Enhance cache management and UI: implement multi-team cache claiming, add admin waitlist functionality, and improve leaderboard display for better user experience

This commit is contained in:
bobbert committed 2026-04-08 20:42:11 +01:00
1 parent e34cbba9b5
commit 9a9db74369
16 files changed
+636 -129

No files matched your search

+21 -5
View File
@@ -2,12 +2,12 @@ import {StyleSheet, Text, View} from 'react-native';
import Card from '../UI/Card';
import {Button} from '../UI/Button';
const CacheCardItem = ({cache, isAdmin, onEdit, onDelete, onSelect}) => {
const CacheCardItem = ({cache, isAdmin, isSelected, onEdit, onDelete, onSelect}) => {
// Initialisation -------------
const isClaimed = Boolean(cache.ClaimedByUid);
const statusText = isClaimed ? 'Claimed' : 'Available';
const statusColor = isClaimed ? '#9ca3af' : '#16a34a';
const hasClaims = (cache.Claims || []).length > 0;
const statusText = hasClaims ? 'Claimed' : 'Available';
const statusColor = hasClaims ? '#9ca3af' : '#16a34a';
const displayName = cache.name || cache.clue || 'Unnamed Cache';
// State ----------------------
@@ -15,7 +15,7 @@ const CacheCardItem = ({cache, isAdmin, onEdit, onDelete, onSelect}) => {
// View -----------------------
return (
<Card>
<Card style={isSelected ? styles.selectedCard : null}>
<View style={styles.topRow}>
<View style={styles.nameWrap}>
<Text style={styles.name} numberOfLines={1}>{displayName}</Text>
@@ -41,6 +41,13 @@ const CacheCardItem = ({cache, isAdmin, onEdit, onDelete, onSelect}) => {
styleLabel={styles.btnLabel}
/>
</>
) : isSelected ? (
<Button
label="Cache Currently Selected"
onClick={() => {}}
styleButton={styles.selectedBtn}
styleLabel={styles.selectedBtnLabel}
/>
) : (
<Button
label="Select Cache"
@@ -87,6 +94,15 @@ const styles = StyleSheet.create({
paddingHorizontal: 14,
flex: 1,
},
selectedBtn: {
backgroundColor: '#6b7280',
borderColor: '#6b7280',
minHeight: 36,
paddingHorizontal: 14,
flex: 1,
},
selectedBtnLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
selectedCard: {borderColor: '#2563eb', borderWidth: 2},
btnLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
});
+32 -7
View File
@@ -1,30 +1,42 @@
import React, {useEffect, useState} from 'react';
import React, {useEffect, useRef, useState} from 'react';
import {StyleSheet, Text, View} from 'react-native';
import {CACHE_CLAIM_TIMER} from '../../utils/geoMath';
const ClaimTimerView = ({ cache, onClaimSuccess, isClaiming }) => {
const ClaimTimerView = ({ cache, onClaimSuccess, isClaiming, showClaimedPopup }) => {
// Initialisation -------------
// State ----------------------
const [timeLeft, setTimeLeft] = useState(CACHE_CLAIM_TIMER);
// Use a ref for the callback so it does not appear in the dependency array
const onClaimRef = useRef(onClaimSuccess);
onClaimRef.current = onClaimSuccess;
// Handlers -------------------
useEffect(() => {
let timer;
if (isClaiming && timeLeft > 0) {
if (isClaiming && cache && timeLeft > 0) {
timer = setTimeout(() => setTimeLeft((prev) => prev - 1), 1000);
} else if (isClaiming && timeLeft === 0 && cache) {
onClaimSuccess(cache.id);
} else {
onClaimRef.current(cache.id);
} else if (!isClaiming) {
setTimeLeft(CACHE_CLAIM_TIMER);
}
return () => clearTimeout(timer);
}, [isClaiming, timeLeft, cache, onClaimSuccess]);
}, [isClaiming, timeLeft, cache]);
// View -----------------------
if (!isClaiming) {
// Show "Cache Claimed!" popup after a successful claim
if (showClaimedPopup) {
return (
<View style={styles.claimedOverlay}>
<Text style={styles.claimedText}>Cache Claimed!</Text>
</View>
);
}
if (!isClaiming || !cache) {
return null;
}
@@ -49,6 +61,19 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '700',
},
claimedOverlay: {
alignSelf: 'center',
backgroundColor: '#16a34a',
paddingHorizontal: 20,
paddingVertical: 14,
borderRadius: 10,
marginTop: 8,
},
claimedText: {
color: '#ffffff',
fontSize: 18,
fontWeight: '700',
},
});
export default ClaimTimerView;
+19 -10
View File
@@ -1,7 +1,8 @@
import MapView, {Circle, Polygon} from 'react-native-maps';
import React from 'react';
import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
import {getFovCone} from '../../utils/geoMath';
const PlayerMapView = ({userLocation, visibleCache, heading, claimDistance}) => {
const PlayerMapView = ({userLocation, visibleCaches, heading, claimDistance}) => {
// Initialisation -------------
// State ----------------------
// Handlers -------------------
@@ -23,14 +24,22 @@ const PlayerMapView = ({userLocation, visibleCache, heading, claimDistance}) =>
pitchEnabled={false}
showsUserLocation
>
{visibleCache && (
<Circle
center={visibleCache.coordinates}
radius={claimDistance}
fillColor="rgba(250, 204, 21, 0.20)"
strokeColor="rgba(250, 204, 21, 0.90)"
/>
)}
{/* Show cache markers for all caches currently inside the FOV cone */}
{(visibleCaches || []).map((cache) => (
<React.Fragment key={cache.id}>
<Marker
coordinate={cache.coordinates}
title={cache.clue}
pinColor="#facc15"
/>
<Circle
center={cache.coordinates}
radius={claimDistance}
fillColor="rgba(250, 204, 21, 0.20)"
strokeColor="rgba(250, 204, 21, 0.90)"
/>
</React.Fragment>
))}
{coneCoords && (
<Polygon
coordinates={coneCoords}
+1
View File
@@ -81,6 +81,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'space-around',
paddingHorizontal: 8,
zIndex: 10,
},
tab: {
flex: 1,
+2
View File
@@ -48,6 +48,8 @@ const styles = StyleSheet.create({
content: {
flex: 1,
padding: 15,
zIndex: 1,
overflow: "hidden",
},
});
+1
View File
@@ -53,6 +53,7 @@ const styles = StyleSheet.create({
justifyContent: 'space-between',
paddingHorizontal: 12,
backgroundColor: '#000000',
zIndex: 10,
},
leftSlot: {
width: 72,
+23 -2
View File
@@ -3,7 +3,7 @@ import {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} from '../../utils/geoMath';
import {getFovCone, isInClaimCone} from '../../utils/geoMath';
const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.01, longitudeDelta: 0.01};
@@ -70,6 +70,11 @@ const ExpandedMapScreen = ({route}) => {
? 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))
: [];
return (
<Screen showBack={true} style={styles.container}>
<View style={styles.mapWrap}>
@@ -78,7 +83,7 @@ const ExpandedMapScreen = ({route}) => {
initialRegion={region}
showsUserLocation
>
{/* Only admins see cache pin locations */}
{/* Admins see all cache pin locations */}
{isAdmin && caches.map((cache) => (
<React.Fragment key={cache.id}>
<Marker
@@ -94,6 +99,22 @@ const ExpandedMapScreen = ({route}) => {
/>
</React.Fragment>
))}
{/* Players only see caches that are inside the FOV cone */}
{!isAdmin && visiblePlayerCaches.map((cache) => (
<React.Fragment key={cache.id}>
<Marker
coordinate={cache.coordinates}
title={cache.clue}
pinColor="#facc15"
/>
<Circle
center={cache.coordinates}
radius={claimDistance}
fillColor="rgba(250, 204, 21, 0.20)"
strokeColor="rgba(250, 204, 21, 0.90)"
/>
</React.Fragment>
))}
{/* Heading FOV cone */}
{coneCoords && (
<Polygon
+99 -2
View File
@@ -1,6 +1,7 @@
import React, {useEffect, useState} from 'react';
import {ActivityIndicator, ScrollView, StyleSheet, Switch, Text, TextInput, View} from 'react-native';
import {ActivityIndicator, Alert, ScrollView, StyleSheet, Switch, Text, TextInput, View} from 'react-native';
import Screen from '../layout/Screen';
import Card from '../UI/Card';
import {Button, ButtonTray} from '../UI/Button';
import useGameHook from '../../hooks/useGameHook';
import {getSession, setSessionTeamsEnabled} from '../../hooks/SessionStore';
@@ -9,7 +10,7 @@ const GameSettingsScreen = () => {
// Initialisation ------------
const session = getSession();
const {getLobby, updateGroup, getSubgroups} = useGameHook();
const {getLobby, updateGroup, getSubgroups, resetGame, getAdminWaitlist, approveAdmin, rejectAdmin, getUser} = useGameHook();
const isBusiness = Boolean(session.isBusiness);
// State ----------------------
@@ -22,9 +23,23 @@ const GameSettingsScreen = () => {
const [cacheTriggerMeters, setCacheTriggerMeters] = useState('20');
const [adminJoinCode, setAdminJoinCode] = useState('');
const [memberJoinCode, setMemberJoinCode] = useState('');
const [waitlist, setWaitlist] = useState([]);
const [waitlistNames, setWaitlistNames] = useState({});
// Handlers -------------------
const loadWaitlist = async () => {
if (!session.currentGid) return;
const rows = await getAdminWaitlist(session.currentGid);
setWaitlist(rows || []);
const names = {};
for (const entry of (rows || [])) {
const u = await getUser(entry.Uid);
if (u) names[entry.Uid] = u.username;
}
setWaitlistNames(names);
};
useEffect(() => {
const load = async () => {
if (!session.currentGid) {
@@ -45,6 +60,7 @@ const GameSettingsScreen = () => {
const sgs = await getSubgroups(session.currentGid);
const memberSg = (sgs || []).find((sg) => !sg.IsAdminGroup);
if (memberSg) setMemberJoinCode(memberSg.JoinCode || '');
await loadWaitlist();
setLoading(false);
};
load();
@@ -64,6 +80,30 @@ const GameSettingsScreen = () => {
setSaving(false);
};
const handleResetGame = () => {
Alert.alert('Reset Entire Game', 'This will clear ALL cache claims for every player. Are you sure?', [
{text: 'Cancel', style: 'cancel'},
{
text: 'Reset',
style: 'destructive',
onPress: async () => {
await resetGame(session.currentGid);
Alert.alert('Done', 'All cache claims have been reset.');
},
},
]);
};
const handleApproveAdmin = async (entry) => {
await approveAdmin(entry.id);
await loadWaitlist();
};
const handleRejectAdmin = async (entry) => {
await rejectAdmin(entry.id);
await loadWaitlist();
};
// View -----------------------
if (loading) {
@@ -150,6 +190,46 @@ const GameSettingsScreen = () => {
/>
</ButtonTray>
</View>
{/* Admin Waitlist */}
{waitlist.length > 0 && (
<View style={styles.waitlistSection}>
<Text style={styles.sectionTitle}>Admin Waitlist</Text>
{waitlist.map((entry) => (
<Card key={entry.id}>
<View style={styles.waitlistRow}>
<Text style={styles.waitlistName}>
{waitlistNames[entry.Uid] || `User ${entry.Uid}`}
</Text>
<View style={styles.waitlistActions}>
<Button
label="Approve"
onClick={() => handleApproveAdmin(entry)}
styleButton={styles.approveButton}
styleLabel={styles.approveBtnLabel}
/>
<Button
label="Reject"
onClick={() => handleRejectAdmin(entry)}
styleButton={styles.rejectButton}
styleLabel={styles.rejectBtnLabel}
/>
</View>
</View>
</Card>
))}
</View>
)}
{/* Reset Entire Game */}
<View style={styles.resetSection}>
<Button
label="Reset Entire Game"
onClick={handleResetGame}
styleButton={styles.resetButton}
styleLabel={styles.resetLabel}
/>
</View>
</ScrollView>
</Screen>
);
@@ -196,6 +276,23 @@ const styles = StyleSheet.create({
saveWrap: {marginTop: 24},
saveButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'},
saveLabel: {color: '#ffffff', fontWeight: '600'},
// Admin waitlist styles
waitlistSection: {marginTop: 30},
waitlistRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
waitlistName: {fontSize: 15, fontWeight: '600', color: '#1f2937', flex: 1},
waitlistActions: {flexDirection: 'row', gap: 6},
approveButton: {backgroundColor: '#16a34a', borderColor: '#16a34a', minHeight: 36, flex: 0, paddingHorizontal: 10},
approveBtnLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
rejectButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36, flex: 0, paddingHorizontal: 10},
rejectBtnLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
// Reset game styles
resetSection: {marginTop: 30},
resetButton: {backgroundColor: '#dc2626', borderColor: '#dc2626'},
resetLabel: {color: '#ffffff', fontWeight: '600'},
});
export default GameSettingsScreen;
+96 -52
View File
@@ -9,15 +9,17 @@ const LeaderboardScreen = () => {
// Initialisation ------------
const session = getSession();
const isAdmin = session.isAcceptedAdmin;
const {getLobby, getTeams, getTeamMembers, getGroupMembers, getCaches, getUser} = useGameHook();
// State ----------------------
const [loading, setLoading] = useState(true);
const [teamsEnabled, setTeamsEnabled] = useState(false);
const [activeTab, setActiveTab] = useState('global');
const [globalRanking, setGlobalRanking] = useState([]);
const [teamRanking, setTeamRanking] = useState([]);
const [activeTab, setActiveTab] = useState('game');
const [teamRankings, setTeamRankings] = useState([]);
const [playerRankings, setPlayerRankings] = useState([]);
const [myTeamRanking, setMyTeamRanking] = useState([]);
const [expandedTeam, setExpandedTeam] = useState(null);
// Handlers -------------------
@@ -30,7 +32,6 @@ const LeaderboardScreen = () => {
setTeamsEnabled(isTeams);
const caches = await getCaches(session.currentGid, null);
const claimedCaches = (caches || []).filter((c) => c.ClaimedByUid);
if (isTeams) {
// Build team rankings
@@ -42,7 +43,9 @@ const LeaderboardScreen = () => {
const memberData = [];
for (const m of (members || [])) {
const user = await getUser(m.Uid);
const count = claimedCaches.filter((c) => c.ClaimedByUid === m.Uid).length;
const count = (caches || []).filter((c) =>
(c.Claims || []).some((cl) => cl.Uid === m.Uid)
).length;
memberData.push({
uid: m.Uid,
name: user?.username || `Player ${m.Uid}`,
@@ -51,7 +54,9 @@ const LeaderboardScreen = () => {
});
}
memberData.sort((a, b) => b.caches - a.caches);
const totalCaches = memberData.reduce((sum, m) => sum + m.caches, 0);
const totalCaches = (caches || []).filter((c) =>
(c.Claims || []).some((cl) => cl.Tid === team.Tid)
).length;
teamData.push({
tid: team.Tid,
name: team.TeamName || 'Unnamed Team',
@@ -62,20 +67,22 @@ const LeaderboardScreen = () => {
}
teamData.sort((a, b) => b.totalCaches - a.totalCaches);
setGlobalRanking(teamData);
setTeamRankings(teamData);
// Build current team ranking
// Build current user's team ranking (for TEAM tab)
if (session.currentTid) {
const myTeam = teamData.find((t) => t.tid === session.currentTid);
setTeamRanking(myTeam ? myTeam.members : []);
setMyTeamRanking(myTeam ? myTeam.members : []);
}
} else {
// Build player rankings (no teams)
const allMembers = await getGroupMembers(session.currentGid);
const playerData = [];
for (const m of (allMembers || []).filter((m) => !m.IsAcceptedAdmin)) {
for (const m of (allMembers || []).filter((mem) => !mem.IsAcceptedAdmin)) {
const user = await getUser(m.Uid);
const count = claimedCaches.filter((c) => c.ClaimedByUid === m.Uid).length;
const count = (caches || []).filter((c) =>
(c.Claims || []).some((cl) => cl.Uid === m.Uid)
).length;
playerData.push({
uid: m.Uid,
name: user?.username || `Player ${m.Uid}`,
@@ -83,7 +90,7 @@ const LeaderboardScreen = () => {
});
}
playerData.sort((a, b) => b.caches - a.caches);
setGlobalRanking(playerData);
setPlayerRankings(playerData);
}
setLoading(false);
@@ -109,64 +116,109 @@ const LeaderboardScreen = () => {
);
}
// Teams enabled — tabbed view
// Helper: render an expandable team card
const renderTeamCard = (team, index) => (
<Pressable key={team.tid} onPress={() => setExpandedTeam(expandedTeam === team.tid ? null : team.tid)}>
<Card>
<View style={styles.rankRow}>
<Text style={styles.rank}>#{index + 1}</Text>
<Text style={styles.rankName}>{team.name}</Text>
<Text style={styles.score}>{team.totalCaches} cache(s)</Text>
</View>
{expandedTeam === team.tid && team.members.map((m) => (
<View key={m.uid} style={styles.subRow}>
<Text style={styles.subName}>
{m.name}{m.isLeader ? ' (Team Leader)' : ''}
</Text>
<Text style={styles.subScore}>{m.caches}</Text>
</View>
))}
</Card>
</Pressable>
);
// Helper: render a simple player card
const renderPlayerCard = (player, index) => (
<Card key={player.uid}>
<View style={styles.rankRow}>
<Text style={styles.rank}>#{index + 1}</Text>
<Text style={styles.rankName}>{player.name}</Text>
<Text style={styles.score}>{player.caches} cache(s)</Text>
</View>
</Card>
);
// ---------- ADMIN LEADERBOARD ----------
if (isAdmin) {
// Teams enabled: show team list (no tabs for admin)
if (teamsEnabled) {
return (
<Screen style={styles.container}>
<ScrollView style={styles.listSection}>
{teamRankings.map((team, index) => renderTeamCard(team, index))}
{teamRankings.length === 0 && (
<Text style={styles.emptyText}>No teams yet.</Text>
)}
</ScrollView>
</Screen>
);
}
// Teams disabled: show player list
return (
<Screen style={styles.container}>
<ScrollView style={styles.listSection}>
{playerRankings.map((player, index) => renderPlayerCard(player, index))}
{playerRankings.length === 0 && (
<Text style={styles.emptyText}>No players yet.</Text>
)}
</ScrollView>
</Screen>
);
}
// ---------- PLAYER LEADERBOARD ----------
// Teams enabled — tabbed view (GAME / TEAM)
if (teamsEnabled) {
return (
<Screen style={styles.container}>
<View style={styles.tabRow}>
<Pressable
style={[styles.tabButton, activeTab === 'global' && styles.tabButtonActive]}
onPress={() => setActiveTab('global')}
style={[styles.tabButton, activeTab === 'game' && styles.tabButtonActive]}
onPress={() => setActiveTab('game')}
>
<Text style={[styles.tabLabel, activeTab === 'global' && styles.tabLabelActive]}>Global</Text>
<Text style={[styles.tabLabel, activeTab === 'game' && styles.tabLabelActive]}>GAME</Text>
</Pressable>
<Pressable
style={[styles.tabButton, activeTab === 'team' && styles.tabButtonActive]}
onPress={() => setActiveTab('team')}
>
<Text style={[styles.tabLabel, activeTab === 'team' && styles.tabLabelActive]}>Team</Text>
<Text style={[styles.tabLabel, activeTab === 'team' && styles.tabLabelActive]}>TEAM</Text>
</Pressable>
</View>
{activeTab === 'global' ? (
{activeTab === 'game' ? (
<ScrollView style={styles.listSection}>
{globalRanking.map((team, index) => (
<Pressable key={team.tid} onPress={() => setExpandedTeam(expandedTeam === team.tid ? null : team.tid)}>
<Card>
<View style={styles.rankRow}>
<Text style={styles.rank}>#{index + 1}</Text>
<Text style={styles.rankName}>{team.name}</Text>
<Text style={styles.score}>{team.totalCaches} cache(s)</Text>
</View>
{expandedTeam === team.tid && team.members.map((m) => (
<View key={m.uid} style={styles.subRow}>
<Text style={styles.subName}>
{m.name}{m.isLeader ? ' (Leader)' : ''}
</Text>
<Text style={styles.subScore}>{m.caches}</Text>
</View>
))}
</Card>
</Pressable>
))}
{globalRanking.length === 0 && (
{teamRankings.map((team, index) => renderTeamCard(team, index))}
{teamRankings.length === 0 && (
<Text style={styles.emptyText}>No teams yet.</Text>
)}
</ScrollView>
) : (
<ScrollView style={styles.listSection}>
{teamRanking.map((m, index) => (
{myTeamRanking.map((m, index) => (
<Card key={m.uid}>
<View style={styles.rankRow}>
<Text style={styles.rank}>#{index + 1}</Text>
<Text style={styles.rankName}>
{m.name}{m.isLeader ? ' (Leader)' : ''}
{m.name}{m.isLeader ? ' (Team Leader)' : ''}
</Text>
<Text style={styles.score}>{m.caches} cache(s)</Text>
</View>
</Card>
))}
{teamRanking.length === 0 && (
{myTeamRanking.length === 0 && (
<Text style={styles.emptyText}>Join a team to see your team leaderboard.</Text>
)}
</ScrollView>
@@ -175,20 +227,12 @@ const LeaderboardScreen = () => {
);
}
// No teams — simple player ranking
// No teams — simple player ranking (GAME tab only, no TEAM tab)
return (
<Screen style={styles.container}>
<ScrollView style={styles.listSection}>
{globalRanking.map((player, index) => (
<Card key={player.uid}>
<View style={styles.rankRow}>
<Text style={styles.rank}>#{index + 1}</Text>
<Text style={styles.rankName}>{player.name}</Text>
<Text style={styles.score}>{player.caches} cache(s)</Text>
</View>
</Card>
))}
{globalRanking.length === 0 && (
{playerRankings.map((player, index) => renderPlayerCard(player, index))}
{playerRankings.length === 0 && (
<Text style={styles.emptyText}>No players yet.</Text>
)}
</ScrollView>
+49 -13
View File
@@ -1,4 +1,4 @@
import React, {useCallback, useEffect, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native';
import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
import * as Location from 'expo-location';
@@ -9,7 +9,7 @@ import ClaimTimerView from '../gameplay/ClaimTimerView';
import PlayerMapView from '../gameplay/PlayerMapView';
import useGameHook from '../../hooks/useGameHook';
import usePlayerGame from '../../hooks/usePlayerGame';
import {getSession, setSessionGroup, setSessionUser} from '../../hooks/SessionStore';
import {getSession, setSelectedCache, setSessionGroup, setSessionUser} from '../../hooks/SessionStore';
import {getFovCone} from '../../utils/geoMath';
const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.05, longitudeDelta: 0.05};
@@ -32,6 +32,8 @@ const MapScreen = ({navigation}) => {
const [error, setError] = useState('');
const [gameCode, setGameCode] = useState('');
const [groupInfo, setGroupInfo] = useState(null);
const [selectedCacheId, setSelectedCacheIdState] = useState(session.selectedCacheId);
const [claimedPopupVisible, setClaimedPopupVisible] = useState(false);
// Admin cache creation / editing state
const [isCreating, setIsCreating] = useState(false);
@@ -44,10 +46,23 @@ const MapScreen = ({navigation}) => {
const claimDistance = groupInfo?.CacheTriggerMeters || 20;
const isPlayer = inGame && !isAdmin;
const {visibleCache, isClaiming, setIsClaiming} = usePlayerGame(
// Filter out caches already claimed by the current user's team (or by the user if no team)
const activeCachesForPlayer = useMemo(() => {
if (!isPlayer) return [];
return cacheRecords.filter((cache) => {
const claims = cache.Claims || [];
if (session.currentTid) {
return !claims.some((c) => c.Tid === session.currentTid);
}
return !claims.some((c) => c.Uid === session.currentUid);
});
}, [cacheRecords, isPlayer, session.currentTid, session.currentUid]);
const {visibleCaches, isClaiming, setIsClaiming} = usePlayerGame(
isPlayer ? userLocation : null,
isPlayer ? heading : null,
isPlayer ? cacheRecords : [],
isPlayer ? activeCachesForPlayer : [],
claimDistance,
);
@@ -73,6 +88,17 @@ const MapScreen = ({navigation}) => {
useEffect(() => { loadCaches(); }, [loadCaches]);
// Auto-select the first cache for the player when caches load and none is selected
useEffect(() => {
if (!isPlayer || cacheRecords.length === 0) return;
// If persisted selection still exists in the list, keep it
if (selectedCacheId && cacheRecords.some((c) => c.id === selectedCacheId)) return;
// Otherwise auto-select the first cache
const firstId = cacheRecords[0].id;
setSelectedCacheIdState(firstId);
setSelectedCache(firstId);
}, [cacheRecords, isPlayer]);
// Location tracking
useEffect(() => {
if (!inGame) return;
@@ -118,6 +144,7 @@ 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);
@@ -143,17 +170,22 @@ const MapScreen = ({navigation}) => {
}
};
const handleClaim = async (cacheId) => {
const handleClaim = useCallback(async (cacheId) => {
if (!session.currentGid) return;
await claimCache({
const result = await claimCache({
gid: session.currentGid,
cacheId,
uid: session.currentUid,
tid: session.currentTid,
});
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 = () => {
@@ -215,9 +247,10 @@ const MapScreen = ({navigation}) => {
});
};
// Player — select cache
// Player — select cache (persist selection)
const handleSelectCache = (cache) => {
// Future: scroll map to cache location
setSelectedCacheIdState(cache.id);
setSelectedCache(cache.id);
};
// View -----------------------
@@ -232,7 +265,7 @@ const MapScreen = ({navigation}) => {
placeholder="Enter Game Code"
placeholderTextColor="#9ca3af"
value={gameCode}
onChangeText={setGameCode}
onChangeText={(text) => setGameCode(text.toUpperCase())}
autoCapitalize="characters"
/>
<Button
@@ -439,8 +472,9 @@ const MapScreen = ({navigation}) => {
);
}
// Player in game
// 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}>
@@ -450,7 +484,7 @@ const MapScreen = ({navigation}) => {
<View style={styles.mapContainer}>
<PlayerMapView
userLocation={userLocation}
visibleCache={visibleCache}
visibleCaches={visibleCaches}
heading={heading}
claimDistance={claimDistance}
/>
@@ -459,9 +493,10 @@ const MapScreen = ({navigation}) => {
</Pressable>
</View>
<ClaimTimerView
cache={visibleCache}
cache={claimTarget}
isClaiming={isClaiming}
onClaimSuccess={handleClaim}
showClaimedPopup={claimedPopupVisible}
/>
<View style={styles.cacheSection}>
<ScrollView>
@@ -470,6 +505,7 @@ const MapScreen = ({navigation}) => {
key={cache.id}
cache={cache}
isAdmin={false}
isSelected={selectedCacheId === cache.id}
onSelect={handleSelectCache}
/>
))}
+34 -6
View File
@@ -1,5 +1,5 @@
import React, {useCallback, useEffect, useState} from 'react';
import {ActivityIndicator, ScrollView, StyleSheet, Text, View} from 'react-native';
import {ActivityIndicator, Alert, ScrollView, StyleSheet, Text, View} from 'react-native';
import Screen from '../layout/Screen';
import Card from '../UI/Card';
import {Button} from '../UI/Button';
@@ -10,7 +10,7 @@ const PlayersScreen = () => {
// Initialisation ------------
const session = getSession();
const {getTeams, getTeamMembers, getGroupMembers, removeMember, getUser} = useGameHook();
const {getTeams, getTeamMembers, getGroupMembers, removeMember, resetPlayerProgress, getUser} = useGameHook();
// State ----------------------
@@ -63,6 +63,24 @@ const PlayersScreen = () => {
await loadData();
};
const handleResetPlayerProgress = (player) => {
Alert.alert(
'Reset Progress',
`Reset all cache claims for ${player.username}?`,
[
{text: 'Cancel', style: 'cancel'},
{
text: 'Reset',
style: 'destructive',
onPress: async () => {
await resetPlayerProgress(session.currentGid, player.Uid);
Alert.alert('Done', `Progress for ${player.username} has been reset.`);
},
},
],
);
};
// View -----------------------
if (loading) {
@@ -97,11 +115,19 @@ const PlayersScreen = () => {
<Text style={styles.teamCodeBadge}>{player.team.teamCode}</Text>
)}
</View>
</View>
<View style={styles.actionRow}>
<Button
label="Reset Progress"
onClick={() => handleResetPlayerProgress(player)}
styleButton={styles.resetButton}
styleLabel={styles.actionLabel}
/>
<Button
label="Kick"
onClick={() => handleRemovePlayer(player.membershipId)}
styleButton={styles.removeButton}
styleLabel={styles.removeLabel}
styleLabel={styles.actionLabel}
/>
</View>
</Card>
@@ -132,14 +158,16 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 8,
},
playerName: {fontSize: 15, fontWeight: '600', color: '#1f2937', flex: 1},
playerMeta: {flexDirection: 'row', alignItems: 'center', gap: 6},
leaderBadge: {fontSize: 11, color: '#2563eb', fontWeight: '700'},
teamCodeBadge: {fontSize: 12, fontWeight: '700', color: '#16a34a', letterSpacing: 1},
removeButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36, flex: 0, paddingHorizontal: 12},
removeLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
actionRow: {flexDirection: 'row', gap: 8},
resetButton: {backgroundColor: '#f59e0b', borderColor: '#f59e0b', minHeight: 36, flex: 1, paddingHorizontal: 10},
removeButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36, flex: 1, paddingHorizontal: 10},
actionLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
});
export default PlayersScreen;
+38 -3
View File
@@ -11,7 +11,7 @@ const TeamScreen = () => {
const session = getSession();
const isAdmin = session.isAcceptedAdmin;
const {getTeam, createTeam, joinTeamByCode, getTeamMembers, leaveTeam, getUser, updateUser} = useGameHook();
const {getTeam, createTeam, joinTeamByCode, getTeamMembers, leaveTeam, getUser, updateUser, getAdminWaitlist} = useGameHook();
// State ----------------------
@@ -21,6 +21,7 @@ const TeamScreen = () => {
const [members, setMembers] = useState([]);
const [memberNames, setMemberNames] = useState({});
const [loading, setLoading] = useState(true);
const [onAdminWaitlist, setOnAdminWaitlist] = useState(false);
// Handlers -------------------
@@ -39,6 +40,15 @@ const TeamScreen = () => {
useEffect(() => {
const load = async () => {
// Check if the user is on the admin waitlist
if (session.currentGid && !activeTid) {
const waitlist = await getAdminWaitlist(session.currentGid, session.currentUid);
if (waitlist.length > 0) {
setOnAdminWaitlist(true);
setLoading(false);
return;
}
}
if (activeTid) await loadTeamData(activeTid);
setLoading(false);
};
@@ -47,8 +57,17 @@ const TeamScreen = () => {
const handleJoinTeam = async () => {
if (!teamCode.trim()) return;
const result = await joinTeamByCode({JoinCode: teamCode.trim().toUpperCase(), Uid: session.currentUid});
// Force uppercase just in case a lowercase code is pasted
const code = teamCode.trim().toUpperCase();
const result = await joinTeamByCode({JoinCode: code, Uid: session.currentUid});
if (!result) return;
// If the server indicates this code matched the admin join code
if (result.adminWaitlist) {
setOnAdminWaitlist(true);
return;
}
setSessionTeam(result.Tid);
setActiveTid(result.Tid);
};
@@ -95,6 +114,15 @@ const TeamScreen = () => {
);
}
// Waiting for admin approval
if (onAdminWaitlist) {
return (
<Screen style={styles.center}>
<Text style={styles.waitlistText}>Waiting to be approved for Admin Team</Text>
</Screen>
);
}
// Not in a team
if (!activeTid || !team) {
return (
@@ -105,7 +133,7 @@ const TeamScreen = () => {
placeholder="Enter Team Code"
placeholderTextColor="#9ca3af"
value={teamCode}
onChangeText={setTeamCode}
onChangeText={(text) => setTeamCode(text.toUpperCase())}
autoCapitalize="characters"
/>
<Button
@@ -195,6 +223,13 @@ const styles = StyleSheet.create({
joinLabel: {color: '#ffffff', fontWeight: '600'},
createButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'},
createLabel: {color: '#ffffff', fontWeight: '600'},
waitlistText: {
fontSize: 18,
fontWeight: '700',
color: '#374151',
textAlign: 'center',
paddingHorizontal: 30,
},
teamHeader: {
backgroundColor: '#374151',
paddingVertical: 14,
+8 -1
View File
@@ -12,6 +12,7 @@ const session = {
isBusiness: null,
isAcceptedAdmin: false,
teamsEnabled: false,
selectedCacheId: null,
};
// --- Handlers ---
@@ -44,6 +45,11 @@ const setSessionTeamsEnabled = (val) => {
persistSession();
};
const setSelectedCache = (cacheId) => {
session.selectedCacheId = cacheId ?? null;
persistSession();
};
const getSession = () => ({ ...session });
const clearSession = () => {
@@ -63,6 +69,7 @@ const clearGameSession = () => {
session.currentTid = null;
session.isAcceptedAdmin = false;
session.teamsEnabled = false;
session.selectedCacheId = null;
persistSession();
};
@@ -85,4 +92,4 @@ const loadSession = async () => {
return { ...session };
};
export { setSessionUser, setSessionGroup, setSessionTeam, setSessionTeamsEnabled, getSession, clearSession, clearGameSession, loadSession };
export { setSessionUser, setSessionGroup, setSessionTeam, setSessionTeamsEnabled, setSelectedCache, getSession, clearSession, clearGameSession, loadSession };
+40 -3
View File
@@ -10,6 +10,7 @@ const useGameHook = () => {
const teamsEndpoint = `${API_BASE_URL}/teams`;
const teamMembersEndpoint = `${API_BASE_URL}/team-members`;
const gameDataEndpoint = `${API_BASE_URL}/game-data`;
const adminWaitlistEndpoint = `${API_BASE_URL}/admin-waitlist`;
// Handlers -------------------
@@ -116,10 +117,11 @@ const useGameHook = () => {
return response.isSuccess ? response.result : null;
};
// Claim a cache via the multi-team claim endpoint
const claimCache = async (payload) => {
const response = await API.put(
`${gameDataEndpoint}/${payload.gid}/caches/${payload.cacheId}`,
{ClaimedByUid: payload.uid, ClaimedByTid: payload.tid},
const response = await API.post(
`${gameDataEndpoint}/${payload.gid}/caches/${payload.cacheId}/claim`,
{Uid: payload.uid, Tid: payload.tid},
);
return response.isSuccess ? response.result : null;
};
@@ -129,6 +131,36 @@ const useGameHook = () => {
return response.isSuccess;
};
// Game reset — clear all claims for a game
const resetGame = async (gid) => {
const response = await API.post(`${gameDataEndpoint}/${gid}/reset`, {});
return response.isSuccess;
};
// Player progress reset — clear all claims by a specific user
const resetPlayerProgress = async (gid, uid) => {
const response = await API.post(`${gameDataEndpoint}/${gid}/reset-player/${uid}`, {});
return response.isSuccess;
};
// Admin waitlist
const getAdminWaitlist = async (gid, uid = null) => {
let url = `${adminWaitlistEndpoint}?Gid=${gid}`;
if (uid !== null) url += `&Uid=${uid}`;
const response = await API.get(url);
return response.isSuccess ? response.result : [];
};
const approveAdmin = async (waitlistId) => {
const response = await API.post(`${adminWaitlistEndpoint}/${waitlistId}/approve`, {});
return response.isSuccess ? response.result : null;
};
const rejectAdmin = async (waitlistId) => {
const response = await API.delete(`${adminWaitlistEndpoint}/${waitlistId}`);
return response.isSuccess;
};
// Return ---------------------
return {
@@ -151,6 +183,11 @@ const useGameHook = () => {
upsertCache,
claimCache,
deleteCache,
resetGame,
resetPlayerProgress,
getAdminWaitlist,
approveAdmin,
rejectAdmin,
};
};
+18 -10
View File
@@ -1,34 +1,42 @@
import {useEffect, useState} from 'react';
import {useEffect, useRef, useState} from 'react';
import {isInClaimCone} from '../utils/geoMath';
const usePlayerGame = (playerLocation, playerHeading, activeCaches, claimDistance) => {
// State ----------------------
const [visibleCache, setVisibleCache] = useState(null);
const [visibleCaches, setVisibleCaches] = useState([]);
const [isClaiming, setIsClaiming] = useState(false);
const prevIdsRef = useRef('');
// Handlers -------------------
useEffect(() => {
if (!playerLocation || playerHeading === null || playerHeading === undefined) {
setVisibleCache(null);
setIsClaiming(false);
if (prevIdsRef.current !== '') {
prevIdsRef.current = '';
setVisibleCaches([]);
setIsClaiming(false);
}
return;
}
// Find the first cache that falls inside the invisible claim cone
// (same FOV angle as the visible cone, but radius = admin-set claimDistance)
const cacheInCone = (activeCaches || []).find((cache) =>
// Find all caches that fall inside the claim cone
const inCone = (activeCaches || []).filter((cache) =>
isInClaimCone(playerHeading, playerLocation, cache.coordinates, claimDistance)
);
setVisibleCache(cacheInCone || null);
setIsClaiming(Boolean(cacheInCone));
// 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);
setIsClaiming(inCone.length > 0);
}
}, [playerLocation, playerHeading, activeCaches, claimDistance]);
// Return ---------------------
return {visibleCache, isClaiming, setIsClaiming};
return {visibleCaches, isClaiming, setIsClaiming};
};
export default usePlayerGame;