Enhance player and team management: add admin approval/denial for waitlisted users, conditionally render UI elements based on game status, and improve button states for better user experience

This commit is contained in:
bobbert committed 2026-04-09 01:51:54 +01:00
1 parent 70ed43f12b
commit d60f428383
5 files changed
+109 -28

No files matched your search

+10 -3
View File
@@ -1,15 +1,15 @@
import {Pressable, StyleSheet, Text, View} from "react-native"; import {Pressable, StyleSheet, Text, View} from "react-native";
export const Button = ({ label, icon, onClick, styleLabel, styleButton }) => { export const Button = ({ label, icon, onClick, styleLabel, styleButton, disabled }) => {
// Initialisation ------------- // Initialisation -------------
// State ---------------------- // State ----------------------
// Handlers ------------------- // Handlers -------------------
// View ----------------------- // View -----------------------
return ( return (
<Pressable onPress={onClick} style={[styles.button, styleButton]}> <Pressable onPress={onClick} disabled={disabled} style={[styles.button, styleButton, disabled && styles.buttonDisabled]}>
{icon ? icon : null} {icon ? icon : null}
<Text style={[styles.label, styleLabel]}>{label}</Text> <Text style={[styles.label, styleLabel, disabled && styles.labelDisabled]}>{label}</Text>
</Pressable> </Pressable>
); );
}; };
@@ -44,5 +44,12 @@ const styles = StyleSheet.create({
flexDirection: "row", flexDirection: "row",
gap: 15, gap: 15,
}, },
// Greyed-out state for buttons that should not be interactive
buttonDisabled: {
opacity: 0.4,
},
labelDisabled: {
color: '#9ca3af',
},
}); });
+4 -1
View File
@@ -16,11 +16,14 @@ const BottomNavbar = ({navigation, routeName}) => {
{label: 'Leaderboard', route: 'LeaderboardScreen', onPress: () => navigation.navigate('LeaderboardScreen')}, {label: 'Leaderboard', route: 'LeaderboardScreen', onPress: () => navigation.navigate('LeaderboardScreen')},
]; ];
// Only show the Leave tab when the player is actually in a game
const inGame = Boolean(session.currentGid);
const playerTabs = [ const playerTabs = [
{label: 'Team', route: 'TeamScreen', onPress: () => navigation.navigate('TeamScreen')}, {label: 'Team', route: 'TeamScreen', onPress: () => navigation.navigate('TeamScreen')},
{label: 'Map', route: 'MapScreen', onPress: () => navigation.navigate('MapScreen')}, {label: 'Map', route: 'MapScreen', onPress: () => navigation.navigate('MapScreen')},
{label: 'Leaderboard', route: 'LeaderboardScreen', onPress: () => navigation.navigate('LeaderboardScreen')}, {label: 'Leaderboard', route: 'LeaderboardScreen', onPress: () => navigation.navigate('LeaderboardScreen')},
{label: 'Leave', route: '__leave__', onPress: () => handleLeaveGame()}, ...(inGame ? [{label: 'Leave', route: '__leave__', onPress: () => handleLeaveGame()}] : []),
]; ];
const tabs = session.isAcceptedAdmin ? adminTabs : playerTabs; const tabs = session.isAcceptedAdmin ? adminTabs : playerTabs;
+7 -3
View File
@@ -49,9 +49,13 @@ const MapScreen = ({navigation}) => {
const isPlayer = inGame && !isAdmin; const isPlayer = inGame && !isAdmin;
// 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) // Filter out caches already claimed by the current user's team (or by the user if no team)
const activeCachesForPlayer = useMemo(() => { const activeCachesForPlayer = useMemo(() => {
if (!isPlayer) return []; if (!isPlayer) return EMPTY_CACHES;
return cacheRecords.filter((cache) => { return cacheRecords.filter((cache) => {
const claims = cache.Claims || []; const claims = cache.Claims || [];
if (session.currentTid) { if (session.currentTid) {
@@ -59,12 +63,12 @@ const MapScreen = ({navigation}) => {
} }
return !claims.some((c) => c.Uid === session.currentUid); return !claims.some((c) => c.Uid === session.currentUid);
}); });
}, [cacheRecords, isPlayer, session.currentTid, session.currentUid]); }, [cacheRecords, isPlayer, session.currentTid, session.currentUid, EMPTY_CACHES]);
const {visibleCaches, isClaiming, setIsClaiming} = usePlayerGame( const {visibleCaches, isClaiming, setIsClaiming} = usePlayerGame(
isPlayer ? userLocation : null, isPlayer ? userLocation : null,
isPlayer ? heading : null, isPlayer ? heading : null,
isPlayer ? activeCachesForPlayer : [], activeCachesForPlayer,
claimDistance, claimDistance,
); );
+75 -20
View File
@@ -10,7 +10,7 @@ const PlayersScreen = () => {
// Initialisation ------------ // Initialisation ------------
const session = getSession(); const session = getSession();
const {getTeams, getTeamMembers, getGroupMembers, removeMember, resetPlayerProgress, getUser, getAdminWaitlist} = useGameHook(); const {getTeams, getTeamMembers, getGroupMembers, removeMember, resetPlayerProgress, getUser, getAdminWaitlist, approveAdmin, rejectAdmin} = useGameHook();
// State ---------------------- // State ----------------------
@@ -28,6 +28,13 @@ const PlayersScreen = () => {
// Get admin waitlist entries // Get admin waitlist entries
const waitlist = await getAdminWaitlist(session.currentGid); const waitlist = await getAdminWaitlist(session.currentGid);
// Deduplicate allMembers by Uid — a user may have two rows if they were a
// regular member before being approved as an admin. Prefer the admin row.
const seenUids = new Set();
const uniqueMembers = (allMembers || [])
.sort((a, b) => (b.IsAcceptedAdmin ? 1 : 0) - (a.IsAcceptedAdmin ? 1 : 0))
.filter((m) => { if (seenUids.has(m.Uid)) return false; seenUids.add(m.Uid); return true; });
// Build team lookup: { Uid -> { teamCode, teamName, isLeader } } // Build team lookup: { Uid -> { teamCode, teamName, isLeader } }
const allTeams = await getTeams(session.currentGid); const allTeams = await getTeams(session.currentGid);
const teamInfoByUid = {}; const teamInfoByUid = {};
@@ -44,23 +51,33 @@ const PlayersScreen = () => {
// Build player list — include admins with tags // Build player list — include admins with tags
const playerList = []; const playerList = [];
for (const m of (allMembers || [])) { for (const m of uniqueMembers) {
// Skip the current admin viewing the list // Skip the current admin viewing the list
if (m.Uid === session.currentUid) continue; if (m.Uid === session.currentUid) continue;
const user = await getUser(m.Uid); const user = await getUser(m.Uid);
if (user) { if (user) {
// A group member may also be on the admin waitlist (applied after joining as a player).
// Check the waitlist first so they are treated as pending, not a regular player.
const waitlistEntry = (waitlist || []).find((w) => w.Uid === m.Uid);
let adminTag = null; let adminTag = null;
if (m.IsAcceptedAdmin) adminTag = '[ADMIN]'; let waitlistId = null;
if (waitlistEntry) {
adminTag = '[Admin Awaiting Response]';
waitlistId = waitlistEntry.id;
} else if (m.IsAcceptedAdmin) {
adminTag = '[ADMIN]';
}
playerList.push({ playerList.push({
...user, ...user,
membershipId: m.id, membershipId: m.id,
team: teamInfoByUid[m.Uid] || null, team: teamInfoByUid[m.Uid] || null,
adminTag, adminTag,
waitlistId,
}); });
} }
} }
// Include waitlisted users who are not yet group members // Include waitlisted users who are not yet group members at all
for (const w of (waitlist || [])) { for (const w of (waitlist || [])) {
if (playerList.some((p) => p.Uid === w.Uid)) continue; if (playerList.some((p) => p.Uid === w.Uid)) continue;
if (w.Uid === session.currentUid) continue; if (w.Uid === session.currentUid) continue;
@@ -105,6 +122,18 @@ const PlayersScreen = () => {
); );
}; };
// Approve a waitlisted admin and refresh the list
const handleApproveAdmin = async (player) => {
await approveAdmin(player.waitlistId);
await loadData();
};
// Deny (remove from waitlist) a pending admin and refresh the list
const handleDenyAdmin = async (player) => {
await rejectAdmin(player.waitlistId);
await loadData();
};
// View ----------------------- // View -----------------------
if (loading) { if (loading) {
@@ -128,7 +157,8 @@ const PlayersScreen = () => {
<Text style={styles.sectionTitle}>Players</Text> <Text style={styles.sectionTitle}>Players</Text>
<ScrollView style={styles.listSection}> <ScrollView style={styles.listSection}>
{players.map((player) => ( {players.map((player) => (
<Card key={player.Uid}> // Waitlisted admins get a light red tint so they stand out from regular players
<Card key={player.Uid} style={player.waitlistId ? styles.waitlistCard : undefined}>
<View style={styles.playerRow}> <View style={styles.playerRow}>
<Text style={styles.playerName} numberOfLines={1}> <Text style={styles.playerName} numberOfLines={1}>
{player.username} {player.username}
@@ -148,21 +178,41 @@ const PlayersScreen = () => {
</View> </View>
</View> </View>
<View style={styles.actionRow}> <View style={styles.actionRow}>
{!player.adminTag && ( {/* Waitlisted admins get Approve / Deny instead of the normal player actions */}
<Button {player.waitlistId ? (
label="Reset Progress" <>
onClick={() => handleResetPlayerProgress(player)} <Button
styleButton={styles.resetButton} label="Approve"
styleLabel={styles.actionLabel} onClick={() => handleApproveAdmin(player)}
/> styleButton={styles.approveButton}
)} styleLabel={styles.actionLabel}
{player.membershipId && ( />
<Button <Button
label="Kick" label="Deny"
onClick={() => handleRemovePlayer(player.membershipId)} onClick={() => handleDenyAdmin(player)}
styleButton={styles.removeButton} styleButton={styles.denyButton}
styleLabel={styles.actionLabel} styleLabel={styles.actionLabel}
/> />
</>
) : (
<>
{!player.adminTag && (
<Button
label="Reset Progress"
onClick={() => handleResetPlayerProgress(player)}
styleButton={styles.resetButton}
styleLabel={styles.actionLabel}
/>
)}
{player.membershipId && (
<Button
label="Kick"
onClick={() => handleRemovePlayer(player.membershipId)}
styleButton={styles.removeButton}
styleLabel={styles.actionLabel}
/>
)}
</>
)} )}
</View> </View>
</Card> </Card>
@@ -205,6 +255,11 @@ const styles = StyleSheet.create({
resetButton: {backgroundColor: '#f59e0b', borderColor: '#f59e0b', minHeight: 36, flex: 1, paddingHorizontal: 10}, resetButton: {backgroundColor: '#f59e0b', borderColor: '#f59e0b', minHeight: 36, flex: 1, paddingHorizontal: 10},
removeButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36, flex: 1, paddingHorizontal: 10}, removeButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36, flex: 1, paddingHorizontal: 10},
actionLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13}, actionLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
// Waitlisted admin card tint — light red so they read as pending, not regular players
waitlistCard: {backgroundColor: '#FFD1DC', borderColor: '#fca5a5'},
// Approve / deny buttons for waitlisted admins
approveButton: {backgroundColor: '#16a34a', borderColor: '#16a34a', minHeight: 36, flex: 1, paddingHorizontal: 10},
denyButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36, flex: 1, paddingHorizontal: 10},
}); });
export default PlayersScreen; export default PlayersScreen;
+13 -1
View File
@@ -137,22 +137,30 @@ const TeamScreen = () => {
// Not in a team // Not in a team
if (!activeTid || !team) { if (!activeTid || !team) {
// Disable team actions until the user has joined a game
const notInGame = !session.currentGid;
return ( return (
<Screen style={styles.center}> <Screen style={styles.center}>
{notInGame && (
<Text style={styles.notInGameText}>Join a game first to manage teams.</Text>
)}
<View style={styles.inputRow}> <View style={styles.inputRow}>
<TextInput <TextInput
style={styles.codeInput} style={[styles.codeInput, notInGame && styles.inputDisabled]}
placeholder="Enter Team Code" placeholder="Enter Team Code"
placeholderTextColor="#9ca3af" placeholderTextColor="#9ca3af"
value={teamCode} value={teamCode}
onChangeText={(text) => setTeamCode(text.toUpperCase())} onChangeText={(text) => setTeamCode(text.toUpperCase())}
autoCapitalize="characters" autoCapitalize="characters"
editable={!notInGame}
/> />
<Button <Button
label="Join" label="Join"
onClick={handleJoinTeam} onClick={handleJoinTeam}
styleButton={styles.joinButton} styleButton={styles.joinButton}
styleLabel={styles.joinLabel} styleLabel={styles.joinLabel}
disabled={notInGame}
/> />
</View> </View>
{!isAdmin && ( {!isAdmin && (
@@ -163,6 +171,7 @@ const TeamScreen = () => {
onClick={handleCreateTeam} onClick={handleCreateTeam}
styleButton={styles.createButton} styleButton={styles.createButton}
styleLabel={styles.createLabel} styleLabel={styles.createLabel}
disabled={notInGame}
/> />
</ButtonTray> </ButtonTray>
</View> </View>
@@ -261,6 +270,9 @@ const TeamScreen = () => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
center: {justifyContent: 'center', alignItems: 'center'}, center: {justifyContent: 'center', alignItems: 'center'},
container: {padding: 0}, container: {padding: 0},
// Shown when the user has not yet joined a game
notInGameText: {color: '#6b7280', fontSize: 15, fontWeight: '600', marginBottom: 16, textAlign: 'center', paddingHorizontal: 20},
inputDisabled: {backgroundColor: '#f3f4f6', opacity: 0.5},
inputRow: {flexDirection: 'row', gap: 10, marginBottom: 15, width: '100%', paddingHorizontal: 20}, inputRow: {flexDirection: 'row', gap: 10, marginBottom: 15, width: '100%', paddingHorizontal: 20},
fullRow: {width: '100%', paddingHorizontal: 20}, fullRow: {width: '100%', paddingHorizontal: 20},
codeInput: { codeInput: {