diff --git a/App.js b/App.js index 15a3bbc..222b6a0 100644 --- a/App.js +++ b/App.js @@ -1,90 +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 fa76814..3f8c7e5 100644 --- a/app.json +++ b/app.json @@ -29,8 +29,6 @@ } }, "permissions": [ - "ACCESS_FINE_LOCATION", - "ACCESS_COARSE_LOCATION", "ACCESS_FINE_LOCATION", "ACCESS_COARSE_LOCATION" ], @@ -38,6 +36,11 @@ }, "web": { "favicon": "./assets/favicon.png" + }, + "extra": { + "eas": { + "projectId": "c65a2849-f05d-48ef-9e36-950caa214a7d" + } } } } diff --git a/eas.json b/eas.json new file mode 100644 index 0000000..7754482 --- /dev/null +++ b/eas.json @@ -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": {} + } +} 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 9f3df41..ec7d464 100644 --- a/server/server.js +++ b/server/server.js @@ -4,7 +4,7 @@ const path = require('path'); // --- Configuration --- -const PORT = process.env.PORT || 80; +const PORT = process.env.PORT || 3000; const DATA_DIR = path.join(__dirname, 'data'); // --- Data Helpers --- @@ -209,6 +209,7 @@ addRoute('POST', '/groups', async (req, res) => { CreatedByUid: payload.CreatedByUid, MaxMemberSubgroups: payload.MaxMemberSubgroups || 1, TeamsEnabled: payload.TeamsEnabled !== undefined ? payload.TeamsEnabled : true, + CacheTriggerMeters: payload.CacheTriggerMeters || 20, ApprovedAdmins: [payload.CreatedByUid], 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 (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; @@ -395,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); }); @@ -471,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); @@ -505,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 fa6cf6e..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 944106d..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, 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/CacheCardItem.js b/src/components/gameplay/CacheCardItem.js index 7838843..f5c9341 100644 --- a/src/components/gameplay/CacheCardItem.js +++ b/src/components/gameplay/CacheCardItem.js @@ -8,6 +8,7 @@ const CacheCardItem = ({cache, isAdmin, onEdit, onDelete, onSelect}) => { const isClaimed = Boolean(cache.ClaimedByUid); const statusText = isClaimed ? 'Claimed' : 'Available'; const statusColor = isClaimed ? '#9ca3af' : '#16a34a'; + const displayName = cache.name || cache.clue || 'Unnamed Cache'; // State ---------------------- // Handlers ------------------- @@ -16,7 +17,12 @@ const CacheCardItem = ({cache, isAdmin, onEdit, onDelete, onSelect}) => { return ( - {cache.clue} + + {displayName} + {cache.name ? ( + {cache.clue} + ) : null} + {statusText} @@ -55,7 +61,9 @@ const styles = StyleSheet.create({ alignItems: 'center', 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'}, actions: {flexDirection: 'row', gap: 8}, editBtn: { diff --git a/src/components/gameplay/ClaimTimerView.js b/src/components/gameplay/ClaimTimerView.js index 0b46031..c38315a 100644 --- a/src/components/gameplay/ClaimTimerView.js +++ b/src/components/gameplay/ClaimTimerView.js @@ -1,11 +1,12 @@ import React, {useEffect, useState} from 'react'; import {StyleSheet, Text, View} from 'react-native'; +import {CACHE_CLAIM_TIMER} from '../../utils/geoMath'; const ClaimTimerView = ({ cache, onClaimSuccess, isClaiming }) => { // Initialisation ------------- // State ---------------------- - const [timeLeft, setTimeLeft] = useState(5); + const [timeLeft, setTimeLeft] = useState(CACHE_CLAIM_TIMER); // Handlers ------------------- @@ -16,7 +17,7 @@ const ClaimTimerView = ({ cache, onClaimSuccess, isClaiming }) => { } else if (isClaiming && timeLeft === 0 && cache) { onClaimSuccess(cache.id); } else { - setTimeLeft(5); + setTimeLeft(CACHE_CLAIM_TIMER); } return () => clearTimeout(timer); }, [isClaiming, timeLeft, cache, onClaimSuccess]); @@ -36,13 +37,12 @@ const ClaimTimerView = ({ cache, onClaimSuccess, isClaiming }) => { const styles = StyleSheet.create({ overlay: { - position: 'absolute', - bottom: 24, alignSelf: 'center', backgroundColor: 'rgba(0,0,0,0.75)', paddingHorizontal: 16, paddingVertical: 12, borderRadius: 10, + marginTop: 8, }, text: { color: '#ffffff', diff --git a/src/components/gameplay/PlayerMapView.js b/src/components/gameplay/PlayerMapView.js index b96bc88..a888b2e 100644 --- a/src/components/gameplay/PlayerMapView.js +++ b/src/components/gameplay/PlayerMapView.js @@ -1,44 +1,46 @@ -import React from 'react'; -import MapView, {Circle} from 'react-native-maps'; -import ClaimTimerView from './ClaimTimerView'; +import MapView, {Circle, Polygon} from 'react-native-maps'; +import {getFovCone} from '../../utils/geoMath'; -const PlayerMapView = ({ userLocation, visibleCache, isClaiming, onClaimSuccess }) => { +const PlayerMapView = ({userLocation, visibleCache, heading, claimDistance}) => { // Initialisation ------------- // State ---------------------- // Handlers ------------------- // View ----------------------- - if (!userLocation) return null; + if (!userLocation) return null; - return ( - <> - - {visibleCache ? ( - - ) : null} - + const coneCoords = (heading !== null && heading !== undefined) + ? getFovCone(userLocation, heading) + : null; - - - ); + 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 920d2d4..95957bd 100644 --- a/src/components/layout/Screen.js +++ b/src/components/layout/Screen.js @@ -13,19 +13,12 @@ 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", }; // State ---------------------- 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 new file mode 100644 index 0000000..bc08f26 --- /dev/null +++ b/src/components/screens/ExpandedMapScreen.js @@ -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 ( + + + + {/* Only admins see cache pin locations */} + {isAdmin && caches.map((cache) => ( + + + + + ))} + {/* Heading FOV cone */} + {coneCoords && ( + + )} + + + + ); +}; + +const styles = StyleSheet.create({ + container: {padding: 0}, + mapWrap: {flex: 1}, +}); + +export default ExpandedMapScreen; diff --git a/src/components/screens/GameLobbyScreen.js b/src/components/screens/GameLobbyScreen.js deleted file mode 100644 index eaf14d9..0000000 --- a/src/components/screens/GameLobbyScreen.js +++ /dev/null @@ -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 ( - - 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 b5443b6..e389e6b 100644 --- a/src/components/screens/GameSettingsScreen.js +++ b/src/components/screens/GameSettingsScreen.js @@ -1,9 +1,9 @@ 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 {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,8 +17,9 @@ 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(''); const [memberJoinCode, setMemberJoinCode] = useState(''); @@ -33,8 +34,12 @@ 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 || ''); } const sgs = await getSubgroups(session.currentGid); @@ -48,11 +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); }; @@ -76,65 +84,82 @@ const GameSettingsScreen = () => { return ( - Game Configuration + + Game Configuration - Game Name - - - - Teams Enabled - Game Name + - - {isBusiness && ( - <> - Max Member Subgroups - + Teams Enabled + - - )} + - - Admin Join Code - {adminJoinCode || '—'} - + {isBusiness && ( + <> + Max Member Subgroups + + + )} - - Player Join Code - {memberJoinCode || '—'} - - - -