Update API base URL for local development; implement fetch timeout handling for improved request management

This commit is contained in:
bobbert committed 2026-04-06 19:54:43 +01:00
1 parent da7d5ea48d
commit a326c784a5
4 files changed
+362 -38

No files matched your search

+3 -3
View File
@@ -40,15 +40,15 @@ const GameStack = () => (
contentStyle: { backgroundColor: '#ffffff' },
}}
>
<GameStackNav.Screen name="MapScreen" component={MapScreen}/>
<GameStackNav.Screen name="TeamScreen" component={TeamScreen}/>
<GameStackNav.Screen name="LeaderboardScreen" component={LeaderboardScreen}/>
<GameStackNav.Screen name="GameTypeScreen" component={GameTypeScreen}/>
<GameStackNav.Screen name="JoinPrivateGameScreen" component={JoinPrivateGameScreen}/>
<GameStackNav.Screen name="ManagePrivateGameScreen" component={ManagePrivateGameScreen}/>
<GameStackNav.Screen name="CreateGameScreen" component={CreateGameScreen}/>
<GameStackNav.Screen name="ManageGameScreen" component={ManageGameScreen}/>
<GameStackNav.Screen name="GameLobbyScreen" component={GameLobbyScreen}/>
<GameStackNav.Screen name="LeaderboardScreen" component={LeaderboardScreen}/>
<GameStackNav.Screen name="TeamScreen" component={TeamScreen}/>
<GameStackNav.Screen name="MapScreen" component={MapScreen}/>
</GameStackNav.Navigator>
);
+154 -21
View File
@@ -1,49 +1,70 @@
import React, {useCallback, useEffect, useState} from 'react';
import {ActivityIndicator, StyleSheet, Text, View} from 'react-native';
import {ActivityIndicator, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native';
import * as Location from 'expo-location';
import {Magnetometer} from 'expo-sensors';
import Screen from '../layout/Screen';
import {Button, ButtonTray} from '../UI/Button';
import PlayerMapView from '../gameplay/PlayerMapView';
import useGameHook from '../../hooks/useGameHook';
import usePlayerGame from '../../hooks/usePlayerGame';
import {getSession} from '../../hooks/SessionStore';
import {getSession, setSessionGroup, setSessionUser} from '../../hooks/SessionStore';
const toHeading = ({x, y}) => {
const angle = Math.atan2(y, x) * (180 / Math.PI);
return angle < 0 ? angle + 360 : angle;
};
const MapScreen = () => {
const MapScreen = ({navigation}) => {
// Initialisation ------------
const session = getSession();
const {getCaches, claimCache} = useGameHook();
const {getCaches, claimCache, joinPrivateGame, createPrivateGame, getLobby, getUser} = useGameHook();
// State ----------------------
const [inGame, setInGame] = useState(Boolean(session.currentGid));
const [isAdmin, setIsAdmin] = useState(session.isAcceptedAdmin);
const [userLocation, setUserLocation] = useState(null);
const [heading, setHeading] = useState(null);
const [loading, setLoading] = useState(true);
const [cacheRecords, setCacheRecords] = useState([]);
const [error, setError] = useState('');
const [gameCode, setGameCode] = useState('');
const [groupInfo, setGroupInfo] = useState(null);
const subgroupFilter = session.currentSGid;
const {visibleCache, isClaiming, setIsClaiming} = usePlayerGame(userLocation, heading, cacheRecords);
const isPlayer = inGame && !isAdmin;
const {visibleCache, isClaiming, setIsClaiming} = usePlayerGame(
isPlayer ? userLocation : null,
isPlayer ? heading : null,
isPlayer ? cacheRecords : [],
);
// Handlers -------------------
// Load group info for TeamsEnabled check
useEffect(() => {
if (!session.currentGid) return;
getLobby(session.currentGid).then(setGroupInfo);
}, [inGame]);
// Load caches for players only
const loadCaches = useCallback(async () => {
if (!session.currentGid) {
if (!session.currentGid || isAdmin) {
setCacheRecords([]);
return;
}
const rows = await getCaches(session.currentGid, subgroupFilter);
const rows = await getCaches(session.currentGid, session.currentSGid);
setCacheRecords(rows || []);
}, [session.currentGid, subgroupFilter]);
}, [inGame, isAdmin]);
useEffect(() => { loadCaches(); }, [loadCaches]);
// Location + compass tracking for players only
useEffect(() => {
if (!isPlayer) {
setLoading(false);
return;
}
let locationSub;
let headingSub;
@@ -59,23 +80,46 @@ const MapScreen = () => {
locationSub = await Location.watchPositionAsync(
{accuracy: Location.Accuracy.Balanced, distanceInterval: 1, timeInterval: 1000},
(next) => {
setUserLocation({latitude: next.coords.latitude, longitude: next.coords.longitude});
},
(next) => setUserLocation({latitude: next.coords.latitude, longitude: next.coords.longitude}),
);
Magnetometer.setUpdateInterval(500);
headingSub = Magnetometer.addListener((data) => { setHeading(toHeading(data)); });
headingSub = Magnetometer.addListener((data) => setHeading(toHeading(data)));
setLoading(false);
};
start();
return () => {
if (locationSub) locationSub.remove();
if (headingSub) headingSub.remove();
};
}, []);
}, [isPlayer]);
const handleJoinGame = async () => {
if (!gameCode.trim()) return;
const result = await joinPrivateGame({JoinCode: gameCode.trim(), Uid: session.currentUid});
if (!result) return;
setSessionGroup(result.Gid, result.SGid);
setInGame(true);
setIsAdmin(false);
navigation.navigate('TeamScreen');
};
const handleCreateGame = async () => {
const result = await createPrivateGame({
GroupName: 'My Game',
CreatedByUid: session.currentUid,
TeamsEnabled: false,
MaxMemberSubgroups: 1,
});
if (!result) return;
const freshUser = await getUser(session.currentUid);
if (freshUser) {
setSessionUser(freshUser);
setInGame(true);
setIsAdmin(true);
}
};
const handleClaim = async (cacheId) => {
if (!session.currentGid) return;
@@ -91,15 +135,39 @@ const MapScreen = () => {
// View -----------------------
if (!session.currentGid) {
// Not in a game
if (!inGame) {
return (
<Screen style={styles.center}>
<Text style={styles.body}>Join or create a game to use map features.</Text>
<View style={styles.row}>
<TextInput
style={styles.codeInput}
placeholder="Enter Game Code"
value={gameCode}
onChangeText={setGameCode}
autoCapitalize="characters"
/>
<Button
label="Join"
onClick={handleJoinGame}
styleButton={styles.joinButton}
styleLabel={styles.joinLabel}
/>
</View>
<ButtonTray>
<Button
label="Create a Game"
onClick={handleCreateGame}
styleButton={styles.createButton}
styleLabel={styles.createLabel}
/>
</ButtonTray>
</Screen>
);
}
if (session.isAcceptedAdmin) {
// Admin view
if (isAdmin) {
return (
<Screen style={styles.center}>
<Text style={styles.body}>Admins manage caches from Manage Game.</Text>
@@ -107,6 +175,7 @@ const MapScreen = () => {
);
}
// Loading
if (loading) {
return (
<Screen style={styles.center}>
@@ -115,6 +184,7 @@ const MapScreen = () => {
);
}
// Error
if (error) {
return (
<Screen style={styles.center}>
@@ -123,8 +193,14 @@ const MapScreen = () => {
);
}
// Player in game
const teamsWarning = groupInfo?.TeamsEnabled && !session.currentTid;
return (
<Screen style={styles.container}>
<Screen style={styles.containerMap}>
{teamsWarning && (
<Text style={styles.warning}>You are not in a team. Teams are required for this game!</Text>
)}
<View style={styles.mapWrap}>
<PlayerMapView
userLocation={userLocation}
@@ -133,16 +209,73 @@ const MapScreen = () => {
onClaimSuccess={handleClaim}
/>
</View>
<View style={styles.cacheSection}>
<ScrollView>
{cacheRecords.map((cache) => (
<View key={cache.id} style={styles.cacheCard}>
<Text style={styles.cacheClue}>{cache.clue}</Text>
<Text style={[styles.cacheStatus, cache.ClaimedByUid && styles.cacheStatusClaimed]}>
{cache.ClaimedByUid ? 'Claimed' : 'Available'}
</Text>
</View>
))}
{cacheRecords.length === 0 && (
<Text style={styles.emptyText}>No caches available yet.</Text>
)}
</ScrollView>
</View>
</Screen>
);
};
const styles = StyleSheet.create({
container: {padding: 0},
mapWrap: {flex: 1},
center: {justifyContent: 'center', alignItems: 'center'},
containerMap: {padding: 0},
mapWrap: {flex: 2},
body: {color: '#4b5563', fontSize: 15},
error: {color: '#dc2626', fontSize: 15},
row: {flexDirection: 'row', gap: 10, marginBottom: 15, width: '100%', paddingHorizontal: 20},
codeInput: {
flex: 1,
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
},
joinButton: {backgroundColor: '#2563eb', borderColor: '#2563eb', flex: 0, paddingHorizontal: 20},
joinLabel: {color: '#ffffff', fontWeight: '600'},
createButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'},
createLabel: {color: '#ffffff', fontWeight: '600'},
warning: {
color: '#dc2626',
fontWeight: 'bold',
textAlign: 'center',
paddingVertical: 8,
fontSize: 14,
},
cacheSection: {
flex: 1,
backgroundColor: '#f3f4f6',
paddingHorizontal: 12,
paddingTop: 10,
},
cacheCard: {
backgroundColor: '#ffffff',
borderRadius: 8,
padding: 12,
marginBottom: 8,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
borderWidth: 1,
borderColor: '#e5e7eb',
},
cacheClue: {fontSize: 15, fontWeight: '600', color: '#1f2937'},
cacheStatus: {fontSize: 13, color: '#16a34a', fontWeight: '600'},
cacheStatusClaimed: {color: '#9ca3af'},
emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 20, fontSize: 14},
});
export default MapScreen;
+168 -8
View File
@@ -1,25 +1,185 @@
import React from 'react';
import {StyleSheet, Text} from 'react-native';
import React, {useEffect, useState} from 'react';
import {ActivityIndicator, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native';
import Screen from '../layout/Screen';
import {Button, ButtonTray} from '../UI/Button';
import useGameHook from '../../hooks/useGameHook';
import {getSession, setSessionTeam} from '../../hooks/SessionStore';
const TeamScreen = () => {
// Initialisation -------------
// Initialisation ------------
const session = getSession();
const {getTeam, createTeam, joinTeamByCode, getTeamMembers, leaveTeam, getUser, updateUser} = useGameHook();
// State ----------------------
const [activeTid, setActiveTid] = useState(session.currentTid);
const [teamCode, setTeamCode] = useState('');
const [team, setTeam] = useState(null);
const [members, setMembers] = useState([]);
const [memberNames, setMemberNames] = useState({});
const [loading, setLoading] = useState(true);
// Handlers -------------------
useEffect(() => {
const load = async () => {
if (activeTid) {
const t = await getTeam(activeTid);
setTeam(t);
const ms = await getTeamMembers(activeTid);
setMembers(ms || []);
const names = {};
for (const m of (ms || [])) {
const u = await getUser(m.Uid);
if (u) names[m.Uid] = u.username;
}
setMemberNames(names);
}
setLoading(false);
};
load();
}, [activeTid]);
const handleJoinTeam = async () => {
if (!teamCode.trim()) return;
const result = await joinTeamByCode({JoinCode: teamCode.trim(), Uid: session.currentUid});
if (!result) return;
setSessionTeam(result.Tid);
setActiveTid(result.Tid);
};
const handleCreateTeam = async () => {
if (!session.currentGid) return;
const result = await createTeam({Gid: session.currentGid, TeamName: 'My Team'});
if (!result) return;
const membership = await joinTeamByCode({Tid: result.Tid, Uid: session.currentUid});
if (membership) {
setSessionTeam(result.Tid);
setActiveTid(result.Tid);
}
};
const handleLeaveTeam = async () => {
const myMembership = members.find((m) => m.Uid === session.currentUid);
if (!myMembership) return;
await leaveTeam(myMembership.id);
await updateUser(session.currentUid, {TGid: null});
setSessionTeam(null);
setActiveTid(null);
setTeam(null);
setMembers([]);
setMemberNames({});
};
// View -----------------------
if (loading) {
return (
<Screen style={styles.center}>
<ActivityIndicator size="large"/>
</Screen>
);
}
// Not in a team
if (!activeTid || !team) {
return (
<Screen style={styles.center}>
<View style={styles.row}>
<TextInput
style={styles.codeInput}
placeholder="Enter Team Code"
value={teamCode}
onChangeText={setTeamCode}
autoCapitalize="characters"
/>
<Button
label="Join"
onClick={handleJoinTeam}
styleButton={styles.joinButton}
styleLabel={styles.joinLabel}
/>
</View>
<ButtonTray>
<Button
label="Create Team"
onClick={handleCreateTeam}
styleButton={styles.createButton}
styleLabel={styles.createLabel}
/>
</ButtonTray>
</Screen>
);
}
// In a team
return (
<Screen style={styles.container}>
<Text style={styles.title}>Team Management</Text>
<Text style={styles.body}>Team management setup goes here.</Text>
<View style={styles.teamHeader}>
<Text style={styles.teamCodeLabel}>Team Code:</Text>
<Text style={styles.teamCodeValue}>{team.JoinCode}</Text>
</View>
<ScrollView style={styles.memberList}>
{members.map((m) => (
<View key={m.id} style={styles.memberItem}>
<Text style={styles.memberName}>{memberNames[m.Uid] || `Player ${m.Uid}`}</Text>
</View>
))}
{members.length === 0 && (
<Text style={styles.emptyText}>No members yet.</Text>
)}
</ScrollView>
<View style={styles.leaveWrap}>
<Button
label="Leave Team"
onClick={handleLeaveTeam}
styleButton={styles.leaveButton}
styleLabel={styles.leaveLabel}
/>
</View>
</Screen>
);
};
const styles = StyleSheet.create({
container: {justifyContent: 'center', alignItems: 'center'},
title: {fontSize: 24, fontWeight: 'bold'},
body: {color: '#4b5563', fontSize: 15, marginTop: 10},
center: {justifyContent: 'center', alignItems: 'center'},
container: {padding: 0},
row: {flexDirection: 'row', gap: 10, marginBottom: 15, width: '100%', paddingHorizontal: 20},
codeInput: {
flex: 1,
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
},
joinButton: {backgroundColor: '#2563eb', borderColor: '#2563eb', flex: 0, paddingHorizontal: 20},
joinLabel: {color: '#ffffff', fontWeight: '600'},
createButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'},
createLabel: {color: '#ffffff', fontWeight: '600'},
teamHeader: {
backgroundColor: '#374151',
paddingVertical: 14,
paddingHorizontal: 16,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
teamCodeLabel: {color: '#d1d5db', fontSize: 15, fontWeight: '600'},
teamCodeValue: {color: '#ffffff', fontSize: 17, fontWeight: '700', letterSpacing: 2},
memberList: {flex: 1, paddingHorizontal: 15, paddingTop: 10},
memberItem: {
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
memberName: {fontSize: 16, color: '#1f2937'},
emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 30, fontSize: 14},
leaveWrap: {padding: 15},
leaveButton: {backgroundColor: '#dc2626', borderColor: '#dc2626'},
leaveLabel: {color: '#ffffff', fontWeight: '600'},
});
export default TeamScreen;
+37 -6
View File
@@ -3,6 +3,7 @@ import API, {API_BASE_URL} from '../components/API/API';
const useGameHook = () => {
// Initialisation ------------
const usersEndpoint = `${API_BASE_URL}/users`;
const gameTypesEndpoint = `${API_BASE_URL}/game-types`;
const groupsEndpoint = `${API_BASE_URL}/groups`;
const subgroupMembershipsEndpoint = `${API_BASE_URL}/subgroup-memberships`;
@@ -13,11 +14,24 @@ const useGameHook = () => {
// Handlers -------------------
// Users
const getUser = async (uid) => {
const response = await API.get(`${usersEndpoint}/${uid}`);
return response.isSuccess ? response.result : null;
};
const updateUser = async (uid, data) => {
const response = await API.put(`${usersEndpoint}/${uid}`, data);
return response.isSuccess ? response.result : null;
};
// Game types
const getGameTypes = async () => {
const response = await API.get(gameTypesEndpoint);
return response.isSuccess ? response.result : [];
};
// Groups
const getCreatedPrivateGame = async (userId) => {
const response = await API.get(`${groupsEndpoint}?CreatedByUid=${userId}`);
return response.isSuccess ? response.result : [];
@@ -28,6 +42,12 @@ const useGameHook = () => {
return response.isSuccess ? response.result : null;
};
const getLobby = async (gid) => {
const response = await API.get(`${groupsEndpoint}/${gid}`);
return response.isSuccess ? response.result : null;
};
// Subgroup memberships
const joinPrivateGame = async (payload) => {
const response = await API.post(subgroupMembershipsEndpoint, payload);
return response.isSuccess ? response.result : null;
@@ -38,11 +58,7 @@ const useGameHook = () => {
return response.isSuccess ? response.result : null;
};
const getLobby = async (gid) => {
const response = await API.get(`${groupsEndpoint}/${gid}`);
return response.isSuccess ? response.result : null;
};
// Teams
const getTeam = async (tid) => {
const response = await API.get(`${teamsEndpoint}/${tid}`);
return response.isSuccess ? response.result : null;
@@ -58,7 +74,18 @@ const useGameHook = () => {
return response.isSuccess ? response.result : null;
};
// Team members
const getTeamMembers = async (tid) => {
const response = await API.get(`${teamMembersEndpoint}?Tid=${tid}`);
return response.isSuccess ? response.result : [];
};
const leaveTeam = async (membershipId) => {
const response = await API.delete(`${teamMembersEndpoint}/${membershipId}`);
return response.isSuccess;
};
// Caches
const getCaches = async (gid, sgid = null) => {
let url = `${gameDataEndpoint}/${gid}/caches`;
if (sgid !== null) url += `?SGid=${sgid}`;
@@ -92,15 +119,19 @@ const useGameHook = () => {
// Return ---------------------
return {
getUser,
updateUser,
getGameTypes,
getCreatedPrivateGame,
createPrivateGame,
getLobby,
joinPrivateGame,
joinAsAdmin,
getLobby,
getTeam,
createTeam,
joinTeamByCode,
getTeamMembers,
leaveTeam,
getCaches,
upsertCache,
claimCache,