Merge pull request #5 from mudabbir-ahmad/Mudabbir-DEV

Mudabbir-DEV
This commit is contained in:
bobbert authored and GitHub committed 2026-04-08 16:46:54 +01:00
commit e34cbba9b5
33 files changed
+962 -846

No files matched your search

+35 -43
View File
@@ -1,90 +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 {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="GameTypeScreen" component={GameTypeScreen}/> <GameStackNav.Screen name="ExpandedMapScreen" component={ExpandedMapScreen}/>
<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="AdminHubScreen" component={AdminHubScreen}/> <AdminStackNav.Screen name="ExpandedMapScreen" component={ExpandedMapScreen}/>
<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}/>
+5 -2
View File
@@ -29,8 +29,6 @@
} }
}, },
"permissions": [ "permissions": [
"ACCESS_FINE_LOCATION",
"ACCESS_COARSE_LOCATION",
"ACCESS_FINE_LOCATION", "ACCESS_FINE_LOCATION",
"ACCESS_COARSE_LOCATION" "ACCESS_COARSE_LOCATION"
], ],
@@ -38,6 +36,11 @@
}, },
"web": { "web": {
"favicon": "./assets/favicon.png" "favicon": "./assets/favicon.png"
},
"extra": {
"eas": {
"projectId": "c65a2849-f05d-48ef-9e36-950caa214a7d"
}
} }
} }
} }
+21
View File
@@ -0,0 +1,21 @@
{
"cli": {
"version": ">= 18.0.5",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}
+35 -15
View File
@@ -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
View File
@@ -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",
+60 -3
View File
@@ -4,7 +4,7 @@ const path = require('path');
// --- Configuration --- // --- Configuration ---
const PORT = process.env.PORT || 80; const PORT = process.env.PORT || 3000;
const DATA_DIR = path.join(__dirname, 'data'); const DATA_DIR = path.join(__dirname, 'data');
// --- Data Helpers --- // --- Data Helpers ---
@@ -209,6 +209,7 @@ addRoute('POST', '/groups', async (req, res) => {
CreatedByUid: payload.CreatedByUid, CreatedByUid: payload.CreatedByUid,
MaxMemberSubgroups: payload.MaxMemberSubgroups || 1, MaxMemberSubgroups: payload.MaxMemberSubgroups || 1,
TeamsEnabled: payload.TeamsEnabled !== undefined ? payload.TeamsEnabled : true, TeamsEnabled: payload.TeamsEnabled !== undefined ? payload.TeamsEnabled : true,
CacheTriggerMeters: payload.CacheTriggerMeters || 20,
ApprovedAdmins: [payload.CreatedByUid], ApprovedAdmins: [payload.CreatedByUid],
CreatedAt: new Date().toISOString(), CreatedAt: new Date().toISOString(),
}; };
@@ -358,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;
@@ -395,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);
}); });
@@ -471,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);
@@ -505,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);
}); });
+2 -2
View File
@@ -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, 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={cache.radius}
fillColor="rgba(59, 130, 246, 0.15)"
strokeColor="rgba(59, 130, 246, 0.85)"
/>
</React.Fragment>
))}
</MapView>
);
};
export default AdminCacheEditorView;
+9 -1
View File
@@ -8,6 +8,7 @@ const CacheCardItem = ({cache, isAdmin, onEdit, onDelete, onSelect}) => {
const isClaimed = Boolean(cache.ClaimedByUid); const isClaimed = Boolean(cache.ClaimedByUid);
const statusText = isClaimed ? 'Claimed' : 'Available'; const statusText = isClaimed ? 'Claimed' : 'Available';
const statusColor = isClaimed ? '#9ca3af' : '#16a34a'; const statusColor = isClaimed ? '#9ca3af' : '#16a34a';
const displayName = cache.name || cache.clue || 'Unnamed Cache';
// State ---------------------- // State ----------------------
// Handlers ------------------- // Handlers -------------------
@@ -16,7 +17,12 @@ const CacheCardItem = ({cache, isAdmin, onEdit, onDelete, onSelect}) => {
return ( return (
<Card> <Card>
<View style={styles.topRow}> <View style={styles.topRow}>
<View style={styles.nameWrap}>
<Text style={styles.name} numberOfLines={1}>{displayName}</Text>
{cache.name ? (
<Text style={styles.clue} numberOfLines={1}>{cache.clue}</Text> <Text style={styles.clue} numberOfLines={1}>{cache.clue}</Text>
) : null}
</View>
<Text style={[styles.status, {color: statusColor}]}>{statusText}</Text> <Text style={[styles.status, {color: statusColor}]}>{statusText}</Text>
</View> </View>
<View style={styles.actions}> <View style={styles.actions}>
@@ -55,7 +61,9 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
marginBottom: 8, marginBottom: 8,
}, },
clue: {fontSize: 15, fontWeight: '600', color: '#1f2937', flex: 1, marginRight: 8}, clue: {fontSize: 13, color: '#6b7280', marginTop: 2},
nameWrap: {flex: 1, marginRight: 8},
name: {fontSize: 15, fontWeight: '600', color: '#1f2937'},
status: {fontSize: 13, fontWeight: '600'}, status: {fontSize: 13, fontWeight: '600'},
actions: {flexDirection: 'row', gap: 8}, actions: {flexDirection: 'row', gap: 8},
editBtn: { editBtn: {
+4 -4
View File
@@ -1,11 +1,12 @@
import React, {useEffect, useState} from 'react'; import React, {useEffect, useState} from 'react';
import {StyleSheet, Text, View} from 'react-native'; import {StyleSheet, Text, View} from 'react-native';
import {CACHE_CLAIM_TIMER} from '../../utils/geoMath';
const ClaimTimerView = ({ cache, onClaimSuccess, isClaiming }) => { const ClaimTimerView = ({ cache, onClaimSuccess, isClaiming }) => {
// Initialisation ------------- // Initialisation -------------
// State ---------------------- // State ----------------------
const [timeLeft, setTimeLeft] = useState(5); const [timeLeft, setTimeLeft] = useState(CACHE_CLAIM_TIMER);
// Handlers ------------------- // Handlers -------------------
@@ -16,7 +17,7 @@ const ClaimTimerView = ({ cache, onClaimSuccess, isClaiming }) => {
} else if (isClaiming && timeLeft === 0 && cache) { } else if (isClaiming && timeLeft === 0 && cache) {
onClaimSuccess(cache.id); onClaimSuccess(cache.id);
} else { } else {
setTimeLeft(5); setTimeLeft(CACHE_CLAIM_TIMER);
} }
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [isClaiming, timeLeft, cache, onClaimSuccess]); }, [isClaiming, timeLeft, cache, onClaimSuccess]);
@@ -36,13 +37,12 @@ const ClaimTimerView = ({ cache, onClaimSuccess, isClaiming }) => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
overlay: { overlay: {
position: 'absolute',
bottom: 24,
alignSelf: 'center', alignSelf: 'center',
backgroundColor: 'rgba(0,0,0,0.75)', backgroundColor: 'rgba(0,0,0,0.75)',
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 12, paddingVertical: 12,
borderRadius: 10, borderRadius: 10,
marginTop: 8,
}, },
text: { text: {
color: '#ffffff', color: '#ffffff',
+23 -21
View File
@@ -1,8 +1,7 @@
import React from 'react'; import MapView, {Circle, Polygon} from 'react-native-maps';
import MapView, {Circle} from 'react-native-maps'; import {getFovCone} from '../../utils/geoMath';
import ClaimTimerView from './ClaimTimerView';
const PlayerMapView = ({ userLocation, visibleCache, isClaiming, onClaimSuccess }) => { const PlayerMapView = ({userLocation, visibleCache, heading, claimDistance}) => {
// Initialisation ------------- // Initialisation -------------
// State ---------------------- // State ----------------------
// Handlers ------------------- // Handlers -------------------
@@ -10,35 +9,38 @@ const PlayerMapView = ({ userLocation, visibleCache, isClaiming, onClaimSuccess
if (!userLocation) return null; if (!userLocation) return null;
const coneCoords = (heading !== null && heading !== undefined)
? getFovCone(userLocation, heading)
: null;
return ( return (
<>
<MapView <MapView
style={{flex: 1}} style={{flex: 1}}
initialRegion={{ region={{...userLocation, latitudeDelta: 0.01, longitudeDelta: 0.01}}
...userLocation, scrollEnabled={false}
latitudeDelta: 0.01, zoomEnabled={true}
longitudeDelta: 0.01, rotateEnabled={false}
}} pitchEnabled={false}
showsUserLocation showsUserLocation
> >
{visibleCache ? ( {visibleCache && (
<Circle <Circle
center={visibleCache.coordinates} center={visibleCache.coordinates}
radius={visibleCache.radius} 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} )}
</MapView> {coneCoords && (
<Polygon
<ClaimTimerView coordinates={coneCoords}
cache={visibleCache} fillColor="rgba(66,133,244,0.28)"
isClaiming={isClaiming} strokeColor="rgba(66,133,244,0.50)"
onClaimSuccess={onClaimSuccess} strokeWidth={1}
/> />
</> )}
</MapView>
); );
}; };
export default PlayerMapView; export default PlayerMapView;
+44 -40
View File
@@ -1,6 +1,7 @@
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 ------------
@@ -9,63 +10,60 @@ const BottomNavbar = ({ navigation, routeName }) => {
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;
+1 -8
View File
@@ -13,19 +13,12 @@ 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",
}; };
// State ---------------------- // State ----------------------
-25
View File
@@ -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;
+117
View File
@@ -0,0 +1,117 @@
import React, {useEffect, useState} from 'react';
import {StyleSheet, View} from 'react-native';
import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
import * as Location from 'expo-location';
import Screen from '../layout/Screen';
import {getFovCone} from '../../utils/geoMath';
const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.01, longitudeDelta: 0.01};
const ExpandedMapScreen = ({route}) => {
// Initialisation ------------
const {isAdmin, cacheRecords: cacheStr, claimDistance: routeClaimDistance, userLocation: routeLocation} = route.params || {};
const caches = cacheStr ? JSON.parse(cacheStr) : [];
const claimDistance = routeClaimDistance || 20;
// State ----------------------
// Seed with the location passed from MapScreen so the map opens centred on the user immediately
const [userLocation, setUserLocation] = useState(routeLocation || null);
const [heading, setHeading] = useState(null);
// Handlers -------------------
useEffect(() => {
let locationSub;
let headingSub;
const start = async () => {
const {status} = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') return;
// Only do a fresh one-shot fix if MapScreen didn't supply a location
if (!routeLocation) {
const last = await Location.getLastKnownPositionAsync();
if (last) {
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});
}
}
locationSub = await Location.watchPositionAsync(
{accuracy: Location.Accuracy.Balanced, distanceInterval: 2, timeInterval: 1000},
(next) => setUserLocation({latitude: next.coords.latitude, longitude: next.coords.longitude}),
);
// Use OS-fused heading from expo-location
headingSub = await Location.watchHeadingAsync((headingData) => {
const raw = headingData.trueHeading >= 0 ? headingData.trueHeading : headingData.magHeading;
setHeading(raw);
});
};
start();
return () => {
if (locationSub) locationSub.remove();
if (headingSub) headingSub.remove();
};
}, []);
const region = userLocation
? {...userLocation, latitudeDelta: 0.01, longitudeDelta: 0.01}
: DEFAULT_REGION;
// View -----------------------
const coneCoords = (userLocation && heading !== null)
? getFovCone(userLocation, heading)
: null;
return (
<Screen showBack={true} style={styles.container}>
<View style={styles.mapWrap}>
<MapView
style={{flex: 1}}
initialRegion={region}
showsUserLocation
>
{/* Only admins see cache pin locations */}
{isAdmin && caches.map((cache) => (
<React.Fragment key={cache.id}>
<Marker
coordinate={cache.coordinates}
title={cache.name || cache.clue}
pinColor="#2563eb"
/>
<Circle
center={cache.coordinates}
radius={claimDistance}
fillColor="rgba(59,130,246,0.15)"
strokeColor="rgba(59,130,246,0.85)"
/>
</React.Fragment>
))}
{/* Heading FOV cone */}
{coneCoords && (
<Polygon
coordinates={coneCoords}
fillColor="rgba(66,133,244,0.28)"
strokeColor="rgba(66,133,244,0.50)"
strokeWidth={1}
/>
)}
</MapView>
</View>
</Screen>
);
};
const styles = StyleSheet.create({
container: {padding: 0},
mapWrap: {flex: 1},
});
export default ExpandedMapScreen;
-25
View File
@@ -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;
+40 -12
View File
@@ -1,9 +1,9 @@
import React, {useEffect, useState} from 'react'; import React, {useEffect, useState} from 'react';
import {ActivityIndicator, StyleSheet, Switch, Text, TextInput, View} from 'react-native'; import {ActivityIndicator, ScrollView, StyleSheet, Switch, Text, TextInput, View} from 'react-native';
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,8 +17,9 @@ 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 [adminJoinCode, setAdminJoinCode] = useState(''); const [adminJoinCode, setAdminJoinCode] = useState('');
const [memberJoinCode, setMemberJoinCode] = useState(''); const [memberJoinCode, setMemberJoinCode] = useState('');
@@ -33,8 +34,12 @@ 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));
setAdminJoinCode(group.AdminJoinCode || ''); setAdminJoinCode(group.AdminJoinCode || '');
} }
const sgs = await getSubgroups(session.currentGid); const sgs = await getSubgroups(session.currentGid);
@@ -48,11 +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,
}); });
// Persist the new toggle value so the session reflects it after a reload
if (result) setSessionTeamsEnabled(teamsEnabled);
setSaving(false); setSaving(false);
}; };
@@ -76,6 +84,7 @@ const GameSettingsScreen = () => {
return ( return (
<Screen> <Screen>
<ScrollView contentContainerStyle={styles.formContainer} showsVerticalScrollIndicator={false}>
<Text style={styles.sectionTitle}>Game Configuration</Text> <Text style={styles.sectionTitle}>Game Configuration</Text>
<Text style={styles.label}>Game Name</Text> <Text style={styles.label}>Game Name</Text>
@@ -84,10 +93,11 @@ const GameSettingsScreen = () => {
value={groupName} value={groupName}
onChangeText={setGroupName} onChangeText={setGroupName}
placeholder="Enter game name" placeholder="Enter game name"
placeholderTextColor="#9ca3af"
/> />
<View style={styles.toggleRow}> <View style={styles.toggleRow}>
<Text style={styles.label}>Teams Enabled</Text> <Text style={styles.toggleLabel}>Teams Enabled</Text>
<Switch <Switch
value={teamsEnabled} value={teamsEnabled}
onValueChange={setTeamsEnabled} onValueChange={setTeamsEnabled}
@@ -105,10 +115,21 @@ const GameSettingsScreen = () => {
onChangeText={setMaxSubgroups} onChangeText={setMaxSubgroups}
keyboardType="numeric" keyboardType="numeric"
placeholder="1" placeholder="1"
placeholderTextColor="#9ca3af"
/> />
</> </>
)} )}
<Text style={styles.label}>Cache Claim Distance (metres)</Text>
<TextInput
style={styles.input}
value={cacheTriggerMeters}
onChangeText={setCacheTriggerMeters}
keyboardType="numeric"
placeholder="20"
placeholderTextColor="#9ca3af"
/>
<View style={styles.codeSection}> <View style={styles.codeSection}>
<Text style={styles.codeLabel}>Admin Join Code</Text> <Text style={styles.codeLabel}>Admin Join Code</Text>
<Text style={styles.codeValue}>{adminJoinCode || '—'}</Text> <Text style={styles.codeValue}>{adminJoinCode || '—'}</Text>
@@ -119,6 +140,7 @@ const GameSettingsScreen = () => {
<Text style={styles.codeValue}>{memberJoinCode || '—'}</Text> <Text style={styles.codeValue}>{memberJoinCode || '—'}</Text>
</View> </View>
<View style={styles.saveWrap}>
<ButtonTray> <ButtonTray>
<Button <Button
label={saving ? 'Saving...' : 'Save Settings'} label={saving ? 'Saving...' : 'Save Settings'}
@@ -127,14 +149,17 @@ const GameSettingsScreen = () => {
styleLabel={styles.saveLabel} styleLabel={styles.saveLabel}
/> />
</ButtonTray> </ButtonTray>
</View>
</ScrollView>
</Screen> </Screen>
); );
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
center: {justifyContent: 'center', alignItems: 'center'}, center: {justifyContent: 'center', alignItems: 'center'},
sectionTitle: {fontSize: 20, fontWeight: '700', color: '#1f2937', marginBottom: 15}, formContainer: {paddingBottom: 20},
label: {fontSize: 14, fontWeight: '600', color: '#374151', marginBottom: 4, marginTop: 10}, sectionTitle: {fontSize: 20, fontWeight: '700', color: '#1f2937', marginBottom: 16},
label: {fontSize: 14, fontWeight: '600', color: '#374151', marginBottom: 6, marginTop: 14},
body: {color: '#4b5563', fontSize: 15}, body: {color: '#4b5563', fontSize: 15},
input: { input: {
borderWidth: 1, borderWidth: 1,
@@ -143,29 +168,32 @@ const styles = StyleSheet.create({
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
fontSize: 16, fontSize: 16,
color: '#1f2937',
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
}, },
toggleRow: { toggleRow: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
marginTop: 10, marginTop: 16,
paddingVertical: 8, paddingVertical: 10,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: '#e5e7eb', borderBottomColor: '#e5e7eb',
}, },
toggleLabel: {fontSize: 14, fontWeight: '600', color: '#374151'},
codeSection: { codeSection: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
marginTop: 14, marginTop: 16,
paddingVertical: 10, paddingVertical: 12,
paddingHorizontal: 12, paddingHorizontal: 12,
backgroundColor: '#f3f4f6', backgroundColor: '#f3f4f6',
borderRadius: 8, borderRadius: 8,
}, },
codeLabel: {fontSize: 14, fontWeight: '600', color: '#374151'}, codeLabel: {fontSize: 14, fontWeight: '600', color: '#374151'},
codeValue: {fontSize: 16, fontWeight: '700', color: '#2563eb', letterSpacing: 2}, codeValue: {fontSize: 16, fontWeight: '700', color: '#2563eb', letterSpacing: 2},
saveWrap: {marginTop: 24},
saveButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'}, saveButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'},
saveLabel: {color: '#ffffff', fontWeight: '600'}, saveLabel: {color: '#ffffff', fontWeight: '600'},
}); });
-73
View File
@@ -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;
+224 -8
View File
@@ -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;
+4
View File
@@ -39,6 +39,7 @@ const LoginScreen = ({ navigation }) => {
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Email" placeholder="Email"
placeholderTextColor="#9ca3af"
autoCapitalize="none" autoCapitalize="none"
keyboardType="email-address" keyboardType="email-address"
value={email} value={email}
@@ -48,6 +49,7 @@ const LoginScreen = ({ navigation }) => {
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Password" placeholder="Password"
placeholderTextColor="#9ca3af"
secureTextEntry secureTextEntry
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
@@ -93,6 +95,8 @@ const styles = StyleSheet.create({
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
fontSize: 16, fontSize: 16,
color: '#1f2937',
backgroundColor: '#ffffff',
}, },
errorText: { errorText: {
color: '#dc2626', color: '#dc2626',
-144
View File
@@ -1,144 +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} = useGameHook();
// State ----------------------
const [userLocation, setUserLocation] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [cacheRecords, setCacheRecords] = useState([]);
// 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(() => {
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,
radius: 20,
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,
radius: current.radius,
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}
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;
+115 -48
View File
@@ -1,22 +1,19 @@
import React, {useCallback, useEffect, useState} from 'react'; import React, {useCallback, useEffect, useState} from 'react';
import {ActivityIndicator, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native'; import {ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native';
import MapView, {Circle, Marker} 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';
import ClaimTimerView from '../gameplay/ClaimTimerView';
import PlayerMapView from '../gameplay/PlayerMapView'; 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} 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};
const toHeading = ({x, y}) => {
const angle = Math.atan2(y, x) * (180 / Math.PI);
return angle < 0 ? angle + 360 : angle;
};
const MapScreen = ({navigation}) => { const MapScreen = ({navigation}) => {
// Initialisation ------------ // Initialisation ------------
@@ -40,14 +37,18 @@ const MapScreen = ({navigation}) => {
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [editingCacheId, setEditingCacheId] = useState(null); const [editingCacheId, setEditingCacheId] = useState(null);
const [newCoord, setNewCoord] = useState(null); const [newCoord, setNewCoord] = useState(null);
const [newName, setNewName] = useState('');
const [newClue, setNewClue] = useState(''); const [newClue, setNewClue] = useState('');
const [newRadius, setNewRadius] = useState('20');
// Global claim distance comes from the group's CacheTriggerMeters setting
const claimDistance = groupInfo?.CacheTriggerMeters || 20;
const isPlayer = inGame && !isAdmin; const isPlayer = inGame && !isAdmin;
const {visibleCache, isClaiming, setIsClaiming} = usePlayerGame( const {visibleCache, isClaiming, setIsClaiming} = usePlayerGame(
isPlayer ? userLocation : null, isPlayer ? userLocation : null,
isPlayer ? heading : null, isPlayer ? heading : null,
isPlayer ? cacheRecords : [], isPlayer ? cacheRecords : [],
claimDistance,
); );
const mapRegion = userLocation const mapRegion = userLocation
@@ -66,8 +67,7 @@ const MapScreen = ({navigation}) => {
setCacheRecords([]); setCacheRecords([]);
return; return;
} }
const sgFilter = isAdmin ? null : session.currentSGid; const rows = await getCaches(session.currentGid, null);
const rows = await getCaches(session.currentGid, sgFilter);
setCacheRecords(rows || []); setCacheRecords(rows || []);
}, [inGame, isAdmin]); }, [inGame, isAdmin]);
@@ -86,19 +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}),
); );
if (isPlayer) { // 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;
} setHeading(raw);
setPlayerLoading(false); });
}; };
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);
@@ -152,8 +160,8 @@ const MapScreen = ({navigation}) => {
const fallback = userLocation || {latitude: mapRegion.latitude, longitude: mapRegion.longitude}; const fallback = userLocation || {latitude: mapRegion.latitude, longitude: mapRegion.longitude};
setEditingCacheId(null); setEditingCacheId(null);
setNewCoord(fallback); setNewCoord(fallback);
setNewName('');
setNewClue(''); setNewClue('');
setNewRadius('20');
setIsCreating(true); setIsCreating(true);
}; };
@@ -161,8 +169,8 @@ const MapScreen = ({navigation}) => {
const handleEditCache = (cache) => { const handleEditCache = (cache) => {
setEditingCacheId(cache.id); setEditingCacheId(cache.id);
setNewCoord(cache.coordinates); setNewCoord(cache.coordinates);
setNewName(cache.name || '');
setNewClue(cache.clue || ''); setNewClue(cache.clue || '');
setNewRadius(String(cache.radius || 20));
setIsCreating(true); setIsCreating(true);
}; };
@@ -178,9 +186,9 @@ const MapScreen = ({navigation}) => {
if (!newCoord || !newClue.trim() || !session.currentGid) return; if (!newCoord || !newClue.trim() || !session.currentGid) return;
const payload = { const payload = {
gid: session.currentGid, gid: session.currentGid,
name: newName.trim(),
latitude: newCoord.latitude, latitude: newCoord.latitude,
longitude: newCoord.longitude, longitude: newCoord.longitude,
radius: parseInt(newRadius) || 20,
clue: newClue.trim(), clue: newClue.trim(),
subgroupId: 1, subgroupId: 1,
}; };
@@ -196,7 +204,18 @@ const MapScreen = ({navigation}) => {
setEditingCacheId(null); setEditingCacheId(null);
}; };
// Player — select cache (scroll to / highlight) // Navigate to expanded map view, passing the live location so it opens centred on the user
const handleExpandMap = () => {
navigation.navigate('ExpandedMapScreen', {
isAdmin,
cacheRecords: JSON.stringify(cacheRecords),
heading: heading || 0,
claimDistance,
userLocation: userLocation || null,
});
};
// Player — select cache
const handleSelectCache = (cache) => { const handleSelectCache = (cache) => {
// Future: scroll map to cache location // Future: scroll map to cache location
}; };
@@ -211,6 +230,7 @@ const MapScreen = ({navigation}) => {
<TextInput <TextInput
style={styles.codeInput} style={styles.codeInput}
placeholder="Enter Game Code" placeholder="Enter Game Code"
placeholderTextColor="#9ca3af"
value={gameCode} value={gameCode}
onChangeText={setGameCode} onChangeText={setGameCode}
autoCapitalize="characters" autoCapitalize="characters"
@@ -254,7 +274,7 @@ const MapScreen = ({navigation}) => {
<Marker coordinate={cache.coordinates} pinColor="#9ca3af" title={cache.clue}/> <Marker coordinate={cache.coordinates} pinColor="#9ca3af" title={cache.clue}/>
<Circle <Circle
center={cache.coordinates} center={cache.coordinates}
radius={cache.radius} radius={claimDistance}
fillColor="rgba(156,163,175,0.15)" fillColor="rgba(156,163,175,0.15)"
strokeColor="rgba(156,163,175,0.6)" strokeColor="rgba(156,163,175,0.6)"
/> />
@@ -272,7 +292,7 @@ const MapScreen = ({navigation}) => {
/> />
<Circle <Circle
center={newCoord} center={newCoord}
radius={parseInt(newRadius) || 20} radius={claimDistance}
fillColor="rgba(37,99,235,0.15)" fillColor="rgba(37,99,235,0.15)"
strokeColor="rgba(37,99,235,0.85)" strokeColor="rgba(37,99,235,0.85)"
/> />
@@ -283,7 +303,15 @@ const MapScreen = ({navigation}) => {
<ScrollView style={styles.formSection}> <ScrollView style={styles.formSection}>
<TextInput <TextInput
style={styles.formInput} style={styles.formInput}
placeholder="Cache's Clue" placeholder="Cache Name"
placeholderTextColor="#9ca3af"
value={newName}
onChangeText={setNewName}
/>
<TextInput
style={styles.formInput}
placeholder="Cache Clue"
placeholderTextColor="#9ca3af"
value={newClue} value={newClue}
onChangeText={setNewClue} onChangeText={setNewClue}
/> />
@@ -293,6 +321,7 @@ const MapScreen = ({navigation}) => {
<TextInput <TextInput
style={styles.formInput} style={styles.formInput}
placeholder="Latitude" placeholder="Latitude"
placeholderTextColor="#9ca3af"
value={newCoord ? String(newCoord.latitude.toFixed(6)) : ''} value={newCoord ? String(newCoord.latitude.toFixed(6)) : ''}
editable={false} editable={false}
/> />
@@ -302,18 +331,12 @@ const MapScreen = ({navigation}) => {
<TextInput <TextInput
style={styles.formInput} style={styles.formInput}
placeholder="Longitude" placeholder="Longitude"
placeholderTextColor="#9ca3af"
value={newCoord ? String(newCoord.longitude.toFixed(6)) : ''} value={newCoord ? String(newCoord.longitude.toFixed(6)) : ''}
editable={false} editable={false}
/> />
</View> </View>
</View> </View>
<TextInput
style={styles.formInput}
placeholder="Claim Radius (metres)"
value={newRadius}
onChangeText={setNewRadius}
keyboardType="numeric"
/>
<ButtonTray> <ButtonTray>
<Button <Button
label={editingCacheId ? 'Update Cache' : 'Save Cache'} label={editingCacheId ? 'Update Cache' : 'Save Cache'}
@@ -337,24 +360,37 @@ const MapScreen = ({navigation}) => {
if (isAdmin) { if (isAdmin) {
return ( return (
<Screen style={styles.containerMap}> <Screen style={styles.containerMap}>
<View style={styles.mapWrap}> <View style={styles.mapContainer}>
<MapView <MapView
style={{flex: 1}} style={{flex: 1}}
initialRegion={mapRegion} region={mapRegion}
scrollEnabled={true}
zoomEnabled={true}
showsUserLocation showsUserLocation
> >
{cacheRecords.map((cache) => ( {cacheRecords.map((cache) => (
<React.Fragment key={cache.id}> <React.Fragment key={cache.id}>
<Marker coordinate={cache.coordinates} title={cache.clue}/> <Marker coordinate={cache.coordinates} title={cache.name || cache.clue}/>
<Circle <Circle
center={cache.coordinates} center={cache.coordinates}
radius={cache.radius} radius={claimDistance}
fillColor="rgba(59,130,246,0.15)" fillColor="rgba(59,130,246,0.15)"
strokeColor="rgba(59,130,246,0.85)" strokeColor="rgba(59,130,246,0.85)"
/> />
</React.Fragment> </React.Fragment>
))} ))}
{userLocation && heading !== null && (
<Polygon
coordinates={getFovCone(userLocation, heading)}
fillColor="rgba(66,133,244,0.28)"
strokeColor="rgba(66,133,244,0.50)"
strokeWidth={1}
/>
)}
</MapView> </MapView>
<Pressable style={styles.expandButton} onPress={handleExpandMap}>
<Text style={styles.expandIcon}>⛶</Text>
</Pressable>
</View> </View>
<View style={styles.createCacheWrap}> <View style={styles.createCacheWrap}>
<Button <Button
@@ -364,8 +400,8 @@ const MapScreen = ({navigation}) => {
styleLabel={styles.createCacheLabel} styleLabel={styles.createCacheLabel}
/> />
</View> </View>
<View style={styles.cacheSection}> <View style={styles.cacheListWrap}>
<ScrollView> <ScrollView contentContainerStyle={styles.cacheListContent}>
{cacheRecords.map((cache) => ( {cacheRecords.map((cache) => (
<CacheCardItem <CacheCardItem
key={cache.id} key={cache.id}
@@ -411,14 +447,22 @@ const MapScreen = ({navigation}) => {
{teamsWarning && ( {teamsWarning && (
<Text style={styles.warning}>You are not in a team. Teams are required for this game!</Text> <Text style={styles.warning}>You are not in a team. Teams are required for this game!</Text>
)} )}
<View style={styles.mapWrap}> <View style={styles.mapContainer}>
<PlayerMapView <PlayerMapView
userLocation={userLocation} userLocation={userLocation}
visibleCache={visibleCache} visibleCache={visibleCache}
heading={heading}
claimDistance={claimDistance}
/>
<Pressable style={styles.expandButton} onPress={handleExpandMap}>
<Text style={styles.expandIcon}>⛶</Text>
</Pressable>
</View>
<ClaimTimerView
cache={visibleCache}
isClaiming={isClaiming} isClaiming={isClaiming}
onClaimSuccess={handleClaim} onClaimSuccess={handleClaim}
/> />
</View>
<View style={styles.cacheSection}> <View style={styles.cacheSection}>
<ScrollView> <ScrollView>
{cacheRecords.map((cache) => ( {cacheRecords.map((cache) => (
@@ -441,7 +485,7 @@ const MapScreen = ({navigation}) => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
center: {justifyContent: 'center', alignItems: 'center'}, center: {justifyContent: 'center', alignItems: 'center'},
containerMap: {padding: 0}, containerMap: {padding: 0},
mapWrap: {flex: 1}, mapContainer: {height: 200},
error: {color: '#dc2626', fontSize: 15}, error: {color: '#dc2626', fontSize: 15},
loadingText: {color: '#6b7280', fontSize: 14, marginTop: 10}, loadingText: {color: '#6b7280', fontSize: 14, marginTop: 10},
inputRow: {flexDirection: 'row', gap: 10, marginBottom: 15, width: '100%', paddingHorizontal: 20}, inputRow: {flexDirection: 'row', gap: 10, marginBottom: 15, width: '100%', paddingHorizontal: 20},
@@ -454,6 +498,8 @@ const styles = StyleSheet.create({
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
fontSize: 16, fontSize: 16,
color: '#1f2937',
backgroundColor: '#ffffff',
}, },
joinButton: {backgroundColor: '#2563eb', borderColor: '#2563eb', flex: 0, paddingHorizontal: 20}, joinButton: {backgroundColor: '#2563eb', borderColor: '#2563eb', flex: 0, paddingHorizontal: 20},
joinLabel: {color: '#ffffff', fontWeight: '600'}, joinLabel: {color: '#ffffff', fontWeight: '600'},
@@ -466,9 +512,32 @@ const styles = StyleSheet.create({
paddingVertical: 8, paddingVertical: 8,
fontSize: 14, fontSize: 14,
}, },
createCacheWrap: {paddingHorizontal: 12, paddingVertical: 8}, expandButton: {
createCacheButton: {backgroundColor: '#2563eb', borderColor: '#2563eb'}, position: 'absolute',
top: 10,
left: 10,
backgroundColor: 'rgba(0,0,0,0.6)',
borderRadius: 6,
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
zIndex: 10,
},
expandIcon: {color: '#ffffff', fontSize: 18, fontWeight: '700'},
// Admin create button + scrollable cache list
createCacheWrap: {
paddingHorizontal: 12,
paddingVertical: 10,
backgroundColor: '#ffffff',
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
createCacheButton: {backgroundColor: '#2563eb', borderColor: '#2563eb', flex: 0},
createCacheLabel: {color: '#ffffff', fontWeight: '600'}, createCacheLabel: {color: '#ffffff', fontWeight: '600'},
cacheListWrap: {flex: 1, backgroundColor: '#f3f4f6'},
cacheListContent: {paddingHorizontal: 12, paddingTop: 10, paddingBottom: 12},
// Cache editor form
formSection: {flex: 1, paddingHorizontal: 12, paddingVertical: 10}, formSection: {flex: 1, paddingHorizontal: 12, paddingVertical: 10},
formInput: { formInput: {
borderWidth: 1, borderWidth: 1,
@@ -477,6 +546,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
fontSize: 16, fontSize: 16,
color: '#1f2937',
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
marginBottom: 10, marginBottom: 10,
}, },
@@ -487,13 +557,10 @@ const styles = StyleSheet.create({
saveLabel: {color: '#ffffff', fontWeight: '600'}, saveLabel: {color: '#ffffff', fontWeight: '600'},
cancelButton: {backgroundColor: '#6b7280', borderColor: '#6b7280'}, cancelButton: {backgroundColor: '#6b7280', borderColor: '#6b7280'},
cancelLabel: {color: '#ffffff', fontWeight: '600'}, cancelLabel: {color: '#ffffff', fontWeight: '600'},
cacheSection: { // Player cache section
maxHeight: 160, cacheSection: {flex: 1, backgroundColor: '#f3f4f6', paddingHorizontal: 12, paddingTop: 10},
backgroundColor: '#f3f4f6',
paddingHorizontal: 12,
paddingTop: 10,
},
emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 20, fontSize: 14}, emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 20, fontSize: 14},
mapWrap: {flex: 1},
}); });
export default MapScreen; export default MapScreen;
+58 -157
View File
@@ -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,80 +10,57 @@ const PlayersScreen = () => {
// Initialisation ------------ // Initialisation ------------
const session = getSession(); const session = getSession();
const {getLobby, getTeams, getTeamMembers, getGroupMembers, 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
// Teams enabled — load all teams for this group const allMembers = await getGroupMembers(session.currentGid);
const teamRows = await getTeams(session.currentGid); const nonAdminMembers = (allMembers || []).filter((m) => !m.IsAcceptedAdmin);
setTeams(teamRows || []);
} else { // Build team lookup: { Uid -> { teamCode, teamName, isLeader } }
// Teams disabled — load all members for this group const allTeams = await getTeams(session.currentGid);
const memberships = await getGroupMembers(session.currentGid); const teamInfoByUid = {};
const nonAdminMembers = (memberships || []).filter((m) => !m.IsAcceptedAdmin); 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); 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) => { const handleRemovePlayer = async (membershipId) => {
setSelectedTeam(team); await removeMember(membershipId);
setDetailLoading(true); await loadData();
// Load team members and their usernames
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);
// Load all caches to count individual contributions
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({});
}; };
// View ----------------------- // View -----------------------
@@ -104,88 +81,29 @@ 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>
</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}>
<Text style={styles.playerName}>{player.username}</Text> <View style={styles.playerRow}>
<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>
<Button
label="Kick"
onClick={() => handleRemovePlayer(player.membershipId)}
styleButton={styles.removeButton}
styleLabel={styles.removeLabel}
/>
</View>
</Card> </Card>
))} ))}
{players.length === 0 && ( {players.length === 0 && (
@@ -204,40 +122,23 @@ const styles = StyleSheet.create({
fontSize: 20, fontSize: 20,
fontWeight: '700', fontWeight: '700',
color: '#1f2937', color: '#1f2937',
marginBottom: 10, marginBottom: 12,
paddingHorizontal: 15, paddingHorizontal: 15,
paddingTop: 15, paddingTop: 15,
}, },
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, removeLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
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'},
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;
+6
View File
@@ -42,6 +42,7 @@ const RegisterScreen = ({navigation}) => {
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Username" placeholder="Username"
placeholderTextColor="#9ca3af"
value={username} value={username}
onChangeText={setUsername} onChangeText={setUsername}
editable={!isLoading} editable={!isLoading}
@@ -49,6 +50,7 @@ const RegisterScreen = ({navigation}) => {
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Email" placeholder="Email"
placeholderTextColor="#9ca3af"
autoCapitalize="none" autoCapitalize="none"
keyboardType="email-address" keyboardType="email-address"
value={email} value={email}
@@ -58,6 +60,7 @@ const RegisterScreen = ({navigation}) => {
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Password" placeholder="Password"
placeholderTextColor="#9ca3af"
secureTextEntry secureTextEntry
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
@@ -66,6 +69,7 @@ const RegisterScreen = ({navigation}) => {
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Confirm Password" placeholder="Confirm Password"
placeholderTextColor="#9ca3af"
secureTextEntry secureTextEntry
value={confirmPassword} value={confirmPassword}
onChangeText={setConfirmPassword} onChangeText={setConfirmPassword}
@@ -132,6 +136,8 @@ const styles = StyleSheet.create({
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
fontSize: 16, fontSize: 16,
color: '#1f2937',
backgroundColor: '#ffffff',
}, },
typeRow: { typeRow: {
flexDirection: 'row', flexDirection: 'row',
+49 -15
View File
@@ -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>
@@ -160,6 +188,8 @@ const styles = StyleSheet.create({
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
fontSize: 16, fontSize: 16,
color: '#1f2937',
backgroundColor: '#ffffff',
}, },
joinButton: {backgroundColor: '#2563eb', borderColor: '#2563eb', flex: 0, paddingHorizontal: 20}, joinButton: {backgroundColor: '#2563eb', borderColor: '#2563eb', flex: 0, paddingHorizontal: 20},
joinLabel: {color: '#ffffff', fontWeight: '600'}, joinLabel: {color: '#ffffff', fontWeight: '600'},
@@ -176,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'},
}); });
+45 -2
View File
@@ -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 -19
View File
@@ -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;
@@ -69,9 +56,9 @@ const useGameHook = () => {
return response.isSuccess ? response.result : []; return response.isSuccess ? response.result : [];
}; };
const joinAsAdmin = async (payload) => { const removeMember = async (membershipId) => {
const response = await API.post(adminWaitlistEndpoint, payload); const response = await API.delete(`${subgroupMembershipsEndpoint}/${membershipId}`);
return response.isSuccess ? response.result : null; return response.isSuccess;
}; };
// Teams // Teams
@@ -147,15 +134,13 @@ const useGameHook = () => {
return { return {
getUser, getUser,
updateUser, updateUser,
getGameTypes,
getCreatedPrivateGame,
createPrivateGame, createPrivateGame,
getLobby, getLobby,
updateGroup, updateGroup,
getSubgroups, getSubgroups,
joinPrivateGame, joinPrivateGame,
joinAsAdmin,
getGroupMembers, getGroupMembers,
removeMember,
getTeams, getTeams,
getTeam, getTeam,
createTeam, createTeam,
+10 -14
View File
@@ -1,7 +1,7 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {isLookingAtCache, isWithinRadius} from '../utils/geoMath'; import {isInClaimCone} from '../utils/geoMath';
const usePlayerGame = (playerLocation, playerHeading, activeCaches) => { const usePlayerGame = (playerLocation, playerHeading, activeCaches, claimDistance) => {
// State ---------------------- // State ----------------------
const [visibleCache, setVisibleCache] = useState(null); const [visibleCache, setVisibleCache] = useState(null);
@@ -16,19 +16,15 @@ const usePlayerGame = (playerLocation, playerHeading, activeCaches) => {
return; return;
} }
const nearbyCache = (activeCaches || []).find((cache) => // Find the first cache that falls inside the invisible claim cone
isWithinRadius(playerLocation, cache.coordinates, cache.radius)); // (same FOV angle as the visible cone, but radius = admin-set claimDistance)
const cacheInCone = (activeCaches || []).find((cache) =>
isInClaimCone(playerHeading, playerLocation, cache.coordinates, claimDistance)
);
if (!nearbyCache) { setVisibleCache(cacheInCone || null);
setVisibleCache(null); setIsClaiming(Boolean(cacheInCone));
setIsClaiming(false); }, [playerLocation, playerHeading, activeCaches, claimDistance]);
return;
}
const isTargeted = isLookingAtCache(playerHeading, playerLocation, nearbyCache.coordinates);
setVisibleCache(nearbyCache);
setIsClaiming(isTargeted);
}, [playerLocation, playerHeading, activeCaches]);
// Return --------------------- // Return ---------------------
+46 -2
View File
@@ -1,11 +1,44 @@
import * as geolib from 'geolib'; import * as geolib from 'geolib';
// --- FOV Configuration ---
const FOV_SIZE = 50; // metres — radius of the visible FOV cone drawn on screen
const FOV_ANGLE = 60; // degrees — total field of view (halved for each side)
const CACHE_CLAIM_TIMER = 5; // seconds — how long the player must hold a cache in the claim cone to claim it
// Convert raw Magnetometer {x, y} reading to a 0-360° heading (0° = north)
const toHeading = ({x, y}) => {
const angle = Math.atan2(y, x) * (180 / Math.PI);
const heading = 90 - angle;
return heading < 0 ? heading + 360 : heading % 360;
};
// Build a pie-shaped FOV sector as polygon coordinates
// radiusMeters defaults to FOV_SIZE (the visible cone) — pass a different value to build the invisible claim cone
const getFovCone = (location, headingDeg, radiusMeters = FOV_SIZE, halfAngle = FOV_ANGLE / 2, arcSegments = 20) => {
const origin = {latitude: location.latitude, longitude: location.longitude};
const startBearing = headingDeg - halfAngle;
const endBearing = headingDeg + halfAngle;
const step = (endBearing - startBearing) / arcSegments;
// Tip of the sector
const points = [location];
// Arc points at radiusMeters distance from origin
for (let i = 0; i <= arcSegments; i++) {
const bearing = startBearing + step * i;
const dest = geolib.computeDestinationPoint(origin, radiusMeters, bearing);
points.push({latitude: dest.latitude, longitude: dest.longitude});
}
return points;
};
const isWithinRadius = (playerCoords, cacheCoords, radius) => { const isWithinRadius = (playerCoords, cacheCoords, radius) => {
const distance = geolib.getDistance(playerCoords, cacheCoords); const distance = geolib.getDistance(playerCoords, cacheCoords);
return distance <= radius; return distance <= radius;
}; };
const isLookingAtCache = (playerHeading, playerCoords, cacheCoords, tolerance = 20) => { const isLookingAtCache = (playerHeading, playerCoords, cacheCoords, tolerance = FOV_ANGLE / 2) => {
if (playerHeading === null || playerHeading === undefined) { if (playerHeading === null || playerHeading === undefined) {
return false; return false;
} }
@@ -14,5 +47,16 @@ const isLookingAtCache = (playerHeading, playerCoords, cacheCoords, tolerance =
return diff <= tolerance || diff >= 360 - tolerance; return diff <= tolerance || diff >= 360 - tolerance;
}; };
export { isWithinRadius, isLookingAtCache }; // Checks whether a cache falls within the invisible claim sector
// The claim sector uses the same FOV_ANGLE as the visible cone but extends to the
// admin-defined claimDistance rather than the visible FOV_SIZE.
// If the cache sits inside this sector the claiming countdown is triggered.
const isInClaimCone = (playerHeading, playerCoords, cacheCoords, claimDistance) => {
if (playerHeading === null || playerHeading === undefined) return false;
// Cache must be within the admin-configured claim distance
if (!isWithinRadius(playerCoords, cacheCoords, claimDistance)) return false;
// Cache must also fall within the same angular width as the visible cone
return isLookingAtCache(playerHeading, playerCoords, cacheCoords);
};
export { toHeading, getFovCone, isWithinRadius, isLookingAtCache, isInClaimCone, FOV_ANGLE, FOV_SIZE, CACHE_CLAIM_TIMER };