mirror of
https://github.com/mudabbir-ahmad/MAD-Treasure-Hunt.git
synced 2026-10-07 19:00:20 +00:00
Refactor session management to use AsyncStorage for persistence; update game settings and leaderboard screens for improved functionality
This commit is contained in:
1 parent
2646c7b9bc
commit
5183039b7b
25 files changed
+589
-802
No files matched your search
@@ -1,93 +1,82 @@
|
|||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import {ActivityIndicator, View} from "react-native";
|
||||||
import {NavigationContainer} from "@react-navigation/native";
|
import {NavigationContainer} from "@react-navigation/native";
|
||||||
import {createNativeStackNavigator} from "@react-navigation/native-stack";
|
import {createNativeStackNavigator} from "@react-navigation/native-stack";
|
||||||
import {SafeAreaProvider} from "react-native-safe-area-context";
|
import {SafeAreaProvider} from "react-native-safe-area-context";
|
||||||
import LoginScreen from "./src/components/screens/LoginScreen";
|
import LoginScreen from "./src/components/screens/LoginScreen";
|
||||||
import RegisterScreen from "./src/components/screens/RegisterScreen";
|
import RegisterScreen from "./src/components/screens/RegisterScreen";
|
||||||
import GameTypeScreen from "./src/components/screens/GameTypeScreen";
|
|
||||||
import JoinPrivateGameScreen from "./src/components/screens/JoinPrivateGameScreen";
|
|
||||||
import ManagePrivateGameScreen from "./src/components/screens/ManagePrivateGameScreen";
|
|
||||||
import CreateGameScreen from "./src/components/screens/CreateGameScreen";
|
|
||||||
import ManageGameScreen from "./src/components/screens/ManageGameScreen";
|
|
||||||
import GameLobbyScreen from "./src/components/screens/GameLobbyScreen";
|
|
||||||
import TeamScreen from "./src/components/screens/TeamScreen";
|
import TeamScreen from "./src/components/screens/TeamScreen";
|
||||||
import MapScreen from "./src/components/screens/MapScreen";
|
import MapScreen from "./src/components/screens/MapScreen";
|
||||||
import LeaderboardScreen from "./src/components/screens/LeaderboardScreen";
|
import LeaderboardScreen from "./src/components/screens/LeaderboardScreen";
|
||||||
import AdminHubScreen from "./src/components/screens/AdminHubScreen";
|
|
||||||
import ManageSubgroupsScreen from "./src/components/screens/ManageSubgroupsScreen";
|
|
||||||
import GameSettingsScreen from "./src/components/screens/GameSettingsScreen";
|
import GameSettingsScreen from "./src/components/screens/GameSettingsScreen";
|
||||||
import PlayersScreen from "./src/components/screens/PlayersScreen";
|
import PlayersScreen from "./src/components/screens/PlayersScreen";
|
||||||
import ExpandedMapScreen from "./src/components/screens/ExpandedMapScreen";
|
import ExpandedMapScreen from "./src/components/screens/ExpandedMapScreen";
|
||||||
|
import {loadSession} from "./src/hooks/SessionStore";
|
||||||
|
|
||||||
const RootStack = createNativeStackNavigator();
|
const RootStack = createNativeStackNavigator();
|
||||||
const AuthStackNav = createNativeStackNavigator();
|
const AuthStackNav = createNativeStackNavigator();
|
||||||
const GameStackNav = createNativeStackNavigator();
|
const GameStackNav = createNativeStackNavigator();
|
||||||
const AdminStackNav = createNativeStackNavigator();
|
const AdminStackNav = createNativeStackNavigator();
|
||||||
|
|
||||||
const AuthStack = () => (
|
const screenOptions = {
|
||||||
<AuthStackNav.Navigator
|
|
||||||
screenOptions={{
|
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
contentStyle: { backgroundColor: '#ffffff' },
|
contentStyle: {backgroundColor: '#ffffff'},
|
||||||
keyboardHandlingEnabled: false,
|
};
|
||||||
}}
|
|
||||||
>
|
const AuthStack = () => (
|
||||||
|
<AuthStackNav.Navigator screenOptions={{...screenOptions, keyboardHandlingEnabled: false}}>
|
||||||
<AuthStackNav.Screen name="LoginScreen" component={LoginScreen}/>
|
<AuthStackNav.Screen name="LoginScreen" component={LoginScreen}/>
|
||||||
<AuthStackNav.Screen name="RegisterScreen" component={RegisterScreen}/>
|
<AuthStackNav.Screen name="RegisterScreen" component={RegisterScreen}/>
|
||||||
</AuthStackNav.Navigator>
|
</AuthStackNav.Navigator>
|
||||||
);
|
);
|
||||||
|
|
||||||
const GameStack = () => (
|
const GameStack = () => (
|
||||||
<GameStackNav.Navigator
|
<GameStackNav.Navigator screenOptions={screenOptions}>
|
||||||
screenOptions={{
|
|
||||||
headerShown: false,
|
|
||||||
contentStyle: { backgroundColor: '#ffffff' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<GameStackNav.Screen name="MapScreen" component={MapScreen}/>
|
<GameStackNav.Screen name="MapScreen" component={MapScreen}/>
|
||||||
<GameStackNav.Screen name="TeamScreen" component={TeamScreen}/>
|
<GameStackNav.Screen name="TeamScreen" component={TeamScreen}/>
|
||||||
<GameStackNav.Screen name="LeaderboardScreen" component={LeaderboardScreen}/>
|
<GameStackNav.Screen name="LeaderboardScreen" component={LeaderboardScreen}/>
|
||||||
<GameStackNav.Screen name="GameSettingsScreen" component={GameSettingsScreen}/>
|
|
||||||
<GameStackNav.Screen name="PlayersScreen" component={PlayersScreen}/>
|
<GameStackNav.Screen name="PlayersScreen" component={PlayersScreen}/>
|
||||||
<GameStackNav.Screen name="ExpandedMapScreen" component={ExpandedMapScreen}/>
|
<GameStackNav.Screen name="ExpandedMapScreen" component={ExpandedMapScreen}/>
|
||||||
<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.Navigator>
|
</GameStackNav.Navigator>
|
||||||
);
|
);
|
||||||
|
|
||||||
const AdminStack = () => (
|
const AdminStack = () => (
|
||||||
<AdminStackNav.Navigator
|
<AdminStackNav.Navigator screenOptions={screenOptions}>
|
||||||
screenOptions={{
|
|
||||||
headerShown: false,
|
|
||||||
contentStyle: { backgroundColor: '#ffffff' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AdminStackNav.Screen name="GameSettingsScreen" component={GameSettingsScreen}/>
|
<AdminStackNav.Screen name="GameSettingsScreen" component={GameSettingsScreen}/>
|
||||||
<AdminStackNav.Screen name="MapScreen" component={MapScreen}/>
|
<AdminStackNav.Screen name="MapScreen" component={MapScreen}/>
|
||||||
<AdminStackNav.Screen name="LeaderboardScreen" component={LeaderboardScreen}/>
|
<AdminStackNav.Screen name="LeaderboardScreen" component={LeaderboardScreen}/>
|
||||||
<AdminStackNav.Screen name="PlayersScreen" component={PlayersScreen}/>
|
<AdminStackNav.Screen name="PlayersScreen" component={PlayersScreen}/>
|
||||||
<AdminStackNav.Screen name="ExpandedMapScreen" component={ExpandedMapScreen}/>
|
<AdminStackNav.Screen name="ExpandedMapScreen" component={ExpandedMapScreen}/>
|
||||||
<AdminStackNav.Screen name="AdminHubScreen" component={AdminHubScreen}/>
|
|
||||||
<AdminStackNav.Screen name="ManageSubgroupsScreen" component={ManageSubgroupsScreen}/>
|
|
||||||
<AdminStackNav.Screen name="TeamScreen" component={TeamScreen}/>
|
<AdminStackNav.Screen name="TeamScreen" component={TeamScreen}/>
|
||||||
<AdminStackNav.Screen name="CreateGameScreen" component={CreateGameScreen}/>
|
|
||||||
<AdminStackNav.Screen name="ManageGameScreen" component={ManageGameScreen}/>
|
|
||||||
</AdminStackNav.Navigator>
|
</AdminStackNav.Navigator>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const App = () => {
|
export const App = () => {
|
||||||
|
// Load persisted session from AsyncStorage before choosing initial route
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [initialRoute, setInitialRoute] = useState('Auth');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSession().then((session) => {
|
||||||
|
if (session.currentUid) {
|
||||||
|
setInitialRoute(session.isAcceptedAdmin ? 'Admin' : 'Game');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style={{flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#fff'}}>
|
||||||
|
<ActivityIndicator size="large" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<NavigationContainer>
|
<NavigationContainer>
|
||||||
<RootStack.Navigator
|
<RootStack.Navigator initialRouteName={initialRoute} screenOptions={screenOptions}>
|
||||||
screenOptions={{
|
|
||||||
headerShown: false,
|
|
||||||
contentStyle: { backgroundColor: '#ffffff' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RootStack.Screen name="Auth" component={AuthStack}/>
|
<RootStack.Screen name="Auth" component={AuthStack}/>
|
||||||
<RootStack.Screen name="Game" component={GameStack}/>
|
<RootStack.Screen name="Game" component={GameStack}/>
|
||||||
<RootStack.Screen name="Admin" component={AdminStack}/>
|
<RootStack.Screen name="Admin" component={AdminStack}/>
|
||||||
|
|||||||
Generated
+35
-15
@@ -8,6 +8,7 @@
|
|||||||
"name": "mad_treasure_hunt_app",
|
"name": "mad_treasure_hunt_app",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-navigation/native": "^7.1.18",
|
"@react-navigation/native": "^7.1.18",
|
||||||
"@react-navigation/native-stack": "^7.3.28",
|
"@react-navigation/native-stack": "^7.3.28",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
@@ -16,7 +17,6 @@
|
|||||||
"expo-haptics": "~15.0.8",
|
"expo-haptics": "~15.0.8",
|
||||||
"expo-image": "~3.0.11",
|
"expo-image": "~3.0.11",
|
||||||
"expo-location": "~19.0.7",
|
"expo-location": "~19.0.7",
|
||||||
"expo-sensors": "~15.0.7",
|
|
||||||
"expo-splash-screen": "~31.0.13",
|
"expo-splash-screen": "~31.0.13",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"expo-symbols": "~1.0.8",
|
"expo-symbols": "~1.0.8",
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-maps": "1.20.1",
|
"react-native-maps": "^1.20.1",
|
||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
@@ -2352,6 +2352,18 @@
|
|||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-native-async-storage/async-storage": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"merge-options": "^3.0.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-native": "^0.0.0-0 || >=0.65 <1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-native/assets-registry": {
|
"node_modules/@react-native/assets-registry": {
|
||||||
"version": "0.81.5",
|
"version": "0.81.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
||||||
@@ -5490,19 +5502,6 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-sensors": {
|
|
||||||
"version": "15.0.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/expo-sensors/-/expo-sensors-15.0.8.tgz",
|
|
||||||
"integrity": "sha512-ttibOSCYjFAMIfjV+vVukO1v7GKlbcPRfxcRqbTaSMGneewDwVSXbGFImY530fj1BR3mWq4n9jHnuDp8tAEY9g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"invariant": "^2.2.4"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"expo": "*",
|
|
||||||
"react-native": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/expo-server": {
|
"node_modules/expo-server": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
|
||||||
@@ -6779,6 +6778,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-plain-obj": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-regex": {
|
"node_modules/is-regex": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||||
@@ -7682,6 +7690,18 @@
|
|||||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||||
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
|
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
|
||||||
},
|
},
|
||||||
|
"node_modules/merge-options": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-plain-obj": "^2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/merge-stream": {
|
"node_modules/merge-stream": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||||
|
|||||||
+5
-5
@@ -10,6 +10,7 @@
|
|||||||
"lint": "expo lint"
|
"lint": "expo lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-navigation/native": "^7.1.18",
|
"@react-navigation/native": "^7.1.18",
|
||||||
"@react-navigation/native-stack": "^7.3.28",
|
"@react-navigation/native-stack": "^7.3.28",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
@@ -17,24 +18,23 @@
|
|||||||
"expo-font": "~14.0.11",
|
"expo-font": "~14.0.11",
|
||||||
"expo-haptics": "~15.0.8",
|
"expo-haptics": "~15.0.8",
|
||||||
"expo-image": "~3.0.11",
|
"expo-image": "~3.0.11",
|
||||||
|
"expo-location": "~19.0.7",
|
||||||
"expo-splash-screen": "~31.0.13",
|
"expo-splash-screen": "~31.0.13",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"expo-symbols": "~1.0.8",
|
"expo-symbols": "~1.0.8",
|
||||||
"expo-system-ui": "~6.0.9",
|
"expo-system-ui": "~6.0.9",
|
||||||
"expo-location": "~19.0.7",
|
|
||||||
"expo-web-browser": "~15.0.10",
|
"expo-web-browser": "~15.0.10",
|
||||||
"geolib": "^3.3.4",
|
"geolib": "^3.3.4",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-maps": "1.20.1",
|
"react-native-maps": "^1.20.1",
|
||||||
"react-native-worklets": "0.5.1",
|
|
||||||
"expo-sensors": "~15.0.7",
|
|
||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-web": "~0.21.0"
|
"react-native-web": "~0.21.0",
|
||||||
|
"react-native-worklets": "0.5.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
|
|||||||
+58
-2
@@ -359,7 +359,7 @@ addRoute('POST', '/subgroup-memberships', async (req, res) => {
|
|||||||
// If a JoinCode was supplied, look up the matching subgroup
|
// If a JoinCode was supplied, look up the matching subgroup
|
||||||
if (payload.JoinCode) {
|
if (payload.JoinCode) {
|
||||||
const subgroups = readJson('subgroups.json');
|
const subgroups = readJson('subgroups.json');
|
||||||
const sg = subgroups.find((s) => s.JoinCode === payload.JoinCode);
|
const sg = subgroups.find((s) => s.JoinCode === String(payload.JoinCode).toUpperCase());
|
||||||
if (!sg) return sendJson(res, 400, { message: 'Invalid join code' });
|
if (!sg) return sendJson(res, 400, { message: 'Invalid join code' });
|
||||||
payload.Gid = sg.Gid;
|
payload.Gid = sg.Gid;
|
||||||
payload.SGid = sg.SGid;
|
payload.SGid = sg.SGid;
|
||||||
@@ -396,8 +396,39 @@ addRoute('DELETE', '/subgroup-memberships/:id', (req, res, params) => {
|
|||||||
const rows = readJson('subgroup-memberships.json');
|
const rows = readJson('subgroup-memberships.json');
|
||||||
const idx = rows.findIndex((r) => String(r.id) === params.id);
|
const idx = rows.findIndex((r) => String(r.id) === params.id);
|
||||||
if (idx === -1) return sendJson(res, 404, { message: 'Membership not found' });
|
if (idx === -1) return sendJson(res, 404, { message: 'Membership not found' });
|
||||||
|
|
||||||
|
const removed = rows[idx];
|
||||||
rows.splice(idx, 1);
|
rows.splice(idx, 1);
|
||||||
writeJson('subgroup-memberships.json', rows);
|
writeJson('subgroup-memberships.json', rows);
|
||||||
|
|
||||||
|
// Cascade: remove from team if in one
|
||||||
|
const teamMembers = readJson('team-members.json');
|
||||||
|
const tmIdx = teamMembers.findIndex((r) => r.Uid === removed.Uid);
|
||||||
|
if (tmIdx !== -1) {
|
||||||
|
const tmRemoved = teamMembers[tmIdx];
|
||||||
|
teamMembers.splice(tmIdx, 1);
|
||||||
|
// Transfer leadership to longest-serving member if leader left
|
||||||
|
if (tmRemoved.IsLeader) {
|
||||||
|
const remaining = teamMembers.filter((r) => r.Tid === tmRemoved.Tid);
|
||||||
|
if (remaining.length > 0) {
|
||||||
|
remaining.sort((a, b) => new Date(a.JoinedAt || 0) - new Date(b.JoinedAt || 0));
|
||||||
|
remaining[0].IsLeader = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJson('team-members.json', teamMembers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear user game data
|
||||||
|
const users = readJson('users.json');
|
||||||
|
const uIdx = users.findIndex((u) => u.Uid === removed.Uid);
|
||||||
|
if (uIdx !== -1) {
|
||||||
|
users[uIdx].Gid = null;
|
||||||
|
users[uIdx].SGid = null;
|
||||||
|
users[uIdx].TGid = null;
|
||||||
|
users[uIdx].IsAcceptedAdmin = false;
|
||||||
|
writeJson('users.json', users);
|
||||||
|
}
|
||||||
|
|
||||||
sendJson(res, 204, null);
|
sendJson(res, 204, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -472,12 +503,16 @@ addRoute('POST', '/team-members', async (req, res) => {
|
|||||||
// If a JoinCode was supplied, look up the matching team
|
// If a JoinCode was supplied, look up the matching team
|
||||||
if (payload.JoinCode) {
|
if (payload.JoinCode) {
|
||||||
const teams = readJson('teams.json');
|
const teams = readJson('teams.json');
|
||||||
const team = teams.find((t) => t.JoinCode === payload.JoinCode);
|
const team = teams.find((t) => t.JoinCode === String(payload.JoinCode).toUpperCase());
|
||||||
if (!team) return sendJson(res, 400, { message: 'Invalid team join code' });
|
if (!team) return sendJson(res, 400, { message: 'Invalid team join code' });
|
||||||
payload.Tid = team.Tid;
|
payload.Tid = team.Tid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-set leader if first member in team
|
||||||
|
const existing = rows.filter((r) => r.Tid === payload.Tid);
|
||||||
payload.id = nextId(rows, 'id');
|
payload.id = nextId(rows, 'id');
|
||||||
|
payload.JoinedAt = new Date().toISOString();
|
||||||
|
payload.IsLeader = existing.length === 0;
|
||||||
rows.push(payload);
|
rows.push(payload);
|
||||||
writeJson('team-members.json', rows);
|
writeJson('team-members.json', rows);
|
||||||
|
|
||||||
@@ -506,8 +541,29 @@ addRoute('DELETE', '/team-members/:id', (req, res, params) => {
|
|||||||
const rows = readJson('team-members.json');
|
const rows = readJson('team-members.json');
|
||||||
const idx = rows.findIndex((r) => String(r.id) === params.id);
|
const idx = rows.findIndex((r) => String(r.id) === params.id);
|
||||||
if (idx === -1) return sendJson(res, 404, { message: 'Team member not found' });
|
if (idx === -1) return sendJson(res, 404, { message: 'Team member not found' });
|
||||||
|
|
||||||
|
const removed = rows[idx];
|
||||||
rows.splice(idx, 1);
|
rows.splice(idx, 1);
|
||||||
|
|
||||||
|
// Transfer leadership to longest-serving member if leader left
|
||||||
|
if (removed.IsLeader) {
|
||||||
|
const remaining = rows.filter((r) => r.Tid === removed.Tid);
|
||||||
|
if (remaining.length > 0) {
|
||||||
|
remaining.sort((a, b) => new Date(a.JoinedAt || 0) - new Date(b.JoinedAt || 0));
|
||||||
|
remaining[0].IsLeader = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
writeJson('team-members.json', rows);
|
writeJson('team-members.json', rows);
|
||||||
|
|
||||||
|
// Clear user TGid
|
||||||
|
const users = readJson('users.json');
|
||||||
|
const uIdx = users.findIndex((u) => u.Uid === removed.Uid);
|
||||||
|
if (uIdx !== -1) {
|
||||||
|
users[uIdx].TGid = null;
|
||||||
|
writeJson('users.json', users);
|
||||||
|
}
|
||||||
|
|
||||||
sendJson(res, 204, null);
|
sendJson(res, 204, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const API_BASE_URL = 'http://api.bobby.ip-ddns.com'; //For outside Home network
|
//export const API_BASE_URL = 'http://api.bobby.ip-ddns.com'; // For actual use
|
||||||
//export const API_BASE_URL = 'http://192.168.8.100:3000'; //For when im home (API Endpoint is on my home server...)
|
export const API_BASE_URL = 'http://192.168.8.100:3000'; // Home network for mudabbir since, server is on my home network and router dont do loopback!!!
|
||||||
const API = {};
|
const API = {};
|
||||||
|
|
||||||
API.get = (endpoint) => callFetch(endpoint, 'GET');
|
API.get = (endpoint) => callFetch(endpoint, 'GET');
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import MapView, {Circle, Marker} from 'react-native-maps';
|
|
||||||
|
|
||||||
const AdminCacheEditorView = ({ userLocation, caches, claimDistance, onAddCache, onMoveCache }) => {
|
|
||||||
// Initialisation -------------
|
|
||||||
// State ----------------------
|
|
||||||
// Handlers -------------------
|
|
||||||
// View -----------------------
|
|
||||||
|
|
||||||
if (!userLocation) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MapView
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
initialRegion={{
|
|
||||||
...userLocation,
|
|
||||||
latitudeDelta: 0.01,
|
|
||||||
longitudeDelta: 0.01,
|
|
||||||
}}
|
|
||||||
onLongPress={(event) => onAddCache(event.nativeEvent.coordinate)}
|
|
||||||
scrollEnabled={true}
|
|
||||||
zoomEnabled={true}
|
|
||||||
showsUserLocation
|
|
||||||
>
|
|
||||||
{(caches || []).map((cache) => (
|
|
||||||
<React.Fragment key={cache.id}>
|
|
||||||
<Marker
|
|
||||||
coordinate={cache.coordinates}
|
|
||||||
draggable
|
|
||||||
onDragEnd={(event) => onMoveCache(cache.id, event.nativeEvent.coordinate)}
|
|
||||||
title={`Cache ${cache.id}`}
|
|
||||||
/>
|
|
||||||
<Circle
|
|
||||||
center={cache.coordinates}
|
|
||||||
radius={claimDistance}
|
|
||||||
fillColor="rgba(59, 130, 246, 0.15)"
|
|
||||||
strokeColor="rgba(59, 130, 246, 0.85)"
|
|
||||||
/>
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</MapView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminCacheEditorView;
|
|
||||||
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import React from 'react';
|
|
||||||
import MapView, {Circle, Polygon} from 'react-native-maps';
|
import MapView, {Circle, Polygon} from 'react-native-maps';
|
||||||
import {getFovCone} from '../../utils/geoMath';
|
import {getFovCone} from '../../utils/geoMath';
|
||||||
|
|
||||||
const PlayerMapView = ({ userLocation, visibleCache, heading, claimDistance }) => {
|
const PlayerMapView = ({userLocation, visibleCache, heading, claimDistance}) => {
|
||||||
// Initialisation -------------
|
// Initialisation -------------
|
||||||
// State ----------------------
|
// State ----------------------
|
||||||
// Handlers -------------------
|
// Handlers -------------------
|
||||||
@@ -17,25 +16,21 @@ const PlayerMapView = ({ userLocation, visibleCache, heading, claimDistance }) =
|
|||||||
return (
|
return (
|
||||||
<MapView
|
<MapView
|
||||||
style={{flex: 1}}
|
style={{flex: 1}}
|
||||||
region={{
|
region={{...userLocation, latitudeDelta: 0.01, longitudeDelta: 0.01}}
|
||||||
...userLocation,
|
|
||||||
latitudeDelta: 0.01,
|
|
||||||
longitudeDelta: 0.01,
|
|
||||||
}}
|
|
||||||
scrollEnabled={false}
|
scrollEnabled={false}
|
||||||
zoomEnabled={true}
|
zoomEnabled={true}
|
||||||
rotateEnabled={false}
|
rotateEnabled={false}
|
||||||
pitchEnabled={false}
|
pitchEnabled={false}
|
||||||
showsUserLocation
|
showsUserLocation
|
||||||
>
|
>
|
||||||
{visibleCache ? (
|
{visibleCache && (
|
||||||
<Circle
|
<Circle
|
||||||
center={visibleCache.coordinates}
|
center={visibleCache.coordinates}
|
||||||
radius={claimDistance}
|
radius={claimDistance}
|
||||||
fillColor="rgba(250, 204, 21, 0.20)"
|
fillColor="rgba(250, 204, 21, 0.20)"
|
||||||
strokeColor="rgba(250, 204, 21, 0.90)"
|
strokeColor="rgba(250, 204, 21, 0.90)"
|
||||||
/>
|
/>
|
||||||
) : null}
|
)}
|
||||||
{coneCoords && (
|
{coneCoords && (
|
||||||
<Polygon
|
<Polygon
|
||||||
coordinates={coneCoords}
|
coordinates={coneCoords}
|
||||||
@@ -48,5 +43,4 @@ const PlayerMapView = ({ userLocation, visibleCache, heading, claimDistance }) =
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export default PlayerMapView;
|
export default PlayerMapView;
|
||||||
@@ -1,71 +1,69 @@
|
|||||||
import {Pressable, StyleSheet, Text, View} from 'react-native';
|
import {Alert, Pressable, StyleSheet, Text, View} from 'react-native';
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
import {getSession} from '../../hooks/SessionStore';
|
import {clearGameSession, getSession} from '../../hooks/SessionStore';
|
||||||
|
import API, {API_BASE_URL} from '../API/API';
|
||||||
|
|
||||||
const BottomNavbar = ({ navigation, routeName }) => {
|
const BottomNavbar = ({navigation, routeName}) => {
|
||||||
// Initialisation ------------
|
// Initialisation ------------
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
|
|
||||||
const adminTabs = [
|
const adminTabs = [
|
||||||
{
|
{label: 'Settings', route: 'GameSettingsScreen', onPress: () => navigation.navigate('GameSettingsScreen')},
|
||||||
label: 'Game Settings',
|
{label: 'Map', route: 'MapScreen', onPress: () => navigation.navigate('MapScreen')},
|
||||||
route: 'GameSettingsScreen',
|
{label: 'Players', route: 'PlayersScreen', onPress: () => navigation.navigate('PlayersScreen')},
|
||||||
onPress: () => navigation.navigate('GameSettingsScreen'),
|
{label: 'Leaderboard', route: 'LeaderboardScreen', onPress: () => navigation.navigate('LeaderboardScreen')},
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Map',
|
|
||||||
route: 'MapScreen',
|
|
||||||
onPress: () => navigation.navigate('MapScreen'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Players',
|
|
||||||
route: 'PlayersScreen',
|
|
||||||
onPress: () => navigation.navigate('PlayersScreen'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Leaderboard',
|
|
||||||
route: 'LeaderboardScreen',
|
|
||||||
onPress: () => navigation.navigate('LeaderboardScreen'),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const playerTabs = [
|
const playerTabs = [
|
||||||
{
|
{label: 'Team', route: 'TeamScreen', onPress: () => navigation.navigate('TeamScreen')},
|
||||||
label: 'Map',
|
{label: 'Map', route: 'MapScreen', onPress: () => navigation.navigate('MapScreen')},
|
||||||
route: 'MapScreen',
|
{label: 'Leaderboard', route: 'LeaderboardScreen', onPress: () => navigation.navigate('LeaderboardScreen')},
|
||||||
onPress: () => navigation.navigate('MapScreen'),
|
{label: 'Leave', route: '__leave__', onPress: () => handleLeaveGame()},
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Leaderboard',
|
|
||||||
route: 'LeaderboardScreen',
|
|
||||||
onPress: () => navigation.navigate('LeaderboardScreen'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Current Team',
|
|
||||||
route: 'TeamScreen',
|
|
||||||
onPress: () => navigation.navigate('TeamScreen'),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const tabs = session.isAcceptedAdmin ? adminTabs : playerTabs;
|
const tabs = session.isAcceptedAdmin ? adminTabs : playerTabs;
|
||||||
|
|
||||||
// State ----------------------
|
// State ----------------------
|
||||||
// Handlers -------------------
|
// Handlers -------------------
|
||||||
|
|
||||||
|
const handleLeaveGame = () => {
|
||||||
|
Alert.alert('Leave Game', 'Are you sure you want to leave this game?', [
|
||||||
|
{text: 'Cancel', style: 'cancel'},
|
||||||
|
{
|
||||||
|
text: 'Leave',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
const s = getSession();
|
||||||
|
const endpoint = `${API_BASE_URL}/subgroup-memberships?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}`);
|
||||||
|
}
|
||||||
|
clearGameSession();
|
||||||
|
navigation.reset({index: 0, routes: [{name: 'Game'}]});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
// View -----------------------
|
// View -----------------------
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, {paddingBottom: insets.bottom}]}>
|
<View style={[styles.container, {paddingBottom: insets.bottom}]}>
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const isActive = routeName === tab.route;
|
const isActive = routeName === tab.route;
|
||||||
|
const isLeave = tab.route === '__leave__';
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={tab.route}
|
key={tab.label}
|
||||||
onPress={tab.onPress}
|
onPress={tab.onPress}
|
||||||
style={[styles.tab, isActive && styles.tabActive]}
|
style={[styles.tab, isActive && styles.tabActive, isLeave && styles.tabLeave]}
|
||||||
>
|
>
|
||||||
<Text style={[styles.tabText, isActive && styles.tabTextActive]}>{tab.label}</Text>
|
<Text style={[styles.tabText, isActive && styles.tabTextActive, isLeave && styles.tabTextLeave]}>
|
||||||
|
{tab.label}
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -95,6 +93,9 @@ const styles = StyleSheet.create({
|
|||||||
tabActive: {
|
tabActive: {
|
||||||
backgroundColor: '#5c5c5c',
|
backgroundColor: '#5c5c5c',
|
||||||
},
|
},
|
||||||
|
tabLeave: {
|
||||||
|
backgroundColor: 'rgba(220,38,38,0.15)',
|
||||||
|
},
|
||||||
tabText: {
|
tabText: {
|
||||||
color: '#d1d5db',
|
color: '#d1d5db',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -104,6 +105,9 @@ const styles = StyleSheet.create({
|
|||||||
tabTextActive: {
|
tabTextActive: {
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
},
|
},
|
||||||
|
tabTextLeave: {
|
||||||
|
color: '#fca5a5',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default BottomNavbar;
|
export default BottomNavbar;
|
||||||
@@ -13,17 +13,9 @@ const Screen = ({ children, style, showBack = false }) => {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const titleMap = {
|
const titleMap = {
|
||||||
GameTypeScreen: "Game Type",
|
|
||||||
JoinPrivateGameScreen: "Join Private Game",
|
|
||||||
ManagePrivateGameScreen: "Manage Private Game",
|
|
||||||
CreateGameScreen: "Create Game",
|
|
||||||
ManageGameScreen: "Manage Game",
|
|
||||||
GameLobbyScreen: "Game Lobby",
|
|
||||||
LeaderboardScreen: "Leaderboard",
|
LeaderboardScreen: "Leaderboard",
|
||||||
TeamScreen: "Current Team",
|
TeamScreen: "Current Team",
|
||||||
MapScreen: "Map",
|
MapScreen: "Map",
|
||||||
AdminHubScreen: "Admin Hub",
|
|
||||||
ManageSubgroupsScreen: "Manage Subgroups",
|
|
||||||
GameSettingsScreen: "Game Settings",
|
GameSettingsScreen: "Game Settings",
|
||||||
PlayersScreen: "Players",
|
PlayersScreen: "Players",
|
||||||
ExpandedMapScreen: "Map View",
|
ExpandedMapScreen: "Map View",
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {StyleSheet, Text} from 'react-native';
|
|
||||||
import Screen from '../layout/Screen';
|
|
||||||
|
|
||||||
const AdminHubScreen = () => {
|
|
||||||
// Initialisation -------------
|
|
||||||
// State ----------------------
|
|
||||||
// Handlers -------------------
|
|
||||||
// View -----------------------
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Screen showBack={true} style={styles.container}>
|
|
||||||
<Text style={styles.title}>Admin Hub</Text>
|
|
||||||
<Text style={styles.body}>Admin hub setup goes here.</Text>
|
|
||||||
</Screen>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {justifyContent: 'center', alignItems: 'center'},
|
|
||||||
title: {fontSize: 24, fontWeight: 'bold'},
|
|
||||||
body: {color: '#4b5563', fontSize: 15, marginTop: 10},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default AdminHubScreen;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {StyleSheet, Text} from 'react-native';
|
|
||||||
import Screen from '../layout/Screen';
|
|
||||||
|
|
||||||
const CreateGameScreen = () => {
|
|
||||||
// Initialisation -------------
|
|
||||||
// State ----------------------
|
|
||||||
// Handlers -------------------
|
|
||||||
// View -----------------------
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Screen showBack={true} style={styles.container}>
|
|
||||||
<Text style={styles.title}>Create Game</Text>
|
|
||||||
<Text style={styles.body}>Create game setup goes here.</Text>
|
|
||||||
</Screen>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {justifyContent: 'center', alignItems: 'center'},
|
|
||||||
title: {fontSize: 24, fontWeight: 'bold'},
|
|
||||||
body: {color: '#4b5563', fontSize: 15, marginTop: 10},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default CreateGameScreen;
|
|
||||||
@@ -2,22 +2,22 @@ import React, {useEffect, useState} from 'react';
|
|||||||
import {StyleSheet, View} from 'react-native';
|
import {StyleSheet, View} from 'react-native';
|
||||||
import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
|
import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
|
||||||
import * as Location from 'expo-location';
|
import * as Location from 'expo-location';
|
||||||
import {Magnetometer} from 'expo-sensors';
|
|
||||||
import Screen from '../layout/Screen';
|
import Screen from '../layout/Screen';
|
||||||
import {getFovCone, toHeading} from '../../utils/geoMath';
|
import {getFovCone} from '../../utils/geoMath';
|
||||||
|
|
||||||
const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.01, longitudeDelta: 0.01};
|
const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.01, longitudeDelta: 0.01};
|
||||||
|
|
||||||
const ExpandedMapScreen = ({route}) => {
|
const ExpandedMapScreen = ({route}) => {
|
||||||
// Initialisation ------------
|
// Initialisation ------------
|
||||||
|
|
||||||
const {isAdmin, cacheRecords: cacheStr, claimDistance: routeClaimDistance} = route.params || {};
|
const {isAdmin, cacheRecords: cacheStr, claimDistance: routeClaimDistance, userLocation: routeLocation} = route.params || {};
|
||||||
const caches = cacheStr ? JSON.parse(cacheStr) : [];
|
const caches = cacheStr ? JSON.parse(cacheStr) : [];
|
||||||
const claimDistance = routeClaimDistance || 20;
|
const claimDistance = routeClaimDistance || 20;
|
||||||
|
|
||||||
// State ----------------------
|
// State ----------------------
|
||||||
|
|
||||||
const [userLocation, setUserLocation] = useState(null);
|
// 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);
|
const [heading, setHeading] = useState(null);
|
||||||
|
|
||||||
// Handlers -------------------
|
// Handlers -------------------
|
||||||
@@ -30,16 +30,27 @@ const ExpandedMapScreen = ({route}) => {
|
|||||||
const {status} = await Location.requestForegroundPermissionsAsync();
|
const {status} = await Location.requestForegroundPermissionsAsync();
|
||||||
if (status !== 'granted') return;
|
if (status !== 'granted') return;
|
||||||
|
|
||||||
const pos = await Location.getCurrentPositionAsync({accuracy: Location.Accuracy.Balanced});
|
// Only do a fresh one-shot fix if MapScreen didn't supply a location
|
||||||
|
if (!routeLocation) {
|
||||||
|
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});
|
setUserLocation({latitude: pos.coords.latitude, longitude: pos.coords.longitude});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
locationSub = await Location.watchPositionAsync(
|
locationSub = await Location.watchPositionAsync(
|
||||||
{accuracy: Location.Accuracy.Balanced, distanceInterval: 2, timeInterval: 1000},
|
{accuracy: Location.Accuracy.Balanced, distanceInterval: 2, 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);
|
// Use OS-fused heading from expo-location
|
||||||
headingSub = Magnetometer.addListener((data) => setHeading(toHeading(data)));
|
headingSub = await Location.watchHeadingAsync((headingData) => {
|
||||||
|
const raw = headingData.trueHeading >= 0 ? headingData.trueHeading : headingData.magHeading;
|
||||||
|
setHeading(raw);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
start();
|
start();
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {StyleSheet, Text} from 'react-native';
|
|
||||||
import Screen from '../layout/Screen';
|
|
||||||
|
|
||||||
const GameLobbyScreen = () => {
|
|
||||||
// Initialisation -------------
|
|
||||||
// State ----------------------
|
|
||||||
// Handlers -------------------
|
|
||||||
// View -----------------------
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Screen showBack={true} style={styles.container}>
|
|
||||||
<Text style={styles.title}>Game Lobby</Text>
|
|
||||||
<Text style={styles.body}>Game lobby setup goes here.</Text>
|
|
||||||
</Screen>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {justifyContent: 'center', alignItems: 'center'},
|
|
||||||
title: {fontSize: 24, fontWeight: 'bold'},
|
|
||||||
body: {color: '#4b5563', fontSize: 15, marginTop: 10},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default GameLobbyScreen;
|
|
||||||
@@ -3,7 +3,7 @@ import {ActivityIndicator, ScrollView, StyleSheet, Switch, Text, TextInput, View
|
|||||||
import Screen from '../layout/Screen';
|
import Screen from '../layout/Screen';
|
||||||
import {Button, ButtonTray} from '../UI/Button';
|
import {Button, ButtonTray} from '../UI/Button';
|
||||||
import useGameHook from '../../hooks/useGameHook';
|
import useGameHook from '../../hooks/useGameHook';
|
||||||
import {getSession} from '../../hooks/SessionStore';
|
import {getSession, setSessionTeamsEnabled} from '../../hooks/SessionStore';
|
||||||
|
|
||||||
const GameSettingsScreen = () => {
|
const GameSettingsScreen = () => {
|
||||||
// Initialisation ------------
|
// Initialisation ------------
|
||||||
@@ -17,7 +17,7 @@ const GameSettingsScreen = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [groupName, setGroupName] = useState('');
|
const [groupName, setGroupName] = useState('');
|
||||||
const [teamsEnabled, setTeamsEnabled] = useState(false);
|
const [teamsEnabled, setTeamsEnabled] = useState(session.teamsEnabled);
|
||||||
const [maxSubgroups, setMaxSubgroups] = useState('1');
|
const [maxSubgroups, setMaxSubgroups] = useState('1');
|
||||||
const [cacheTriggerMeters, setCacheTriggerMeters] = useState('20');
|
const [cacheTriggerMeters, setCacheTriggerMeters] = useState('20');
|
||||||
const [adminJoinCode, setAdminJoinCode] = useState('');
|
const [adminJoinCode, setAdminJoinCode] = useState('');
|
||||||
@@ -34,7 +34,10 @@ const GameSettingsScreen = () => {
|
|||||||
const group = await getLobby(session.currentGid);
|
const group = await getLobby(session.currentGid);
|
||||||
if (group) {
|
if (group) {
|
||||||
setGroupName(group.GroupName || '');
|
setGroupName(group.GroupName || '');
|
||||||
setTeamsEnabled(Boolean(group.TeamsEnabled));
|
const serverTeams = Boolean(group.TeamsEnabled);
|
||||||
|
setTeamsEnabled(serverTeams);
|
||||||
|
// Keep session in sync so the toggle survives app reloads
|
||||||
|
setSessionTeamsEnabled(serverTeams);
|
||||||
setMaxSubgroups(String(group.MaxMemberSubgroups || 1));
|
setMaxSubgroups(String(group.MaxMemberSubgroups || 1));
|
||||||
setCacheTriggerMeters(String(group.CacheTriggerMeters || 20));
|
setCacheTriggerMeters(String(group.CacheTriggerMeters || 20));
|
||||||
setAdminJoinCode(group.AdminJoinCode || '');
|
setAdminJoinCode(group.AdminJoinCode || '');
|
||||||
@@ -50,12 +53,14 @@ const GameSettingsScreen = () => {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!session.currentGid) return;
|
if (!session.currentGid) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
await updateGroup(session.currentGid, {
|
const result = await updateGroup(session.currentGid, {
|
||||||
GroupName: groupName.trim(),
|
GroupName: groupName.trim(),
|
||||||
TeamsEnabled: teamsEnabled,
|
TeamsEnabled: teamsEnabled,
|
||||||
MaxMemberSubgroups: parseInt(maxSubgroups) || 1,
|
MaxMemberSubgroups: parseInt(maxSubgroups) || 1,
|
||||||
CacheTriggerMeters: parseInt(cacheTriggerMeters) || 20,
|
CacheTriggerMeters: parseInt(cacheTriggerMeters) || 20,
|
||||||
});
|
});
|
||||||
|
// Persist the new toggle value so the session reflects it after a reload
|
||||||
|
if (result) setSessionTeamsEnabled(teamsEnabled);
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import React, {useEffect, useState} from 'react';
|
|
||||||
import {Pressable, StyleSheet, Text, View} from 'react-native';
|
|
||||||
import useGameHook from '../../hooks/useGameHook';
|
|
||||||
import Screen from '../layout/Screen';
|
|
||||||
|
|
||||||
const GameTypeScreen = ({navigation}) => {
|
|
||||||
// Initialisation ------------
|
|
||||||
|
|
||||||
const {getGameTypes} = useGameHook();
|
|
||||||
|
|
||||||
// State ----------------------
|
|
||||||
|
|
||||||
const [gameTypes, setGameTypes] = useState([]);
|
|
||||||
|
|
||||||
// Handlers -------------------
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
const load = async () => {
|
|
||||||
const types = await getGameTypes();
|
|
||||||
if (isMounted) setGameTypes(types || []);
|
|
||||||
};
|
|
||||||
load();
|
|
||||||
return () => { isMounted = false; };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// View -----------------------
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Screen showBack={true} style={styles.container}>
|
|
||||||
<Text style={styles.title}>Select Game Type</Text>
|
|
||||||
<View style={styles.content}>
|
|
||||||
{gameTypes && gameTypes.map((type) => (
|
|
||||||
<Pressable
|
|
||||||
key={type}
|
|
||||||
style={styles.button}
|
|
||||||
onPress={() => navigation.navigate('GameLobbyScreen')}
|
|
||||||
>
|
|
||||||
<Text style={styles.buttonText}>{type}</Text>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</Screen>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
gap: 12,
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
textAlign: 'center',
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
backgroundColor: '#2563eb',
|
|
||||||
borderRadius: 8,
|
|
||||||
paddingVertical: 12,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
buttonText: {
|
|
||||||
color: '#ffffff',
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default GameTypeScreen;
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {StyleSheet, Text} from 'react-native';
|
|
||||||
import Screen from '../layout/Screen';
|
|
||||||
|
|
||||||
const JoinPrivateGameScreen = () => {
|
|
||||||
// Initialisation -------------
|
|
||||||
// State ----------------------
|
|
||||||
// Handlers -------------------
|
|
||||||
// View -----------------------
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Screen showBack={true} style={styles.container}>
|
|
||||||
<Text style={styles.title}>Join Private Game</Text>
|
|
||||||
<Text style={styles.body}>Join private game setup goes here.</Text>
|
|
||||||
</Screen>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
color: '#4b5563',
|
|
||||||
fontSize: 15,
|
|
||||||
marginTop: 10,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default JoinPrivateGameScreen;
|
|
||||||
@@ -1,25 +1,241 @@
|
|||||||
import React from 'react';
|
import React, {useCallback, useEffect, useState} from 'react';
|
||||||
import {StyleSheet, Text} from 'react-native';
|
import {ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View} from 'react-native';
|
||||||
import Screen from '../layout/Screen';
|
import Screen from '../layout/Screen';
|
||||||
|
import Card from '../UI/Card';
|
||||||
|
import useGameHook from '../../hooks/useGameHook';
|
||||||
|
import {getSession} from '../../hooks/SessionStore';
|
||||||
|
|
||||||
const LeaderboardScreen = () => {
|
const LeaderboardScreen = () => {
|
||||||
// Initialisation -------------
|
// Initialisation ------------
|
||||||
|
|
||||||
|
const session = getSession();
|
||||||
|
const {getLobby, getTeams, getTeamMembers, getGroupMembers, getCaches, getUser} = useGameHook();
|
||||||
|
|
||||||
// State ----------------------
|
// 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 [expandedTeam, setExpandedTeam] = useState(null);
|
||||||
|
|
||||||
// Handlers -------------------
|
// Handlers -------------------
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (!session.currentGid) { setLoading(false); return; }
|
||||||
|
|
||||||
|
const group = await getLobby(session.currentGid);
|
||||||
|
const isTeams = Boolean(group?.TeamsEnabled);
|
||||||
|
setTeamsEnabled(isTeams);
|
||||||
|
|
||||||
|
const caches = await getCaches(session.currentGid, null);
|
||||||
|
const claimedCaches = (caches || []).filter((c) => c.ClaimedByUid);
|
||||||
|
|
||||||
|
if (isTeams) {
|
||||||
|
// Build team rankings
|
||||||
|
const teams = await getTeams(session.currentGid);
|
||||||
|
const teamData = [];
|
||||||
|
|
||||||
|
for (const team of (teams || [])) {
|
||||||
|
const members = await getTeamMembers(team.Tid);
|
||||||
|
const memberData = [];
|
||||||
|
for (const m of (members || [])) {
|
||||||
|
const user = await getUser(m.Uid);
|
||||||
|
const count = claimedCaches.filter((c) => c.ClaimedByUid === m.Uid).length;
|
||||||
|
memberData.push({
|
||||||
|
uid: m.Uid,
|
||||||
|
name: user?.username || `Player ${m.Uid}`,
|
||||||
|
caches: count,
|
||||||
|
isLeader: Boolean(m.IsLeader),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
memberData.sort((a, b) => b.caches - a.caches);
|
||||||
|
const totalCaches = memberData.reduce((sum, m) => sum + m.caches, 0);
|
||||||
|
teamData.push({
|
||||||
|
tid: team.Tid,
|
||||||
|
name: team.TeamName || 'Unnamed Team',
|
||||||
|
code: team.JoinCode,
|
||||||
|
totalCaches,
|
||||||
|
members: memberData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
teamData.sort((a, b) => b.totalCaches - a.totalCaches);
|
||||||
|
setGlobalRanking(teamData);
|
||||||
|
|
||||||
|
// Build current team ranking
|
||||||
|
if (session.currentTid) {
|
||||||
|
const myTeam = teamData.find((t) => t.tid === session.currentTid);
|
||||||
|
setTeamRanking(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)) {
|
||||||
|
const user = await getUser(m.Uid);
|
||||||
|
const count = claimedCaches.filter((c) => c.ClaimedByUid === m.Uid).length;
|
||||||
|
playerData.push({
|
||||||
|
uid: m.Uid,
|
||||||
|
name: user?.username || `Player ${m.Uid}`,
|
||||||
|
caches: count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
playerData.sort((a, b) => b.caches - a.caches);
|
||||||
|
setGlobalRanking(playerData);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
}, [session.currentGid, session.currentTid]);
|
||||||
|
|
||||||
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
|
|
||||||
// View -----------------------
|
// View -----------------------
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Screen style={styles.center}>
|
||||||
|
<ActivityIndicator size="large"/>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.currentGid) {
|
||||||
|
return (
|
||||||
|
<Screen style={styles.center}>
|
||||||
|
<Text style={styles.body}>Join a game to view the leaderboard.</Text>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teams enabled — tabbed view
|
||||||
|
if (teamsEnabled) {
|
||||||
return (
|
return (
|
||||||
<Screen style={styles.container}>
|
<Screen style={styles.container}>
|
||||||
<Text style={styles.title}>Leaderboard</Text>
|
<View style={styles.tabRow}>
|
||||||
<Text style={styles.body}>Leaderboard view goes here.</Text>
|
<Pressable
|
||||||
|
style={[styles.tabButton, activeTab === 'global' && styles.tabButtonActive]}
|
||||||
|
onPress={() => setActiveTab('global')}
|
||||||
|
>
|
||||||
|
<Text style={[styles.tabLabel, activeTab === 'global' && styles.tabLabelActive]}>Global</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.tabButton, activeTab === 'team' && styles.tabButtonActive]}
|
||||||
|
onPress={() => setActiveTab('team')}
|
||||||
|
>
|
||||||
|
<Text style={[styles.tabLabel, activeTab === 'team' && styles.tabLabelActive]}>Team</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{activeTab === 'global' ? (
|
||||||
|
<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 && (
|
||||||
|
<Text style={styles.emptyText}>No teams yet.</Text>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
<ScrollView style={styles.listSection}>
|
||||||
|
{teamRanking.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)' : ''}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.score}>{m.caches} cache(s)</Text>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
{teamRanking.length === 0 && (
|
||||||
|
<Text style={styles.emptyText}>Join a team to see your team leaderboard.</Text>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No teams — simple player ranking
|
||||||
|
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 && (
|
||||||
|
<Text style={styles.emptyText}>No players yet.</Text>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {justifyContent: 'center', alignItems: 'center'},
|
center: {justifyContent: 'center', alignItems: 'center'},
|
||||||
title: {fontSize: 24, fontWeight: 'bold'},
|
container: {padding: 0},
|
||||||
body: {color: '#4b5563', fontSize: 15, marginTop: 10},
|
body: {color: '#4b5563', fontSize: 15},
|
||||||
|
tabRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#e5e7eb',
|
||||||
|
},
|
||||||
|
tabButton: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
tabButtonActive: {
|
||||||
|
borderBottomWidth: 2,
|
||||||
|
borderBottomColor: '#2563eb',
|
||||||
|
},
|
||||||
|
tabLabel: {fontSize: 15, fontWeight: '600', color: '#6b7280'},
|
||||||
|
tabLabelActive: {color: '#2563eb'},
|
||||||
|
listSection: {flex: 1, paddingHorizontal: 15, paddingTop: 10},
|
||||||
|
rankRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
rank: {fontSize: 16, fontWeight: '700', color: '#374151', width: 32},
|
||||||
|
rankName: {flex: 1, fontSize: 15, fontWeight: '600', color: '#1f2937'},
|
||||||
|
score: {fontSize: 14, fontWeight: '600', color: '#16a34a'},
|
||||||
|
subRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingLeft: 42,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#f3f4f6',
|
||||||
|
},
|
||||||
|
subName: {fontSize: 13, color: '#4b5563'},
|
||||||
|
subScore: {fontSize: 13, fontWeight: '600', color: '#16a34a'},
|
||||||
|
emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 30, fontSize: 14},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default LeaderboardScreen;
|
export default LeaderboardScreen;
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import React, {useCallback, useEffect, useState} from 'react';
|
|
||||||
import {ActivityIndicator, StyleSheet, Text, View} from 'react-native';
|
|
||||||
import * as Location from 'expo-location';
|
|
||||||
import Screen from '../layout/Screen';
|
|
||||||
import AdminCacheEditorView from '../gameplay/AdminCacheEditorView';
|
|
||||||
import useGameHook from '../../hooks/useGameHook';
|
|
||||||
import {getSession} from '../../hooks/SessionStore';
|
|
||||||
|
|
||||||
const ManageGameScreen = () => {
|
|
||||||
// Initialisation ------------
|
|
||||||
|
|
||||||
const session = getSession();
|
|
||||||
const {getCaches, upsertCache, getLobby} = useGameHook();
|
|
||||||
|
|
||||||
// State ----------------------
|
|
||||||
|
|
||||||
const [userLocation, setUserLocation] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [cacheRecords, setCacheRecords] = useState([]);
|
|
||||||
const [claimDistance, setClaimDistance] = useState(20);
|
|
||||||
|
|
||||||
// Handlers -------------------
|
|
||||||
|
|
||||||
const loadCaches = useCallback(async () => {
|
|
||||||
if (!session.currentGid) {
|
|
||||||
setCacheRecords([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rows = await getCaches(session.currentGid, null);
|
|
||||||
setCacheRecords(rows || []);
|
|
||||||
}, [session.currentGid]);
|
|
||||||
|
|
||||||
useEffect(() => { loadCaches(); }, [loadCaches]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!session.currentGid) return;
|
|
||||||
getLobby(session.currentGid).then((group) => {
|
|
||||||
if (group) setClaimDistance(group.CacheTriggerMeters || 20);
|
|
||||||
});
|
|
||||||
}, [session.currentGid]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let locationSub;
|
|
||||||
const start = async () => {
|
|
||||||
const {status} = await Location.requestForegroundPermissionsAsync();
|
|
||||||
if (status !== 'granted') {
|
|
||||||
setError('Location permission denied');
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const current = await Location.getCurrentPositionAsync({accuracy: Location.Accuracy.Balanced});
|
|
||||||
setUserLocation({latitude: current.coords.latitude, longitude: current.coords.longitude});
|
|
||||||
|
|
||||||
locationSub = await Location.watchPositionAsync(
|
|
||||||
{accuracy: Location.Accuracy.Balanced, distanceInterval: 1, timeInterval: 1000},
|
|
||||||
(next) => {
|
|
||||||
setUserLocation({latitude: next.coords.latitude, longitude: next.coords.longitude});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
start();
|
|
||||||
return () => { if (locationSub) locationSub.remove(); };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAddCache = async (coordinate) => {
|
|
||||||
if (!session.currentGid) return;
|
|
||||||
await upsertCache({
|
|
||||||
gid: session.currentGid,
|
|
||||||
latitude: coordinate.latitude,
|
|
||||||
longitude: coordinate.longitude,
|
|
||||||
clue: `Cache ${cacheRecords.length + 1}`,
|
|
||||||
subgroupId: 1,
|
|
||||||
});
|
|
||||||
await loadCaches();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMoveCache = async (cacheId, coordinate) => {
|
|
||||||
if (!session.currentGid) return;
|
|
||||||
const current = cacheRecords.find((cache) => cache.id === cacheId);
|
|
||||||
if (!current) return;
|
|
||||||
await upsertCache({
|
|
||||||
gid: session.currentGid,
|
|
||||||
cacheId,
|
|
||||||
latitude: coordinate.latitude,
|
|
||||||
longitude: coordinate.longitude,
|
|
||||||
clue: current.clue,
|
|
||||||
subgroupId: current.subgroupId,
|
|
||||||
});
|
|
||||||
await loadCaches();
|
|
||||||
};
|
|
||||||
|
|
||||||
// View -----------------------
|
|
||||||
|
|
||||||
if (!session.currentGid) {
|
|
||||||
return (
|
|
||||||
<Screen showBack={true} style={styles.center}>
|
|
||||||
<Text style={styles.body}>Create or join a game first.</Text>
|
|
||||||
</Screen>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session.isAcceptedAdmin) {
|
|
||||||
return (
|
|
||||||
<Screen showBack={true} style={styles.center}>
|
|
||||||
<Text style={styles.body}>Only admins can edit cache locations.</Text>
|
|
||||||
</Screen>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<Screen showBack={true} style={styles.center}>
|
|
||||||
<ActivityIndicator size="large"/>
|
|
||||||
</Screen>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Screen showBack={true} style={styles.center}>
|
|
||||||
<Text style={styles.error}>{error}</Text>
|
|
||||||
</Screen>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Screen showBack={true} style={styles.container}>
|
|
||||||
<View style={styles.mapWrap}>
|
|
||||||
<AdminCacheEditorView
|
|
||||||
userLocation={userLocation}
|
|
||||||
caches={cacheRecords}
|
|
||||||
claimDistance={claimDistance}
|
|
||||||
onAddCache={handleAddCache}
|
|
||||||
onMoveCache={handleMoveCache}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</Screen>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {padding: 0},
|
|
||||||
mapWrap: {flex: 1},
|
|
||||||
center: {justifyContent: 'center', alignItems: 'center'},
|
|
||||||
body: {color: '#4b5563', fontSize: 15},
|
|
||||||
error: {color: '#dc2626', fontSize: 15},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default ManageGameScreen;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {StyleSheet, Text} from 'react-native';
|
|
||||||
import Screen from '../layout/Screen';
|
|
||||||
|
|
||||||
const ManagePrivateGameScreen = () => {
|
|
||||||
// Initialisation -------------
|
|
||||||
// State ----------------------
|
|
||||||
// Handlers -------------------
|
|
||||||
// View -----------------------
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Screen showBack={true} style={styles.container}>
|
|
||||||
<Text style={styles.title}>Manage Private Game</Text>
|
|
||||||
<Text style={styles.body}>Manage private game setup goes here.</Text>
|
|
||||||
</Screen>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {justifyContent: 'center', alignItems: 'center'},
|
|
||||||
title: {fontSize: 24, fontWeight: 'bold'},
|
|
||||||
body: {color: '#4b5563', fontSize: 15, marginTop: 10},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default ManagePrivateGameScreen;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {StyleSheet, Text} from 'react-native';
|
|
||||||
import Screen from '../layout/Screen';
|
|
||||||
|
|
||||||
const ManageSubgroupsScreen = () => {
|
|
||||||
// Initialisation -------------
|
|
||||||
// State ----------------------
|
|
||||||
// Handlers -------------------
|
|
||||||
// View -----------------------
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Screen showBack={true} style={styles.container}>
|
|
||||||
<Text style={styles.title}>Manage Subgroups</Text>
|
|
||||||
<Text style={styles.body}>Manage subgroups setup goes here.</Text>
|
|
||||||
</Screen>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {justifyContent: 'center', alignItems: 'center'},
|
|
||||||
title: {fontSize: 24, fontWeight: 'bold'},
|
|
||||||
body: {color: '#4b5563', fontSize: 15, marginTop: 10},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default ManageSubgroupsScreen;
|
|
||||||
@@ -2,7 +2,6 @@ import React, {useCallback, useEffect, useState} from 'react';
|
|||||||
import {ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native';
|
import {ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native';
|
||||||
import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
|
import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
|
||||||
import * as Location from 'expo-location';
|
import * as Location from 'expo-location';
|
||||||
import {Magnetometer} from 'expo-sensors';
|
|
||||||
import Screen from '../layout/Screen';
|
import Screen from '../layout/Screen';
|
||||||
import {Button, ButtonTray} from '../UI/Button';
|
import {Button, ButtonTray} from '../UI/Button';
|
||||||
import CacheCardItem from '../gameplay/CacheCardItem';
|
import CacheCardItem from '../gameplay/CacheCardItem';
|
||||||
@@ -11,7 +10,7 @@ import PlayerMapView from '../gameplay/PlayerMapView';
|
|||||||
import useGameHook from '../../hooks/useGameHook';
|
import useGameHook from '../../hooks/useGameHook';
|
||||||
import usePlayerGame from '../../hooks/usePlayerGame';
|
import usePlayerGame from '../../hooks/usePlayerGame';
|
||||||
import {getSession, setSessionGroup, setSessionUser} from '../../hooks/SessionStore';
|
import {getSession, setSessionGroup, setSessionUser} from '../../hooks/SessionStore';
|
||||||
import {getFovCone, toHeading} from '../../utils/geoMath';
|
import {getFovCone} from '../../utils/geoMath';
|
||||||
|
|
||||||
const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.05, longitudeDelta: 0.05};
|
const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.05, longitudeDelta: 0.05};
|
||||||
|
|
||||||
@@ -87,18 +86,27 @@ const MapScreen = ({navigation}) => {
|
|||||||
setPlayerLoading(false);
|
setPlayerLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const current = await Location.getCurrentPositionAsync({accuracy: Location.Accuracy.Balanced});
|
|
||||||
|
// 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});
|
||||||
|
} else {
|
||||||
|
const current = await Location.getCurrentPositionAsync({accuracy: Location.Accuracy.Low});
|
||||||
setUserLocation({latitude: current.coords.latitude, longitude: current.coords.longitude});
|
setUserLocation({latitude: current.coords.latitude, longitude: current.coords.longitude});
|
||||||
|
}
|
||||||
|
setPlayerLoading(false);
|
||||||
|
|
||||||
locationSub = await Location.watchPositionAsync(
|
locationSub = await Location.watchPositionAsync(
|
||||||
{accuracy: Location.Accuracy.Balanced, distanceInterval: 1, timeInterval: 1000},
|
{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}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track heading for all in-game users (admin + player)
|
// Use OS-fused heading from expo-location
|
||||||
Magnetometer.setUpdateInterval(500);
|
headingSub = await Location.watchHeadingAsync((headingData) => {
|
||||||
headingSub = Magnetometer.addListener((data) => setHeading(toHeading(data)));
|
const raw = headingData.trueHeading >= 0 ? headingData.trueHeading : headingData.magHeading;
|
||||||
setPlayerLoading(false);
|
setHeading(raw);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
start();
|
start();
|
||||||
@@ -110,7 +118,7 @@ const MapScreen = ({navigation}) => {
|
|||||||
|
|
||||||
const handleJoinGame = async () => {
|
const handleJoinGame = async () => {
|
||||||
if (!gameCode.trim()) return;
|
if (!gameCode.trim()) return;
|
||||||
const result = await joinPrivateGame({JoinCode: gameCode.trim(), Uid: session.currentUid});
|
const result = await joinPrivateGame({JoinCode: gameCode.trim().toUpperCase(), Uid: session.currentUid});
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
setSessionGroup(result.Gid, result.SGid);
|
setSessionGroup(result.Gid, result.SGid);
|
||||||
setInGame(true);
|
setInGame(true);
|
||||||
@@ -196,13 +204,14 @@ const MapScreen = ({navigation}) => {
|
|||||||
setEditingCacheId(null);
|
setEditingCacheId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Navigate to expanded map view
|
// Navigate to expanded map view, passing the live location so it opens centred on the user
|
||||||
const handleExpandMap = () => {
|
const handleExpandMap = () => {
|
||||||
navigation.navigate('ExpandedMapScreen', {
|
navigation.navigate('ExpandedMapScreen', {
|
||||||
isAdmin,
|
isAdmin,
|
||||||
cacheRecords: JSON.stringify(cacheRecords),
|
cacheRecords: JSON.stringify(cacheRecords),
|
||||||
heading: heading || 0,
|
heading: heading || 0,
|
||||||
claimDistance,
|
claimDistance,
|
||||||
|
userLocation: userLocation || null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -306,7 +315,8 @@ const MapScreen = ({navigation}) => {
|
|||||||
value={newClue}
|
value={newClue}
|
||||||
onChangeText={setNewClue}
|
onChangeText={setNewClue}
|
||||||
/>
|
/>
|
||||||
<View style={styles.coordRow}> <View style={styles.coordField}>
|
<View style={styles.coordRow}>
|
||||||
|
<View style={styles.coordField}>
|
||||||
<Text style={styles.coordLabel}>Latitude</Text>
|
<Text style={styles.coordLabel}>Latitude</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.formInput}
|
style={styles.formInput}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, {useCallback, useEffect, useState} from 'react';
|
import React, {useCallback, useEffect, useState} from 'react';
|
||||||
import {ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View} from 'react-native';
|
import {ActivityIndicator, ScrollView, StyleSheet, Text, View} from 'react-native';
|
||||||
import Screen from '../layout/Screen';
|
import Screen from '../layout/Screen';
|
||||||
import Card from '../UI/Card';
|
import Card from '../UI/Card';
|
||||||
import {Button} from '../UI/Button';
|
import {Button} from '../UI/Button';
|
||||||
@@ -10,93 +10,59 @@ const PlayersScreen = () => {
|
|||||||
// Initialisation ------------
|
// Initialisation ------------
|
||||||
|
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
const {getLobby, getTeams, getTeamMembers, getGroupMembers, removeMember, getUser, getCaches} = useGameHook();
|
const {getTeams, getTeamMembers, getGroupMembers, removeMember, getUser} = useGameHook();
|
||||||
|
|
||||||
// State ----------------------
|
// State ----------------------
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [groupInfo, setGroupInfo] = useState(null);
|
|
||||||
const [teams, setTeams] = useState([]);
|
|
||||||
const [players, setPlayers] = useState([]);
|
const [players, setPlayers] = useState([]);
|
||||||
const [selectedTeam, setSelectedTeam] = useState(null);
|
|
||||||
const [teamMembers, setTeamMembers] = useState([]);
|
|
||||||
const [memberNames, setMemberNames] = useState({});
|
|
||||||
const [memberCacheCounts, setMemberCacheCounts] = useState({});
|
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
|
||||||
|
|
||||||
// Handlers -------------------
|
// Handlers -------------------
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
if (!session.currentGid) {
|
if (!session.currentGid) { setLoading(false); return; }
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const group = await getLobby(session.currentGid);
|
|
||||||
setGroupInfo(group);
|
|
||||||
|
|
||||||
if (group && group.TeamsEnabled) {
|
// Get all non-admin players
|
||||||
const teamRows = await getTeams(session.currentGid);
|
|
||||||
setTeams(teamRows || []);
|
|
||||||
} else {
|
|
||||||
const allMembers = await getGroupMembers(session.currentGid);
|
const allMembers = await getGroupMembers(session.currentGid);
|
||||||
const nonAdminMembers = (allMembers || []).filter((m) => !m.IsAcceptedAdmin);
|
const nonAdminMembers = (allMembers || []).filter((m) => !m.IsAcceptedAdmin);
|
||||||
|
|
||||||
|
// Build team lookup: { Uid -> { teamCode, teamName, isLeader } }
|
||||||
|
const allTeams = await getTeams(session.currentGid);
|
||||||
|
const teamInfoByUid = {};
|
||||||
|
for (const team of (allTeams || [])) {
|
||||||
|
const tms = await getTeamMembers(team.Tid);
|
||||||
|
for (const tm of (tms || [])) {
|
||||||
|
teamInfoByUid[tm.Uid] = {
|
||||||
|
teamCode: team.JoinCode,
|
||||||
|
teamName: team.TeamName,
|
||||||
|
isLeader: Boolean(tm.IsLeader),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build player list
|
||||||
const playerList = [];
|
const playerList = [];
|
||||||
for (const m of nonAdminMembers) {
|
for (const m of nonAdminMembers) {
|
||||||
const user = await getUser(m.Uid);
|
const user = await getUser(m.Uid);
|
||||||
if (user) playerList.push({...user, membershipId: m.id});
|
if (user) {
|
||||||
|
playerList.push({
|
||||||
|
...user,
|
||||||
|
membershipId: m.id,
|
||||||
|
team: teamInfoByUid[m.Uid] || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setPlayers(playerList);
|
setPlayers(playerList);
|
||||||
}
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [session.currentGid]);
|
}, [session.currentGid]);
|
||||||
|
|
||||||
useEffect(() => { loadData(); }, [loadData]);
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
|
|
||||||
const handleSelectTeam = async (team) => {
|
|
||||||
setSelectedTeam(team);
|
|
||||||
setDetailLoading(true);
|
|
||||||
|
|
||||||
const ms = await getTeamMembers(team.Tid);
|
|
||||||
setTeamMembers(ms || []);
|
|
||||||
|
|
||||||
const names = {};
|
|
||||||
for (const m of (ms || [])) {
|
|
||||||
const user = await getUser(m.Uid);
|
|
||||||
if (user) names[m.Uid] = user.username;
|
|
||||||
}
|
|
||||||
setMemberNames(names);
|
|
||||||
|
|
||||||
const caches = await getCaches(session.currentGid, null);
|
|
||||||
const counts = {};
|
|
||||||
for (const m of (ms || [])) {
|
|
||||||
counts[m.Uid] = (caches || []).filter((c) => c.ClaimedByUid === m.Uid).length;
|
|
||||||
}
|
|
||||||
setMemberCacheCounts(counts);
|
|
||||||
setDetailLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBackToTeams = () => {
|
|
||||||
setSelectedTeam(null);
|
|
||||||
setTeamMembers([]);
|
|
||||||
setMemberNames({});
|
|
||||||
setMemberCacheCounts({});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemovePlayer = async (membershipId) => {
|
const handleRemovePlayer = async (membershipId) => {
|
||||||
await removeMember(membershipId);
|
await removeMember(membershipId);
|
||||||
await loadData();
|
await loadData();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveTeamMember = async (member) => {
|
|
||||||
// Find this user's subgroup membership to remove them from the game
|
|
||||||
const allMembers = await getGroupMembers(session.currentGid);
|
|
||||||
const membership = (allMembers || []).find((m) => m.Uid === member.Uid && !m.IsAcceptedAdmin);
|
|
||||||
if (membership) {
|
|
||||||
await removeMember(membership.id);
|
|
||||||
await handleSelectTeam(selectedTeam);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// View -----------------------
|
// View -----------------------
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -115,101 +81,24 @@ const PlayersScreen = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Teams enabled — show team detail view
|
|
||||||
if (groupInfo?.TeamsEnabled && selectedTeam) {
|
|
||||||
return (
|
|
||||||
<Screen style={styles.container}>
|
|
||||||
<View style={styles.teamDetailHeader}>
|
|
||||||
<Text style={styles.teamDetailName}>{selectedTeam.TeamName || 'Unnamed Team'}</Text>
|
|
||||||
<Text style={styles.teamDetailCode}>Code: {selectedTeam.JoinCode}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{detailLoading ? (
|
|
||||||
<View style={styles.center}>
|
|
||||||
<ActivityIndicator size="large"/>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<ScrollView style={styles.listSection}>
|
|
||||||
<Text style={styles.sectionTitle}>Team Members</Text>
|
|
||||||
{teamMembers.map((m) => (
|
|
||||||
<Card key={m.id}>
|
|
||||||
<View style={styles.memberRow}>
|
|
||||||
<Text style={styles.memberName}>
|
|
||||||
{memberNames[m.Uid] || `Player ${m.Uid}`}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.memberCaches}>
|
|
||||||
{memberCacheCounts[m.Uid] || 0} cache(s) claimed
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.removeWrap}>
|
|
||||||
<Button
|
|
||||||
label="Remove"
|
|
||||||
onClick={() => handleRemoveTeamMember(m)}
|
|
||||||
styleButton={styles.removeButton}
|
|
||||||
styleLabel={styles.removeLabel}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
{teamMembers.length === 0 && (
|
|
||||||
<Text style={styles.emptyText}>No members in this team.</Text>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={styles.backWrap}>
|
|
||||||
<Button
|
|
||||||
label="Back to Teams"
|
|
||||||
onClick={handleBackToTeams}
|
|
||||||
styleButton={styles.backButton}
|
|
||||||
styleLabel={styles.backLabel}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</Screen>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Teams enabled — show team cards
|
|
||||||
if (groupInfo?.TeamsEnabled) {
|
|
||||||
return (
|
|
||||||
<Screen style={styles.container}>
|
|
||||||
<Text style={styles.sectionTitle}>Teams</Text>
|
|
||||||
<ScrollView style={styles.listSection}>
|
|
||||||
{teams.map((team) => (
|
|
||||||
<Pressable key={team.Tid} onPress={() => handleSelectTeam(team)}>
|
|
||||||
<Card>
|
|
||||||
<View style={styles.teamCardRow}>
|
|
||||||
<Text style={styles.teamName}>
|
|
||||||
{team.TeamName || 'Unnamed Team'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.teamCode}>
|
|
||||||
Code: {team.JoinCode}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
{teams.length === 0 && (
|
|
||||||
<Text style={styles.emptyText}>No teams have been created yet.</Text>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
</Screen>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Teams disabled — show player list
|
|
||||||
return (
|
return (
|
||||||
<Screen style={styles.container}>
|
<Screen style={styles.container}>
|
||||||
<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}>
|
<Card key={player.Uid}>
|
||||||
<View style={styles.memberRow}>
|
<View style={styles.playerRow}>
|
||||||
<Text style={styles.playerName}>{player.username}</Text>
|
<Text style={styles.playerName} numberOfLines={1}>{player.username}</Text>
|
||||||
|
<View style={styles.playerMeta}>
|
||||||
|
{player.team?.isLeader && (
|
||||||
|
<Text style={styles.leaderBadge}>(Team Leader)</Text>
|
||||||
|
)}
|
||||||
|
{player.team && (
|
||||||
|
<Text style={styles.teamCodeBadge}>{player.team.teamCode}</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.removeWrap}>
|
|
||||||
<Button
|
<Button
|
||||||
label="Remove"
|
label="Kick"
|
||||||
onClick={() => handleRemovePlayer(player.membershipId)}
|
onClick={() => handleRemovePlayer(player.membershipId)}
|
||||||
styleButton={styles.removeButton}
|
styleButton={styles.removeButton}
|
||||||
styleLabel={styles.removeLabel}
|
styleLabel={styles.removeLabel}
|
||||||
@@ -239,38 +128,17 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
listSection: {flex: 1, paddingHorizontal: 15},
|
listSection: {flex: 1, paddingHorizontal: 15},
|
||||||
emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 30, fontSize: 14},
|
emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 30, fontSize: 14},
|
||||||
// Team cards
|
playerRow: {
|
||||||
teamCardRow: {
|
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
},
|
},
|
||||||
teamName: {fontSize: 16, fontWeight: '600', color: '#1f2937'},
|
playerName: {fontSize: 15, fontWeight: '600', color: '#1f2937', flex: 1},
|
||||||
teamCode: {fontSize: 14, fontWeight: '700', color: '#2563eb', letterSpacing: 1},
|
playerMeta: {flexDirection: 'row', alignItems: 'center', gap: 6},
|
||||||
// Team detail
|
leaderBadge: {fontSize: 11, color: '#2563eb', fontWeight: '700'},
|
||||||
teamDetailHeader: {
|
teamCodeBadge: {fontSize: 12, fontWeight: '700', color: '#16a34a', letterSpacing: 1},
|
||||||
backgroundColor: '#374151',
|
removeButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36, flex: 0, paddingHorizontal: 12},
|
||||||
paddingVertical: 14,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
},
|
|
||||||
teamDetailName: {fontSize: 18, fontWeight: '700', color: '#ffffff', marginBottom: 4},
|
|
||||||
teamDetailCode: {fontSize: 14, fontWeight: '600', color: '#93c5fd', letterSpacing: 1},
|
|
||||||
memberRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
memberName: {fontSize: 16, fontWeight: '600', color: '#1f2937'},
|
|
||||||
memberCaches: {fontSize: 13, fontWeight: '600', color: '#16a34a'},
|
|
||||||
// Remove button
|
|
||||||
removeWrap: {marginTop: 8},
|
|
||||||
removeButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36},
|
|
||||||
removeLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
|
removeLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
|
||||||
backWrap: {padding: 15},
|
|
||||||
backButton: {backgroundColor: '#6b7280', borderColor: '#6b7280'},
|
|
||||||
backLabel: {color: '#ffffff', fontWeight: '600'},
|
|
||||||
// Player list
|
|
||||||
playerName: {fontSize: 16, fontWeight: '600', color: '#1f2937'},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default PlayersScreen;
|
export default PlayersScreen;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, {useEffect, useState} from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
import {ActivityIndicator, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native';
|
import {ActivityIndicator, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native';
|
||||||
import Screen from '../layout/Screen';
|
import Screen from '../layout/Screen';
|
||||||
|
import Card from '../UI/Card';
|
||||||
import {Button, ButtonTray} from '../UI/Button';
|
import {Button, ButtonTray} from '../UI/Button';
|
||||||
import useGameHook from '../../hooks/useGameHook';
|
import useGameHook from '../../hooks/useGameHook';
|
||||||
import {getSession, setSessionTeam} from '../../hooks/SessionStore';
|
import {getSession, setSessionTeam} from '../../hooks/SessionStore';
|
||||||
@@ -23,12 +24,10 @@ const TeamScreen = () => {
|
|||||||
|
|
||||||
// Handlers -------------------
|
// Handlers -------------------
|
||||||
|
|
||||||
useEffect(() => {
|
const loadTeamData = async (tid) => {
|
||||||
const load = async () => {
|
const t = await getTeam(tid);
|
||||||
if (activeTid) {
|
|
||||||
const t = await getTeam(activeTid);
|
|
||||||
setTeam(t);
|
setTeam(t);
|
||||||
const ms = await getTeamMembers(activeTid);
|
const ms = await getTeamMembers(tid);
|
||||||
setMembers(ms || []);
|
setMembers(ms || []);
|
||||||
const names = {};
|
const names = {};
|
||||||
for (const m of (ms || [])) {
|
for (const m of (ms || [])) {
|
||||||
@@ -36,7 +35,11 @@ const TeamScreen = () => {
|
|||||||
if (u) names[m.Uid] = u.username;
|
if (u) names[m.Uid] = u.username;
|
||||||
}
|
}
|
||||||
setMemberNames(names);
|
setMemberNames(names);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
if (activeTid) await loadTeamData(activeTid);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
@@ -44,7 +47,7 @@ const TeamScreen = () => {
|
|||||||
|
|
||||||
const handleJoinTeam = async () => {
|
const handleJoinTeam = async () => {
|
||||||
if (!teamCode.trim()) return;
|
if (!teamCode.trim()) return;
|
||||||
const result = await joinTeamByCode({JoinCode: teamCode.trim(), Uid: session.currentUid});
|
const result = await joinTeamByCode({JoinCode: teamCode.trim().toUpperCase(), Uid: session.currentUid});
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
setSessionTeam(result.Tid);
|
setSessionTeam(result.Tid);
|
||||||
setActiveTid(result.Tid);
|
setActiveTid(result.Tid);
|
||||||
@@ -73,6 +76,15 @@ const TeamScreen = () => {
|
|||||||
setMemberNames({});
|
setMemberNames({});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleKickMember = async (member) => {
|
||||||
|
await leaveTeam(member.id);
|
||||||
|
await loadTeamData(activeTid);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if current user is the team leader
|
||||||
|
const myMembership = members.find((m) => m.Uid === session.currentUid);
|
||||||
|
const isCurrentUserLeader = Boolean(myMembership?.IsLeader);
|
||||||
|
|
||||||
// View -----------------------
|
// View -----------------------
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -91,6 +103,7 @@ const TeamScreen = () => {
|
|||||||
<TextInput
|
<TextInput
|
||||||
style={styles.codeInput}
|
style={styles.codeInput}
|
||||||
placeholder="Enter Team Code"
|
placeholder="Enter Team Code"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
value={teamCode}
|
value={teamCode}
|
||||||
onChangeText={setTeamCode}
|
onChangeText={setTeamCode}
|
||||||
autoCapitalize="characters"
|
autoCapitalize="characters"
|
||||||
@@ -127,9 +140,24 @@ const TeamScreen = () => {
|
|||||||
</View>
|
</View>
|
||||||
<ScrollView style={styles.memberList}>
|
<ScrollView style={styles.memberList}>
|
||||||
{members.map((m) => (
|
{members.map((m) => (
|
||||||
<View key={m.id} style={styles.memberItem}>
|
<Card key={m.id}>
|
||||||
<Text style={styles.memberName}>{memberNames[m.Uid] || `Player ${m.Uid}`}</Text>
|
<View style={styles.memberRow}>
|
||||||
|
<View style={styles.memberInfo}>
|
||||||
|
<Text style={styles.memberName}>
|
||||||
|
{memberNames[m.Uid] || `Player ${m.Uid}`}
|
||||||
|
</Text>
|
||||||
|
{m.IsLeader && <Text style={styles.leaderBadge}>(Team Leader)</Text>}
|
||||||
</View>
|
</View>
|
||||||
|
{isCurrentUserLeader && m.Uid !== session.currentUid && (
|
||||||
|
<Button
|
||||||
|
label="Kick"
|
||||||
|
onClick={() => handleKickMember(m)}
|
||||||
|
styleButton={styles.kickButton}
|
||||||
|
styleLabel={styles.kickLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
))}
|
))}
|
||||||
{members.length === 0 && (
|
{members.length === 0 && (
|
||||||
<Text style={styles.emptyText}>No members yet.</Text>
|
<Text style={styles.emptyText}>No members yet.</Text>
|
||||||
@@ -178,14 +206,18 @@ const styles = StyleSheet.create({
|
|||||||
teamCodeLabel: {color: '#d1d5db', fontSize: 15, fontWeight: '600'},
|
teamCodeLabel: {color: '#d1d5db', fontSize: 15, fontWeight: '600'},
|
||||||
teamCodeValue: {color: '#ffffff', fontSize: 17, fontWeight: '700', letterSpacing: 2},
|
teamCodeValue: {color: '#ffffff', fontSize: 17, fontWeight: '700', letterSpacing: 2},
|
||||||
memberList: {flex: 1, paddingHorizontal: 15, paddingTop: 10},
|
memberList: {flex: 1, paddingHorizontal: 15, paddingTop: 10},
|
||||||
memberItem: {
|
memberRow: {
|
||||||
paddingVertical: 14,
|
flexDirection: 'row',
|
||||||
borderBottomWidth: 1,
|
justifyContent: 'space-between',
|
||||||
borderBottomColor: '#e5e7eb',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
memberName: {fontSize: 16, color: '#1f2937'},
|
memberInfo: {flex: 1, flexDirection: 'row', alignItems: 'center', gap: 6},
|
||||||
|
memberName: {fontSize: 16, color: '#1f2937', fontWeight: '600'},
|
||||||
|
leaderBadge: {fontSize: 12, color: '#2563eb', fontWeight: '700'},
|
||||||
|
kickButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36, flex: 0, paddingHorizontal: 14},
|
||||||
|
kickLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
|
||||||
emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 30, fontSize: 14},
|
emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 30, fontSize: 14},
|
||||||
leaveWrap: {padding: 15},
|
leaveWrap: {paddingHorizontal: 15, paddingVertical: 10},
|
||||||
leaveButton: {backgroundColor: '#dc2626', borderColor: '#dc2626'},
|
leaveButton: {backgroundColor: '#dc2626', borderColor: '#dc2626'},
|
||||||
leaveLabel: {color: '#ffffff', fontWeight: '600'},
|
leaveLabel: {color: '#ffffff', fontWeight: '600'},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
// --- Session Store ---
|
// --- Session Store (with AsyncStorage persistence) ---
|
||||||
|
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
const STORAGE_KEY = '@mad_session';
|
||||||
|
|
||||||
const session = {
|
const session = {
|
||||||
currentUid: null,
|
currentUid: null,
|
||||||
@@ -7,6 +11,7 @@ const session = {
|
|||||||
currentTid: null,
|
currentTid: null,
|
||||||
isBusiness: null,
|
isBusiness: null,
|
||||||
isAcceptedAdmin: false,
|
isAcceptedAdmin: false,
|
||||||
|
teamsEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Handlers ---
|
// --- Handlers ---
|
||||||
@@ -18,6 +23,7 @@ const setSessionUser = (user) => {
|
|||||||
session.currentTid = user?.TGid ?? null;
|
session.currentTid = user?.TGid ?? null;
|
||||||
session.isBusiness = user?.isBusiness ?? null;
|
session.isBusiness = user?.isBusiness ?? null;
|
||||||
session.isAcceptedAdmin = Boolean(user?.IsAcceptedAdmin);
|
session.isAcceptedAdmin = Boolean(user?.IsAcceptedAdmin);
|
||||||
|
persistSession();
|
||||||
};
|
};
|
||||||
|
|
||||||
const setSessionGroup = (gid, sgid = null) => {
|
const setSessionGroup = (gid, sgid = null) => {
|
||||||
@@ -25,10 +31,17 @@ const setSessionGroup = (gid, sgid = null) => {
|
|||||||
if (sgid !== null && sgid !== undefined) {
|
if (sgid !== null && sgid !== undefined) {
|
||||||
session.currentSGid = sgid;
|
session.currentSGid = sgid;
|
||||||
}
|
}
|
||||||
|
persistSession();
|
||||||
};
|
};
|
||||||
|
|
||||||
const setSessionTeam = (tid) => {
|
const setSessionTeam = (tid) => {
|
||||||
session.currentTid = tid ?? null;
|
session.currentTid = tid ?? null;
|
||||||
|
persistSession();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSessionTeamsEnabled = (val) => {
|
||||||
|
session.teamsEnabled = Boolean(val);
|
||||||
|
persistSession();
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSession = () => ({ ...session });
|
const getSession = () => ({ ...session });
|
||||||
@@ -40,6 +53,36 @@ const clearSession = () => {
|
|||||||
session.currentTid = null;
|
session.currentTid = null;
|
||||||
session.isBusiness = null;
|
session.isBusiness = null;
|
||||||
session.isAcceptedAdmin = false;
|
session.isAcceptedAdmin = false;
|
||||||
|
AsyncStorage.removeItem(STORAGE_KEY).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
export { setSessionUser, setSessionGroup, setSessionTeam, getSession, clearSession };
|
// Clear game data but keep user logged in
|
||||||
|
const clearGameSession = () => {
|
||||||
|
session.currentGid = null;
|
||||||
|
session.currentSGid = null;
|
||||||
|
session.currentTid = null;
|
||||||
|
session.isAcceptedAdmin = false;
|
||||||
|
session.teamsEnabled = false;
|
||||||
|
persistSession();
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Persistence helpers ---
|
||||||
|
|
||||||
|
const persistSession = () => {
|
||||||
|
AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(session)).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSession = async () => {
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const saved = JSON.parse(raw);
|
||||||
|
Object.assign(session, saved);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore read errors — start fresh
|
||||||
|
}
|
||||||
|
return { ...session };
|
||||||
|
};
|
||||||
|
|
||||||
|
export { setSessionUser, setSessionGroup, setSessionTeam, setSessionTeamsEnabled, getSession, clearSession, clearGameSession, loadSession };
|
||||||
@@ -4,14 +4,12 @@ const useGameHook = () => {
|
|||||||
// Initialisation ------------
|
// Initialisation ------------
|
||||||
|
|
||||||
const usersEndpoint = `${API_BASE_URL}/users`;
|
const usersEndpoint = `${API_BASE_URL}/users`;
|
||||||
const gameTypesEndpoint = `${API_BASE_URL}/game-types`;
|
|
||||||
const groupsEndpoint = `${API_BASE_URL}/groups`;
|
const groupsEndpoint = `${API_BASE_URL}/groups`;
|
||||||
const subgroupsEndpoint = `${API_BASE_URL}/subgroups`;
|
const subgroupsEndpoint = `${API_BASE_URL}/subgroups`;
|
||||||
const subgroupMembershipsEndpoint = `${API_BASE_URL}/subgroup-memberships`;
|
const subgroupMembershipsEndpoint = `${API_BASE_URL}/subgroup-memberships`;
|
||||||
const teamsEndpoint = `${API_BASE_URL}/teams`;
|
const teamsEndpoint = `${API_BASE_URL}/teams`;
|
||||||
const teamMembersEndpoint = `${API_BASE_URL}/team-members`;
|
const teamMembersEndpoint = `${API_BASE_URL}/team-members`;
|
||||||
const gameDataEndpoint = `${API_BASE_URL}/game-data`;
|
const gameDataEndpoint = `${API_BASE_URL}/game-data`;
|
||||||
const adminWaitlistEndpoint = `${API_BASE_URL}/admin-waitlist`;
|
|
||||||
|
|
||||||
// Handlers -------------------
|
// Handlers -------------------
|
||||||
|
|
||||||
@@ -26,18 +24,7 @@ const useGameHook = () => {
|
|||||||
return response.isSuccess ? response.result : null;
|
return response.isSuccess ? response.result : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Game types
|
|
||||||
const getGameTypes = async () => {
|
|
||||||
const response = await API.get(gameTypesEndpoint);
|
|
||||||
return response.isSuccess ? response.result : [];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Groups
|
// Groups
|
||||||
const getCreatedPrivateGame = async (userId) => {
|
|
||||||
const response = await API.get(`${groupsEndpoint}?CreatedByUid=${userId}`);
|
|
||||||
return response.isSuccess ? response.result : [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const createPrivateGame = async (payload) => {
|
const createPrivateGame = async (payload) => {
|
||||||
const response = await API.post(groupsEndpoint, payload);
|
const response = await API.post(groupsEndpoint, payload);
|
||||||
return response.isSuccess ? response.result : null;
|
return response.isSuccess ? response.result : null;
|
||||||
@@ -74,11 +61,6 @@ const useGameHook = () => {
|
|||||||
return response.isSuccess;
|
return response.isSuccess;
|
||||||
};
|
};
|
||||||
|
|
||||||
const joinAsAdmin = async (payload) => {
|
|
||||||
const response = await API.post(adminWaitlistEndpoint, payload);
|
|
||||||
return response.isSuccess ? response.result : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Teams
|
// Teams
|
||||||
const getTeams = async (gid) => {
|
const getTeams = async (gid) => {
|
||||||
const response = await API.get(`${teamsEndpoint}?Gid=${gid}`);
|
const response = await API.get(`${teamsEndpoint}?Gid=${gid}`);
|
||||||
@@ -152,14 +134,11 @@ const useGameHook = () => {
|
|||||||
return {
|
return {
|
||||||
getUser,
|
getUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
getGameTypes,
|
|
||||||
getCreatedPrivateGame,
|
|
||||||
createPrivateGame,
|
createPrivateGame,
|
||||||
getLobby,
|
getLobby,
|
||||||
updateGroup,
|
updateGroup,
|
||||||
getSubgroups,
|
getSubgroups,
|
||||||
joinPrivateGame,
|
joinPrivateGame,
|
||||||
joinAsAdmin,
|
|
||||||
getGroupMembers,
|
getGroupMembers,
|
||||||
removeMember,
|
removeMember,
|
||||||
getTeams,
|
getTeams,
|
||||||
|
|||||||
Reference in new issue
Block a user