diff --git a/App.js b/App.js index 161871f..222b6a0 100644 --- a/App.js +++ b/App.js @@ -1,93 +1,82 @@ +import {useEffect, useState} from "react"; +import {ActivityIndicator, View} from "react-native"; import {NavigationContainer} from "@react-navigation/native"; import {createNativeStackNavigator} from "@react-navigation/native-stack"; import {SafeAreaProvider} from "react-native-safe-area-context"; import LoginScreen from "./src/components/screens/LoginScreen"; 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 MapScreen from "./src/components/screens/MapScreen"; 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 PlayersScreen from "./src/components/screens/PlayersScreen"; import ExpandedMapScreen from "./src/components/screens/ExpandedMapScreen"; +import {loadSession} from "./src/hooks/SessionStore"; const RootStack = createNativeStackNavigator(); const AuthStackNav = createNativeStackNavigator(); const GameStackNav = createNativeStackNavigator(); const AdminStackNav = createNativeStackNavigator(); +const screenOptions = { + headerShown: false, + contentStyle: {backgroundColor: '#ffffff'}, +}; + const AuthStack = () => ( - + ); const GameStack = () => ( - + - - - - - - - ); const AdminStack = () => ( - + - - - - ); 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 ( + + + + ); + } + return ( - + diff --git a/app.json b/app.json index 243484a..3f8c7e5 100644 --- a/app.json +++ b/app.json @@ -18,8 +18,8 @@ "NSLocationWhenInUseUsageDescription": "This app needs your location for the treasure hunt." } }, - "android": { - "adaptiveIcon": { + "android": { + "adaptiveIcon": { "foregroundImage": "./assets/images/android-icon-monochrome.png", "backgroundColor": "#ffffff" }, diff --git a/package-lock.json b/package-lock.json index 94e37af..952e587 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "mad_treasure_hunt_app", "version": "1.0.0", "dependencies": { + "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/native": "^7.1.18", "@react-navigation/native-stack": "^7.3.28", "expo": "~54.0.33", @@ -16,7 +17,6 @@ "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", "expo-location": "~19.0.7", - "expo-sensors": "~15.0.7", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "expo-symbols": "~1.0.8", @@ -27,7 +27,7 @@ "react-dom": "19.1.0", "react-native": "0.81.5", "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-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", @@ -2352,6 +2352,18 @@ "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": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", @@ -5490,19 +5502,6 @@ "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": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz", @@ -6779,6 +6778,15 @@ "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": { "version": "1.2.1", "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", "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", diff --git a/package.json b/package.json index f6df575..07c8230 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "lint": "expo lint" }, "dependencies": { + "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/native": "^7.1.18", "@react-navigation/native-stack": "^7.3.28", "expo": "~54.0.33", @@ -17,24 +18,23 @@ "expo-font": "~14.0.11", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", + "expo-location": "~19.0.7", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "expo-symbols": "~1.0.8", "expo-system-ui": "~6.0.9", - "expo-location": "~19.0.7", "expo-web-browser": "~15.0.10", "geolib": "^3.3.4", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", - "react-native-maps": "1.20.1", - "react-native-worklets": "0.5.1", - "expo-sensors": "~15.0.7", + "react-native-maps": "^1.20.1", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.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": { "eslint": "^9.25.0", diff --git a/server/server.js b/server/server.js index 3d068a9..ec7d464 100644 --- a/server/server.js +++ b/server/server.js @@ -359,7 +359,7 @@ addRoute('POST', '/subgroup-memberships', async (req, res) => { // If a JoinCode was supplied, look up the matching subgroup if (payload.JoinCode) { 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' }); payload.Gid = sg.Gid; payload.SGid = sg.SGid; @@ -396,8 +396,39 @@ addRoute('DELETE', '/subgroup-memberships/:id', (req, res, params) => { const rows = readJson('subgroup-memberships.json'); const idx = rows.findIndex((r) => String(r.id) === params.id); if (idx === -1) return sendJson(res, 404, { message: 'Membership not found' }); + + const removed = rows[idx]; rows.splice(idx, 1); 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); }); @@ -472,12 +503,16 @@ addRoute('POST', '/team-members', async (req, res) => { // If a JoinCode was supplied, look up the matching team if (payload.JoinCode) { 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' }); 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.JoinedAt = new Date().toISOString(); + payload.IsLeader = existing.length === 0; rows.push(payload); writeJson('team-members.json', rows); @@ -506,8 +541,29 @@ addRoute('DELETE', '/team-members/:id', (req, res, params) => { const rows = readJson('team-members.json'); const idx = rows.findIndex((r) => String(r.id) === params.id); if (idx === -1) return sendJson(res, 404, { message: 'Team member not found' }); + + const removed = rows[idx]; 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); + + // 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); }); diff --git a/src/components/API/API.js b/src/components/API/API.js index 7b10cc3..1275d4a 100644 --- a/src/components/API/API.js +++ b/src/components/API/API.js @@ -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://192.168.8.100:3000'; //For when im home (API Endpoint is on my home server...) +//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'; // Home network for mudabbir since, server is on my home network and router dont do loopback!!! const API = {}; API.get = (endpoint) => callFetch(endpoint, 'GET'); diff --git a/src/components/gameplay/AdminCacheEditorView.js b/src/components/gameplay/AdminCacheEditorView.js deleted file mode 100644 index b15c6f3..0000000 --- a/src/components/gameplay/AdminCacheEditorView.js +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; -import MapView, {Circle, Marker} from 'react-native-maps'; - -const AdminCacheEditorView = ({ userLocation, caches, claimDistance, onAddCache, onMoveCache }) => { -// Initialisation ------------- -// State ---------------------- -// Handlers ------------------- -// View ----------------------- - - if (!userLocation) { - return null; - } - - return ( - onAddCache(event.nativeEvent.coordinate)} - scrollEnabled={true} - zoomEnabled={true} - showsUserLocation - > - {(caches || []).map((cache) => ( - - onMoveCache(cache.id, event.nativeEvent.coordinate)} - title={`Cache ${cache.id}`} - /> - - - ))} - - ); -}; - -export default AdminCacheEditorView; - diff --git a/src/components/gameplay/PlayerMapView.js b/src/components/gameplay/PlayerMapView.js index ffdf860..a888b2e 100644 --- a/src/components/gameplay/PlayerMapView.js +++ b/src/components/gameplay/PlayerMapView.js @@ -1,52 +1,46 @@ -import React from 'react'; import MapView, {Circle, Polygon} from 'react-native-maps'; import {getFovCone} from '../../utils/geoMath'; -const PlayerMapView = ({ userLocation, visibleCache, heading, claimDistance }) => { +const PlayerMapView = ({userLocation, visibleCache, heading, claimDistance}) => { // Initialisation ------------- // State ---------------------- // Handlers ------------------- // View ----------------------- - if (!userLocation) return null; + if (!userLocation) return null; - const coneCoords = (heading !== null && heading !== undefined) - ? getFovCone(userLocation, heading) - : null; + const coneCoords = (heading !== null && heading !== undefined) + ? getFovCone(userLocation, heading) + : null; - return ( - - {visibleCache ? ( - - ) : null} - {coneCoords && ( - - )} - - ); + return ( + + {visibleCache && ( + + )} + {coneCoords && ( + + )} + + ); }; - export default PlayerMapView; diff --git a/src/components/layout/BottomNavbar.js b/src/components/layout/BottomNavbar.js index 77dd1d9..528699a 100644 --- a/src/components/layout/BottomNavbar.js +++ b/src/components/layout/BottomNavbar.js @@ -1,71 +1,69 @@ -import {Pressable, StyleSheet, Text, View} from 'react-native'; +import {Alert, Pressable, StyleSheet, Text, View} from 'react-native'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; -import {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 ------------ const insets = useSafeAreaInsets(); const session = getSession(); const adminTabs = [ - { - label: 'Game Settings', - route: 'GameSettingsScreen', - onPress: () => navigation.navigate('GameSettingsScreen'), - }, - { - label: 'Map', - route: 'MapScreen', - onPress: () => navigation.navigate('MapScreen'), - }, - { - label: 'Players', - route: 'PlayersScreen', - onPress: () => navigation.navigate('PlayersScreen'), - }, - { - label: 'Leaderboard', - route: 'LeaderboardScreen', - onPress: () => navigation.navigate('LeaderboardScreen'), - }, + {label: 'Settings', route: 'GameSettingsScreen', onPress: () => navigation.navigate('GameSettingsScreen')}, + {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 = [ - { - label: 'Map', - route: 'MapScreen', - onPress: () => navigation.navigate('MapScreen'), - }, - { - label: 'Leaderboard', - route: 'LeaderboardScreen', - onPress: () => navigation.navigate('LeaderboardScreen'), - }, - { - label: 'Current Team', - route: 'TeamScreen', - onPress: () => navigation.navigate('TeamScreen'), - }, + {label: 'Team', route: 'TeamScreen', onPress: () => navigation.navigate('TeamScreen')}, + {label: 'Map', route: 'MapScreen', onPress: () => navigation.navigate('MapScreen')}, + {label: 'Leaderboard', route: 'LeaderboardScreen', onPress: () => navigation.navigate('LeaderboardScreen')}, + {label: 'Leave', route: '__leave__', onPress: () => handleLeaveGame()}, ]; const tabs = session.isAcceptedAdmin ? adminTabs : playerTabs; // State ---------------------- // 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 ----------------------- return ( {tabs.map((tab) => { const isActive = routeName === tab.route; + const isLeave = tab.route === '__leave__'; return ( - {tab.label} + + {tab.label} + ); })} @@ -95,6 +93,9 @@ const styles = StyleSheet.create({ tabActive: { backgroundColor: '#5c5c5c', }, + tabLeave: { + backgroundColor: 'rgba(220,38,38,0.15)', + }, tabText: { color: '#d1d5db', fontSize: 12, @@ -104,6 +105,9 @@ const styles = StyleSheet.create({ tabTextActive: { color: '#ffffff', }, + tabTextLeave: { + color: '#fca5a5', + }, }); export default BottomNavbar; diff --git a/src/components/layout/Screen.js b/src/components/layout/Screen.js index d5fd5f0..95957bd 100644 --- a/src/components/layout/Screen.js +++ b/src/components/layout/Screen.js @@ -13,17 +13,9 @@ const Screen = ({ children, style, showBack = false }) => { const insets = useSafeAreaInsets(); const titleMap = { - GameTypeScreen: "Game Type", - JoinPrivateGameScreen: "Join Private Game", - ManagePrivateGameScreen: "Manage Private Game", - CreateGameScreen: "Create Game", - ManageGameScreen: "Manage Game", - GameLobbyScreen: "Game Lobby", LeaderboardScreen: "Leaderboard", TeamScreen: "Current Team", MapScreen: "Map", - AdminHubScreen: "Admin Hub", - ManageSubgroupsScreen: "Manage Subgroups", GameSettingsScreen: "Game Settings", PlayersScreen: "Players", ExpandedMapScreen: "Map View", diff --git a/src/components/screens/AdminHubScreen.js b/src/components/screens/AdminHubScreen.js deleted file mode 100644 index ac49b8b..0000000 --- a/src/components/screens/AdminHubScreen.js +++ /dev/null @@ -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 ( - - Admin Hub - Admin hub setup goes here. - - ); -}; - -const styles = StyleSheet.create({ - container: {justifyContent: 'center', alignItems: 'center'}, - title: {fontSize: 24, fontWeight: 'bold'}, - body: {color: '#4b5563', fontSize: 15, marginTop: 10}, -}); - -export default AdminHubScreen; diff --git a/src/components/screens/CreateGameScreen.js b/src/components/screens/CreateGameScreen.js deleted file mode 100644 index cc0ec8c..0000000 --- a/src/components/screens/CreateGameScreen.js +++ /dev/null @@ -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 ( - - Create Game - Create game setup goes here. - - ); -}; - -const styles = StyleSheet.create({ - container: {justifyContent: 'center', alignItems: 'center'}, - title: {fontSize: 24, fontWeight: 'bold'}, - body: {color: '#4b5563', fontSize: 15, marginTop: 10}, -}); - -export default CreateGameScreen; diff --git a/src/components/screens/ExpandedMapScreen.js b/src/components/screens/ExpandedMapScreen.js index 01b576d..bc08f26 100644 --- a/src/components/screens/ExpandedMapScreen.js +++ b/src/components/screens/ExpandedMapScreen.js @@ -2,22 +2,22 @@ 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 {Magnetometer} from 'expo-sensors'; import Screen from '../layout/Screen'; -import {getFovCone, toHeading} from '../../utils/geoMath'; +import {getFovCone} from '../../utils/geoMath'; const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.01, longitudeDelta: 0.01}; const ExpandedMapScreen = ({route}) => { // Initialisation ------------ - const {isAdmin, cacheRecords: cacheStr, claimDistance: routeClaimDistance} = route.params || {}; + const {isAdmin, cacheRecords: cacheStr, claimDistance: routeClaimDistance, userLocation: routeLocation} = route.params || {}; const caches = cacheStr ? JSON.parse(cacheStr) : []; const claimDistance = routeClaimDistance || 20; // State ---------------------- - const [userLocation, setUserLocation] = useState(null); + // Seed with the location passed from MapScreen so the map opens centred on the user immediately + const [userLocation, setUserLocation] = useState(routeLocation || null); const [heading, setHeading] = useState(null); // Handlers ------------------- @@ -30,16 +30,27 @@ const ExpandedMapScreen = ({route}) => { const {status} = await Location.requestForegroundPermissionsAsync(); if (status !== 'granted') return; - const pos = await Location.getCurrentPositionAsync({accuracy: Location.Accuracy.Balanced}); - setUserLocation({latitude: pos.coords.latitude, longitude: pos.coords.longitude}); + // 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}), ); - Magnetometer.setUpdateInterval(500); - headingSub = Magnetometer.addListener((data) => setHeading(toHeading(data))); + // Use OS-fused heading from expo-location + headingSub = await Location.watchHeadingAsync((headingData) => { + const raw = headingData.trueHeading >= 0 ? headingData.trueHeading : headingData.magHeading; + setHeading(raw); + }); }; start(); @@ -75,7 +86,7 @@ const ExpandedMapScreen = ({route}) => { title={cache.name || cache.clue} pinColor="#2563eb" /> - { -// Initialisation ------------- -// State ---------------------- -// Handlers ------------------- -// View ----------------------- - - return ( - - Game Lobby - Game lobby setup goes here. - - ); -}; - -const styles = StyleSheet.create({ - container: {justifyContent: 'center', alignItems: 'center'}, - title: {fontSize: 24, fontWeight: 'bold'}, - body: {color: '#4b5563', fontSize: 15, marginTop: 10}, -}); - -export default GameLobbyScreen; diff --git a/src/components/screens/GameSettingsScreen.js b/src/components/screens/GameSettingsScreen.js index 41ec274..e389e6b 100644 --- a/src/components/screens/GameSettingsScreen.js +++ b/src/components/screens/GameSettingsScreen.js @@ -3,7 +3,7 @@ import {ActivityIndicator, ScrollView, StyleSheet, Switch, Text, TextInput, View import Screen from '../layout/Screen'; import {Button, ButtonTray} from '../UI/Button'; import useGameHook from '../../hooks/useGameHook'; -import {getSession} from '../../hooks/SessionStore'; +import {getSession, setSessionTeamsEnabled} from '../../hooks/SessionStore'; const GameSettingsScreen = () => { // Initialisation ------------ @@ -17,7 +17,7 @@ const GameSettingsScreen = () => { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [groupName, setGroupName] = useState(''); - const [teamsEnabled, setTeamsEnabled] = useState(false); + const [teamsEnabled, setTeamsEnabled] = useState(session.teamsEnabled); const [maxSubgroups, setMaxSubgroups] = useState('1'); const [cacheTriggerMeters, setCacheTriggerMeters] = useState('20'); const [adminJoinCode, setAdminJoinCode] = useState(''); @@ -34,7 +34,10 @@ const GameSettingsScreen = () => { const group = await getLobby(session.currentGid); if (group) { 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)); setCacheTriggerMeters(String(group.CacheTriggerMeters || 20)); setAdminJoinCode(group.AdminJoinCode || ''); @@ -50,12 +53,14 @@ const GameSettingsScreen = () => { const handleSave = async () => { if (!session.currentGid) return; setSaving(true); - await updateGroup(session.currentGid, { + const result = await updateGroup(session.currentGid, { GroupName: groupName.trim(), TeamsEnabled: teamsEnabled, 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); }; diff --git a/src/components/screens/GameTypeScreen.js b/src/components/screens/GameTypeScreen.js deleted file mode 100644 index 6573f51..0000000 --- a/src/components/screens/GameTypeScreen.js +++ /dev/null @@ -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 ( - - Select Game Type - - {gameTypes && gameTypes.map((type) => ( - navigation.navigate('GameLobbyScreen')} - > - {type} - - ))} - - - ); -}; - -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; diff --git a/src/components/screens/JoinPrivateGameScreen.js b/src/components/screens/JoinPrivateGameScreen.js deleted file mode 100644 index 221c83a..0000000 --- a/src/components/screens/JoinPrivateGameScreen.js +++ /dev/null @@ -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 ( - - Join Private Game - Join private game setup goes here. - - ); -}; - -const styles = StyleSheet.create({ - container: { - justifyContent: 'center', - alignItems: 'center', - }, - title: { - fontSize: 24, - fontWeight: 'bold', - }, - body: { - color: '#4b5563', - fontSize: 15, - marginTop: 10, - }, -}); - -export default JoinPrivateGameScreen; diff --git a/src/components/screens/LeaderboardScreen.js b/src/components/screens/LeaderboardScreen.js index 4ed2214..47cdd9a 100644 --- a/src/components/screens/LeaderboardScreen.js +++ b/src/components/screens/LeaderboardScreen.js @@ -1,25 +1,241 @@ -import React from 'react'; -import {StyleSheet, Text} from 'react-native'; +import React, {useCallback, useEffect, useState} from 'react'; +import {ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View} from 'react-native'; import Screen from '../layout/Screen'; +import Card from '../UI/Card'; +import useGameHook from '../../hooks/useGameHook'; +import {getSession} from '../../hooks/SessionStore'; const LeaderboardScreen = () => { -// Initialisation ------------- +// Initialisation ------------ + + const session = getSession(); + const {getLobby, getTeams, getTeamMembers, getGroupMembers, getCaches, getUser} = useGameHook(); + // State ---------------------- + + const [loading, setLoading] = useState(true); + const [teamsEnabled, setTeamsEnabled] = useState(false); + const [activeTab, setActiveTab] = useState('global'); + const [globalRanking, setGlobalRanking] = useState([]); + const [teamRanking, setTeamRanking] = useState([]); + const [expandedTeam, setExpandedTeam] = useState(null); + // 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 ----------------------- + if (loading) { + return ( + + + + ); + } + + if (!session.currentGid) { + return ( + + Join a game to view the leaderboard. + + ); + } + + // Teams enabled — tabbed view + if (teamsEnabled) { + return ( + + + setActiveTab('global')} + > + Global + + setActiveTab('team')} + > + Team + + + + {activeTab === 'global' ? ( + + {globalRanking.map((team, index) => ( + setExpandedTeam(expandedTeam === team.tid ? null : team.tid)}> + + + #{index + 1} + {team.name} + {team.totalCaches} cache(s) + + {expandedTeam === team.tid && team.members.map((m) => ( + + + {m.name}{m.isLeader ? ' (Leader)' : ''} + + {m.caches} + + ))} + + + ))} + {globalRanking.length === 0 && ( + No teams yet. + )} + + ) : ( + + {teamRanking.map((m, index) => ( + + + #{index + 1} + + {m.name}{m.isLeader ? ' (Leader)' : ''} + + {m.caches} cache(s) + + + ))} + {teamRanking.length === 0 && ( + Join a team to see your team leaderboard. + )} + + )} + + ); + } + + // No teams — simple player ranking return ( - Leaderboard - Leaderboard view goes here. + + {globalRanking.map((player, index) => ( + + + #{index + 1} + {player.name} + {player.caches} cache(s) + + + ))} + {globalRanking.length === 0 && ( + No players yet. + )} + ); }; const styles = StyleSheet.create({ - container: {justifyContent: 'center', alignItems: 'center'}, - title: {fontSize: 24, fontWeight: 'bold'}, - body: {color: '#4b5563', fontSize: 15, marginTop: 10}, + center: {justifyContent: 'center', alignItems: 'center'}, + container: {padding: 0}, + 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; diff --git a/src/components/screens/ManageGameScreen.js b/src/components/screens/ManageGameScreen.js deleted file mode 100644 index af9f962..0000000 --- a/src/components/screens/ManageGameScreen.js +++ /dev/null @@ -1,151 +0,0 @@ -import React, {useCallback, useEffect, useState} from 'react'; -import {ActivityIndicator, StyleSheet, Text, View} from 'react-native'; -import * as Location from 'expo-location'; -import Screen from '../layout/Screen'; -import AdminCacheEditorView from '../gameplay/AdminCacheEditorView'; -import useGameHook from '../../hooks/useGameHook'; -import {getSession} from '../../hooks/SessionStore'; - -const ManageGameScreen = () => { -// Initialisation ------------ - - const session = getSession(); - const {getCaches, upsertCache, getLobby} = useGameHook(); - -// State ---------------------- - - const [userLocation, setUserLocation] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - const [cacheRecords, setCacheRecords] = useState([]); - const [claimDistance, setClaimDistance] = useState(20); - -// Handlers ------------------- - - const loadCaches = useCallback(async () => { - if (!session.currentGid) { - setCacheRecords([]); - return; - } - const rows = await getCaches(session.currentGid, null); - setCacheRecords(rows || []); - }, [session.currentGid]); - - useEffect(() => { loadCaches(); }, [loadCaches]); - - useEffect(() => { - if (!session.currentGid) return; - getLobby(session.currentGid).then((group) => { - if (group) setClaimDistance(group.CacheTriggerMeters || 20); - }); - }, [session.currentGid]); - - useEffect(() => { - let locationSub; - const start = async () => { - const {status} = await Location.requestForegroundPermissionsAsync(); - if (status !== 'granted') { - setError('Location permission denied'); - setLoading(false); - return; - } - const current = await Location.getCurrentPositionAsync({accuracy: Location.Accuracy.Balanced}); - setUserLocation({latitude: current.coords.latitude, longitude: current.coords.longitude}); - - locationSub = await Location.watchPositionAsync( - {accuracy: Location.Accuracy.Balanced, distanceInterval: 1, timeInterval: 1000}, - (next) => { - setUserLocation({latitude: next.coords.latitude, longitude: next.coords.longitude}); - }, - ); - setLoading(false); - }; - start(); - return () => { if (locationSub) locationSub.remove(); }; - }, []); - - const handleAddCache = async (coordinate) => { - if (!session.currentGid) return; - await upsertCache({ - gid: session.currentGid, - latitude: coordinate.latitude, - longitude: coordinate.longitude, - clue: `Cache ${cacheRecords.length + 1}`, - subgroupId: 1, - }); - await loadCaches(); - }; - - const handleMoveCache = async (cacheId, coordinate) => { - if (!session.currentGid) return; - const current = cacheRecords.find((cache) => cache.id === cacheId); - if (!current) return; - await upsertCache({ - gid: session.currentGid, - cacheId, - latitude: coordinate.latitude, - longitude: coordinate.longitude, - clue: current.clue, - subgroupId: current.subgroupId, - }); - await loadCaches(); - }; - -// View ----------------------- - - if (!session.currentGid) { - return ( - - Create or join a game first. - - ); - } - - if (!session.isAcceptedAdmin) { - return ( - - Only admins can edit cache locations. - - ); - } - - if (loading) { - return ( - - - - ); - } - - if (error) { - return ( - - {error} - - ); - } - - return ( - - - - - - ); -}; - -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; diff --git a/src/components/screens/ManagePrivateGameScreen.js b/src/components/screens/ManagePrivateGameScreen.js deleted file mode 100644 index ad50eaf..0000000 --- a/src/components/screens/ManagePrivateGameScreen.js +++ /dev/null @@ -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 ( - - Manage Private Game - Manage private game setup goes here. - - ); -}; - -const styles = StyleSheet.create({ - container: {justifyContent: 'center', alignItems: 'center'}, - title: {fontSize: 24, fontWeight: 'bold'}, - body: {color: '#4b5563', fontSize: 15, marginTop: 10}, -}); - -export default ManagePrivateGameScreen; diff --git a/src/components/screens/ManageSubgroupsScreen.js b/src/components/screens/ManageSubgroupsScreen.js deleted file mode 100644 index 2f5aff9..0000000 --- a/src/components/screens/ManageSubgroupsScreen.js +++ /dev/null @@ -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 ( - - Manage Subgroups - Manage subgroups setup goes here. - - ); -}; - -const styles = StyleSheet.create({ - container: {justifyContent: 'center', alignItems: 'center'}, - title: {fontSize: 24, fontWeight: 'bold'}, - body: {color: '#4b5563', fontSize: 15, marginTop: 10}, -}); - -export default ManageSubgroupsScreen; diff --git a/src/components/screens/MapScreen.js b/src/components/screens/MapScreen.js index 0fca762..78a5543 100644 --- a/src/components/screens/MapScreen.js +++ b/src/components/screens/MapScreen.js @@ -2,7 +2,6 @@ import React, {useCallback, useEffect, useState} from 'react'; import {ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native'; import MapView, {Circle, Marker, Polygon} from 'react-native-maps'; import * as Location from 'expo-location'; -import {Magnetometer} from 'expo-sensors'; import Screen from '../layout/Screen'; import {Button, ButtonTray} from '../UI/Button'; import CacheCardItem from '../gameplay/CacheCardItem'; @@ -11,7 +10,7 @@ import PlayerMapView from '../gameplay/PlayerMapView'; import useGameHook from '../../hooks/useGameHook'; import usePlayerGame from '../../hooks/usePlayerGame'; import {getSession, setSessionGroup, setSessionUser} from '../../hooks/SessionStore'; -import {getFovCone, toHeading} from '../../utils/geoMath'; +import {getFovCone} from '../../utils/geoMath'; const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.05, longitudeDelta: 0.05}; @@ -87,18 +86,27 @@ const MapScreen = ({navigation}) => { setPlayerLoading(false); return; } - const current = await Location.getCurrentPositionAsync({accuracy: Location.Accuracy.Balanced}); - setUserLocation({latitude: current.coords.latitude, longitude: current.coords.longitude}); + + // 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}); + } + setPlayerLoading(false); locationSub = await Location.watchPositionAsync( {accuracy: Location.Accuracy.Balanced, distanceInterval: 1, timeInterval: 1000}, (next) => setUserLocation({latitude: next.coords.latitude, longitude: next.coords.longitude}), ); - // Track heading for all in-game users (admin + player) - Magnetometer.setUpdateInterval(500); - headingSub = Magnetometer.addListener((data) => setHeading(toHeading(data))); - setPlayerLoading(false); + // Use OS-fused heading from expo-location + headingSub = await Location.watchHeadingAsync((headingData) => { + const raw = headingData.trueHeading >= 0 ? headingData.trueHeading : headingData.magHeading; + setHeading(raw); + }); }; start(); @@ -110,7 +118,7 @@ const MapScreen = ({navigation}) => { const handleJoinGame = async () => { 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; setSessionGroup(result.Gid, result.SGid); setInGame(true); @@ -196,13 +204,14 @@ const MapScreen = ({navigation}) => { setEditingCacheId(null); }; - // Navigate to expanded map view + // Navigate to expanded map view, passing the live location so it opens centred on the user const handleExpandMap = () => { navigation.navigate('ExpandedMapScreen', { isAdmin, cacheRecords: JSON.stringify(cacheRecords), heading: heading || 0, claimDistance, + userLocation: userLocation || null, }); }; @@ -306,7 +315,8 @@ const MapScreen = ({navigation}) => { value={newClue} onChangeText={setNewClue} /> - + + Latitude { // Initialisation ------------ const session = getSession(); - const {getLobby, getTeams, getTeamMembers, getGroupMembers, removeMember, getUser, getCaches} = useGameHook(); + const {getTeams, getTeamMembers, getGroupMembers, removeMember, getUser} = useGameHook(); // State ---------------------- const [loading, setLoading] = useState(true); - const [groupInfo, setGroupInfo] = useState(null); - const [teams, setTeams] = 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 ------------------- const loadData = useCallback(async () => { - if (!session.currentGid) { - setLoading(false); - return; - } - const group = await getLobby(session.currentGid); - setGroupInfo(group); + if (!session.currentGid) { setLoading(false); return; } - if (group && group.TeamsEnabled) { - const teamRows = await getTeams(session.currentGid); - setTeams(teamRows || []); - } else { - const allMembers = await getGroupMembers(session.currentGid); - const nonAdminMembers = (allMembers || []).filter((m) => !m.IsAcceptedAdmin); - const playerList = []; - for (const m of nonAdminMembers) { - const user = await getUser(m.Uid); - if (user) playerList.push({...user, membershipId: m.id}); + // Get all non-admin players + const allMembers = await getGroupMembers(session.currentGid); + const nonAdminMembers = (allMembers || []).filter((m) => !m.IsAcceptedAdmin); + + // Build team lookup: { Uid -> { teamCode, teamName, isLeader } } + const allTeams = await getTeams(session.currentGid); + const teamInfoByUid = {}; + for (const team of (allTeams || [])) { + const tms = await getTeamMembers(team.Tid); + for (const tm of (tms || [])) { + teamInfoByUid[tm.Uid] = { + teamCode: team.JoinCode, + teamName: team.TeamName, + isLeader: Boolean(tm.IsLeader), + }; } - setPlayers(playerList); } + + // Build player list + const playerList = []; + for (const m of nonAdminMembers) { + const user = await getUser(m.Uid); + if (user) { + playerList.push({ + ...user, + membershipId: m.id, + team: teamInfoByUid[m.Uid] || null, + }); + } + } + setPlayers(playerList); setLoading(false); }, [session.currentGid]); useEffect(() => { loadData(); }, [loadData]); - const handleSelectTeam = async (team) => { - setSelectedTeam(team); - setDetailLoading(true); - - const ms = await getTeamMembers(team.Tid); - setTeamMembers(ms || []); - - const names = {}; - for (const m of (ms || [])) { - const user = await getUser(m.Uid); - if (user) names[m.Uid] = user.username; - } - setMemberNames(names); - - const caches = await getCaches(session.currentGid, null); - const counts = {}; - for (const m of (ms || [])) { - counts[m.Uid] = (caches || []).filter((c) => c.ClaimedByUid === m.Uid).length; - } - setMemberCacheCounts(counts); - setDetailLoading(false); - }; - - const handleBackToTeams = () => { - setSelectedTeam(null); - setTeamMembers([]); - setMemberNames({}); - setMemberCacheCounts({}); - }; - const handleRemovePlayer = async (membershipId) => { await removeMember(membershipId); await loadData(); }; - const handleRemoveTeamMember = async (member) => { - // Find this user's subgroup membership to remove them from the game - const allMembers = await getGroupMembers(session.currentGid); - const membership = (allMembers || []).find((m) => m.Uid === member.Uid && !m.IsAcceptedAdmin); - if (membership) { - await removeMember(membership.id); - await handleSelectTeam(selectedTeam); - } - }; - // View ----------------------- if (loading) { @@ -115,101 +81,24 @@ const PlayersScreen = () => { ); } - // Teams enabled — show team detail view - if (groupInfo?.TeamsEnabled && selectedTeam) { - return ( - - - {selectedTeam.TeamName || 'Unnamed Team'} - Code: {selectedTeam.JoinCode} - - - {detailLoading ? ( - - - - ) : ( - - Team Members - {teamMembers.map((m) => ( - - - - {memberNames[m.Uid] || `Player ${m.Uid}`} - - - {memberCacheCounts[m.Uid] || 0} cache(s) claimed - - - -