diff --git a/.gitignore b/.gitignore
index c4fcf5f..9069e79 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,9 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
+# Environment variables (contains secrets)
+.env
+.env.local
+
# dependencies
node_modules/
diff --git a/App.js b/App.js
index 9efbd46..fb2944e 100644
--- a/App.js
+++ b/App.js
@@ -1,8 +1,8 @@
-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 { 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 TeamScreen from "./src/components/screens/TeamScreen";
@@ -12,7 +12,12 @@ import GameSettingsScreen from "./src/components/screens/GameSettingsScreen";
import DepartmentSettingsScreen from "./src/components/screens/DepartmentSettingsScreen";
import PlayersScreen from "./src/components/screens/PlayersScreen";
import ExpandedMapScreen from "./src/components/screens/ExpandedMapScreen";
-import {loadSession} from "./src/hooks/SessionStore";
+import GameSelectionScreen from "./src/components/screens/GameSelectionScreen";
+import GlobalEventsScreen from "./src/components/screens/global/GlobalEventsScreen";
+import GlobalMapScreen from "./src/components/screens/global/GlobalMapScreen";
+import GlobalCacheViewScreen from "./src/components/screens/global/GlobalCacheViewScreen";
+import GlobalLeaderboardScreen from "./src/components/screens/global/GlobalLeaderboardScreen";
+import { loadSession } from "./src/hooks/SessionStore";
const RootStack = createNativeStackNavigator();
const AuthStackNav = createNativeStackNavigator();
@@ -20,28 +25,87 @@ const GameStackNav = createNativeStackNavigator();
const AdminStackNav = createNativeStackNavigator();
const screenOptions = {
- headerShown: false,
- contentStyle: {backgroundColor: '#ffffff'},
+ headerShown: false,
+ contentStyle: { backgroundColor: "#ffffff" },
};
const AuthStack = () => (
-
-
-
-
+
+
+
+
);
const GameStack = () => (
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
);
const AdminStack = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -54,38 +118,48 @@ const AdminStack = () => (
);
export const App = () => {
- // Load persisted session from AsyncStorage before choosing initial route
- const [loading, setLoading] = useState(true);
- const [initialRoute, setInitialRoute] = useState('Auth');
+ // 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 (
-
-
-
- );
- }
+ useEffect(() => {
+ loadSession().then((session) => {
+ if (session.currentUid) {
+ setInitialRoute(session.isAcceptedAdmin ? "Admin" : "Game");
+ }
+ setLoading(false);
+ });
+ }, []);
+ if (loading) {
return (
-
-
-
-
-
-
-
-
-
+
+
+
);
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
};
export default App;
diff --git a/package-lock.json b/package-lock.json
index 952e587..5101718 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -77,6 +77,7 @@
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1364,6 +1365,7 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
+ "peer": true,
"engines": {
"node": ">=6.9.0"
}
@@ -2627,6 +2629,7 @@
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.2.2.tgz",
"integrity": "sha512-kem1Ko2BcbAjmbQIv66dNmr6EtfDut3QU0qjsVhMnLLhktwyXb6FzZYp8gTrUb6AvkAbaJoi+BF5Pl55pAUa5w==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@react-navigation/core": "^7.17.2",
"escape-string-regexp": "^4.0.0",
@@ -2867,6 +2870,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz",
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
"dev": true,
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.57.0",
"@typescript-eslint/types": "8.57.0",
@@ -3415,6 +3419,7 @@
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4043,6 +4048,7 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4927,6 +4933,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5114,6 +5121,7 @@
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
+ "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -5337,6 +5345,7 @@
"version": "54.0.33",
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.33.tgz",
"integrity": "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.20.0",
"@expo/cli": "54.0.23",
@@ -5424,6 +5433,7 @@
"version": "14.0.11",
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz",
"integrity": "sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg==",
+ "peer": true,
"dependencies": {
"fontfaceobserver": "^2.1.0"
},
@@ -8941,6 +8951,7 @@
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -8958,6 +8969,7 @@
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
+ "peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -8986,6 +8998,7 @@
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz",
"integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==",
+ "peer": true,
"dependencies": {
"@jest/create-cache-key-function": "^29.7.0",
"@react-native/assets-registry": "0.81.5",
@@ -9113,6 +9126,7 @@
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
"integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==",
+ "peer": true,
"peerDependencies": {
"react": "*",
"react-native": "*"
@@ -9122,6 +9136,7 @@
"version": "4.16.0",
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz",
"integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==",
+ "peer": true,
"dependencies": {
"react-freeze": "^1.0.0",
"react-native-is-edge-to-edge": "^1.2.1",
@@ -9136,6 +9151,7 @@
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
"integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.18.6",
"@react-native/normalize-colors": "^0.74.1",
@@ -9165,6 +9181,7 @@
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz",
"integrity": "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==",
+ "peer": true,
"dependencies": {
"@babel/plugin-transform-arrow-functions": "^7.0.0-0",
"@babel/plugin-transform-class-properties": "^7.0.0-0",
@@ -9268,6 +9285,7 @@
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -10385,6 +10403,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "peer": true,
"engines": {
"node": ">=12"
},
diff --git a/src/components/API/API.js b/src/components/API/API.js
index 1275d4a..177eb7e 100644
--- a/src/components/API/API.js
+++ b/src/components/API/API.js
@@ -1,34 +1,33 @@
-//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!!!
+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');
-API.post = (endpoint, data) => callFetch(endpoint, 'POST', data);
-API.put = (endpoint, data) => callFetch(endpoint, 'PUT', data);
-API.delete = (endpoint) => callFetch(endpoint, 'DELETE');
+API.get = (endpoint) => callFetch(endpoint, "GET");
+API.post = (endpoint, data) => callFetch(endpoint, "POST", data);
+API.put = (endpoint, data) => callFetch(endpoint, "PUT", data);
+API.delete = (endpoint) => callFetch(endpoint, "DELETE");
export default API;
const callFetch = async (endpoint, method, dataObj = null) => {
- // Build request object
- let requestObj = {method: method}; // GET, POST, PUT or DELETE
- if (dataObj)
- requestObj = {
- ...requestObj,
- headers: {'Content-type': 'application/json'},
- body: JSON.stringify(dataObj),
- };
+ // Build request object
+ let requestObj = { method: method }; // GET, POST, PUT or DELETE
+ if (dataObj)
+ requestObj = {
+ ...requestObj,
+ headers: { "Content-type": "application/json" },
+ body: JSON.stringify(dataObj),
+ };
- // Call the fetch and process the return
- try {
- let result = null;
- const response = await fetch(endpoint, requestObj);
- if (response.status !== 204) result = await response.json();
- return response.status >= 200 && response.status < 300
- ? {isSuccess: true, result}
- : {isSuccess: false, message: `${result.message}`};
- } catch (error) {
- return {isSuccess: false, message: error.message};
- }
+ // Call the fetch and process the return
+ try {
+ let result = null;
+ const response = await fetch(endpoint, requestObj);
+ if (response.status !== 204) result = await response.json();
+ return response.status >= 200 && response.status < 300
+ ? { isSuccess: true, result }
+ : { isSuccess: false, message: `${result.message}` };
+ } catch (error) {
+ return { isSuccess: false, message: error.message };
+ }
};
-
diff --git a/src/components/layout/BottomNavbar.js b/src/components/layout/BottomNavbar.js
index c736d72..3a7ab1a 100644
--- a/src/components/layout/BottomNavbar.js
+++ b/src/components/layout/BottomNavbar.js
@@ -1,14 +1,80 @@
-import {Alert, Pressable, StyleSheet, Text, View} from 'react-native';
-import {useSafeAreaInsets} from 'react-native-safe-area-context';
-import {clearGameSession, getSession} from '../../hooks/SessionStore';
-import API, {API_BASE_URL} from '../API/API';
+import { Alert, Pressable, StyleSheet, Text, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { clearGameSession, getSession } from "../../hooks/SessionStore";
+import API, { API_BASE_URL } from "../API/API";
+const BottomNavbar = ({ navigation, routeName }) => {
+ // Initialisation ------------
const BottomNavbar = ({navigation, routeName, routeParams}) => {
// Initialisation ------------
- const insets = useSafeAreaInsets();
- const session = getSession();
+ const insets = useSafeAreaInsets();
+ const session = getSession();
+ const adminTabs = [
+ {
+ 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"),
+ },
+ {
+ label: "Games",
+ route: "GameSelectionScreen",
+ onPress: () => navigation.navigate("GameSelectionScreen"),
+ },
+ {
+ label: "Global",
+ route: "GlobalEventsScreen",
+ onPress: () => navigation.navigate("GlobalEventsScreen"),
+ },
+ ];
+
+ const playerTabs = [
+ {
+ 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: "Games",
+ route: "GameSelectionScreen",
+ onPress: () => navigation.navigate("GameSelectionScreen"),
+ },
+ {
+ label: "Global",
+ route: "GlobalEventsScreen",
+ onPress: () => navigation.navigate("GlobalEventsScreen"),
+ },
+
+ { label: "Leave", route: "__leave__", onPress: () => handleLeaveGame() },
+ ];
+
+ const tabs = session.isAcceptedAdmin ? adminTabs : playerTabs;
const selectedDepartment = routeName === 'DepartmentSettingsScreen' && routeParams?.SGid
? {SGid: routeParams.SGid, SubGroupName: routeParams.SubGroupName || routeParams.selectedDepartmentName || 'Department'}
: null;
@@ -52,9 +118,30 @@ const BottomNavbar = ({navigation, routeName, routeParams}) => {
];
const visibleTabs = (session.isPendingAdmin && !session.isAcceptedAdmin) ? pendingTabs : tabs;
-// State ----------------------
-// Handlers -------------------
+ // 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" }] });
+ },
+ },
+ ]);
+ };
const handleLeaveGame = () => {
Alert.alert('Leave Game', 'Are you sure you want to leave this game?', [
{text: 'Cancel', style: 'cancel'},
@@ -75,8 +162,76 @@ const BottomNavbar = ({navigation, routeName, routeParams}) => {
]);
};
-// View -----------------------
+ // View -----------------------
+ return (
+
+ {tabs.map((tab) => {
+ const isActive = routeName === tab.route;
+ const isLeave = tab.route === "__leave__";
+ return (
+
+
+ {tab.label}
+
+
+ );
+ })}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ minHeight: 64,
+ borderTopWidth: 1,
+ borderTopColor: "#555555",
+ backgroundColor: "#4a4a4a",
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-around",
+ paddingHorizontal: 8,
+ },
+ tab: {
+ flex: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ paddingVertical: 12,
+ borderRadius: 8,
+ marginHorizontal: 4,
+ },
+ tabActive: {
+ backgroundColor: "#5c5c5c",
+ },
+ tabLeave: {
+ backgroundColor: "rgba(220,38,38,0.15)",
+ },
+ tabText: {
+ color: "#d1d5db",
+ fontSize: 12,
+ fontWeight: "600",
+ textAlign: "center",
+ },
+ tabTextActive: {
+ color: "#ffffff",
+ },
+ tabTextLeave: {
+ color: "#fca5a5",
+ },
return (
{visibleTabs.map((tab) => {
diff --git a/src/components/layout/Screen.js b/src/components/layout/Screen.js
index 988a25a..8311165 100644
--- a/src/components/layout/Screen.js
+++ b/src/components/layout/Screen.js
@@ -1,7 +1,7 @@
-import {StatusBar} from "expo-status-bar";
-import {StyleSheet, View} from "react-native";
-import {useNavigation, useRoute} from "@react-navigation/native";
-import {useSafeAreaInsets} from "react-native-safe-area-context";
+import { StatusBar } from "expo-status-bar";
+import { StyleSheet, View } from "react-native";
+import { useNavigation, useRoute } from "@react-navigation/native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
import TopNavbar from "./TopNavbar";
import BottomNavbar from "./BottomNavbar";
@@ -17,24 +17,47 @@ const Screen = ({ children, style, showBack = false }) => {
TeamScreen: "Current Team",
MapScreen: "Map",
GameSettingsScreen: "Game Settings",
+ GameSelectionScreen: "Games",
DepartmentSettingsScreen: "Department Settings",
PlayersScreen: "Players",
ExpandedMapScreen: "Map View",
+ GlobalEventsScreen: "Global Events",
+ GlobalMapScreen: "Global Map",
+ GlobalCacheViewScreen: "Cache Details",
+ GlobalLeaderboardScreen: "Global Leaderboard",
};
// State ----------------------
const routeName = route.name;
- const isAuthScreen = routeName === "LoginScreen" || routeName === "RegisterScreen";
+ const isAuthScreen =
+ routeName === "LoginScreen" || routeName === "RegisterScreen";
const pageTitle = titleMap[routeName] || "Treasure Hunt";
// Handlers -------------------
// View -----------------------
return (
-
- {!isAuthScreen && }
+
+ {!isAuthScreen && (
+
+ )}
{children}
+ {!isAuthScreen && (
+
+ )}
{!isAuthScreen && }
@@ -55,4 +78,3 @@ const styles = StyleSheet.create({
});
export default Screen;
-
diff --git a/src/components/screens/GameSelectionScreen.js b/src/components/screens/GameSelectionScreen.js
new file mode 100644
index 0000000..969d70a
--- /dev/null
+++ b/src/components/screens/GameSelectionScreen.js
@@ -0,0 +1,184 @@
+import { ScrollView, StyleSheet, Text, View } from "react-native";
+import Screen from "../layout/Screen";
+import Card from "../UI/Card";
+import { Button, ButtonTray } from "../UI/Button";
+import { getSession } from "../../hooks/SessionStore";
+
+const GameSelectionScreen = ({ navigation }) => {
+ // Initialisations ---------------------
+
+ const session = getSession();
+ const isAdmin = Boolean(session.isAcceptedAdmin);
+
+ const privateGames = [
+ {
+ id: "active-game",
+ name: "Private Game",
+ players: "4 players",
+ endsIn: "Ends in 2h 15m",
+ },
+ ];
+
+ // State -------------------------------
+ // Handlers ----------------------------
+
+ const handleCreateGame = () => {
+ if (isAdmin) {
+ navigation.navigate("GameSettingsScreen");
+ return;
+ }
+ navigation.navigate("TeamScreen");
+ };
+
+ const handleOpenGame = () => {
+ navigation.navigate("TeamScreen");
+ };
+
+ const handleExplore = () => {
+ navigation.navigate("GlobalEventsScreen");
+ };
+
+ // View --------------------------------
+
+ return (
+
+
+
+ Your Games
+
+
+
+
+
+
+ Private Games
+ {privateGames.map((game) => (
+
+ {game.name}
+ {game.players}
+ {game.endsIn}
+
+
+
+
+ ))}
+
+
+
+ Global Mode
+
+ Explore all visible caches and live activity from the map.
+
+
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ screenContent: {
+ backgroundColor: "#f3f4f6",
+ paddingHorizontal: 0,
+ paddingVertical: 0,
+ },
+ scrollContent: {
+ padding: 15,
+ paddingBottom: 28,
+ },
+ sectionTop: {
+ marginBottom: 16,
+ gap: 10,
+ },
+ sectionTitle: {
+ fontSize: 22,
+ fontWeight: "700",
+ color: "#111827",
+ },
+ sectionBlock: {
+ marginTop: 8,
+ marginBottom: 10,
+ },
+ blockTitle: {
+ fontSize: 15,
+ fontWeight: "700",
+ color: "#374151",
+ marginBottom: 8,
+ },
+ gameCard: {
+ marginBottom: 10,
+ paddingHorizontal: 16,
+ paddingVertical: 14,
+ borderColor: "#d1d5db",
+ borderRadius: 10,
+ },
+ gameName: {
+ fontSize: 17,
+ fontWeight: "700",
+ color: "#111827",
+ marginBottom: 4,
+ },
+ gameMeta: {
+ fontSize: 14,
+ color: "#4b5563",
+ marginBottom: 2,
+ },
+ cardActions: {
+ marginTop: 12,
+ maxWidth: 160,
+ },
+ supportText: {
+ fontSize: 14,
+ color: "#4b5563",
+ marginBottom: 10,
+ lineHeight: 20,
+ },
+ primaryButton: {
+ backgroundColor: "#16a34a",
+ borderColor: "#16a34a",
+ },
+ primaryButtonLabel: {
+ color: "#ffffff",
+ fontWeight: "600",
+ },
+ secondaryButton: {
+ backgroundColor: "#2563eb",
+ borderColor: "#2563eb",
+ flex: 0,
+ minHeight: 38,
+ paddingHorizontal: 18,
+ },
+ secondaryButtonLabel: {
+ color: "#ffffff",
+ fontWeight: "600",
+ fontSize: 14,
+ },
+ neutralButton: {
+ backgroundColor: "#374151",
+ borderColor: "#374151",
+ maxWidth: 180,
+ },
+ neutralButtonLabel: {
+ color: "#ffffff",
+ fontWeight: "600",
+ },
+});
+
+export default GameSelectionScreen;
diff --git a/src/components/screens/global/GlobalCacheViewScreen.js b/src/components/screens/global/GlobalCacheViewScreen.js
new file mode 100644
index 0000000..1e76668
--- /dev/null
+++ b/src/components/screens/global/GlobalCacheViewScreen.js
@@ -0,0 +1,177 @@
+import { useEffect, useState } from "react";
+import { ActivityIndicator, Alert, StyleSheet, Text, View } from "react-native";
+import Screen from "../../layout/Screen";
+import { Button, ButtonTray } from "../../UI/Button";
+import Card from "../../UI/Card";
+import useGlobalHook from "../../../hooks/useGlobalHook";
+import { getSession } from "../../../hooks/SessionStore";
+
+const GlobalCacheViewScreen = ({ navigation, route }) => {
+ // Initialisations ---------------------
+
+ const { cache, isFound: initIsFound, onFindLogged } = route.params;
+ const { logFind } = useGlobalHook();
+ const session = getSession();
+
+ // State -------------------------------
+
+ const [isFound, setIsFound] = useState(initIsFound);
+ const [isLogging, setIsLogging] = useState(false);
+
+ // Handlers ----------------------------
+
+ const handleLogFind = async () => {
+ if (!session.currentGlobalPlayerId) {
+ Alert.alert(
+ "Not joined",
+ "You must join the event before logging a find.",
+ );
+ return;
+ }
+
+ Alert.alert(
+ "Log Discovery",
+ `Record that you found "${cache.CacheName}" and earn ${cache.CachePoints ?? 0} points?`,
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Log Find",
+ onPress: async () => {
+ setIsLogging(true);
+ const result = await logFind({
+ FindPlayerID: session.currentGlobalPlayerId,
+ FindCacheID: cache.CacheID,
+ FindDatetime: new Date().toISOString(),
+ });
+ setIsLogging(false);
+ if (result) {
+ setIsFound(true);
+ if (onFindLogged) onFindLogged();
+ Alert.alert(
+ "Discovery logged!",
+ `+${cache.CachePoints ?? 0} points earned.`,
+ );
+ } else {
+ Alert.alert("Error", "Could not log find. Please try again.");
+ }
+ },
+ },
+ ],
+ );
+ };
+
+ // View --------------------------------
+
+ return (
+
+
+
+ {cache.CacheName}
+
+
+
+ {isFound ? "Already Found" : "Not Yet Found"}
+
+
+
+
+ Points
+ {cache.CachePoints ?? 0}
+
+
+ {cache.CacheDescription ? (
+
+ Description
+ {cache.CacheDescription}
+
+ ) : null}
+
+ {cache.CacheClue ? (
+
+ Clue
+ {cache.CacheClue}
+
+ ) : null}
+
+
+ {isLogging ? (
+
+ ) : (
+
+ {!isFound && (
+
+ )}
+
+ )}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ gap: 16,
+ },
+ card: {
+ gap: 12,
+ },
+ title: {
+ fontSize: 22,
+ fontWeight: "700",
+ color: "#111827",
+ },
+ statusBadge: {
+ alignSelf: "flex-start",
+ paddingHorizontal: 10,
+ paddingVertical: 4,
+ borderRadius: 12,
+ },
+ badgeFound: {
+ backgroundColor: "#d1fae5",
+ },
+ badgeOpen: {
+ backgroundColor: "#fef3c7",
+ },
+ statusText: {
+ fontSize: 13,
+ fontWeight: "600",
+ color: "#374151",
+ },
+ detailRow: {
+ gap: 2,
+ },
+ label: {
+ fontSize: 12,
+ fontWeight: "600",
+ color: "#6b7280",
+ textTransform: "uppercase",
+ letterSpacing: 0.5,
+ },
+ value: {
+ fontSize: 15,
+ color: "#111827",
+ },
+ logButton: {
+ backgroundColor: "#16a34a",
+ borderColor: "#16a34a",
+ flex: 2,
+ },
+ logButtonLabel: {
+ color: "#fff",
+ fontWeight: "700",
+ },
+});
+
+export default GlobalCacheViewScreen;
diff --git a/src/components/screens/global/GlobalEventsScreen.js b/src/components/screens/global/GlobalEventsScreen.js
new file mode 100644
index 0000000..aabacf4
--- /dev/null
+++ b/src/components/screens/global/GlobalEventsScreen.js
@@ -0,0 +1,140 @@
+import { useCallback, useEffect, useState } from "react";
+import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
+import Screen from "../../layout/Screen";
+import { Button, ButtonTray } from "../../UI/Button";
+import EventList from "../../../entity/event/EventList";
+import useGlobalHook from "../../../hooks/useGlobalHook";
+import { getSession, setGlobalSession } from "../../../hooks/SessionStore";
+
+const GlobalEventsScreen = ({ navigation }) => {
+ // Initialisations ---------------------
+
+ const { getPublicEvents, getPlayersByEvent, joinEvent } = useGlobalHook();
+ const session = getSession();
+
+ // State -------------------------------
+
+ const [events, setEvents] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [joining, setJoining] = useState(null);
+
+ // Handlers ----------------------------
+
+ const loadEvents = useCallback(async () => {
+ setIsLoading(true);
+ setError("");
+ const data = await getPublicEvents();
+ setEvents(data);
+ setIsLoading(false);
+ }, []);
+
+ useEffect(() => {
+ loadEvents();
+ }, [loadEvents]);
+
+ const handleSelect = async (event) => {
+ setError("");
+ setJoining(event.EventID);
+
+ // Check if already a player
+ const players = await getPlayersByEvent(event.EventID);
+ const existing = (players || []).find(
+ (p) => p.PlayerUserID === session.currentUid,
+ );
+
+ if (existing) {
+ setGlobalSession(event.EventID, existing.PlayerID);
+ navigation.navigate("GlobalMapScreen", { eventId: event.EventID });
+ } else {
+ // Join the event
+ const player = await joinEvent({
+ PlayerUserID: session.currentUid,
+ PlayerEventID: event.EventID,
+ });
+ if (player) {
+ setGlobalSession(event.EventID, player.PlayerID);
+ navigation.navigate("GlobalMapScreen", { eventId: event.EventID });
+ } else {
+ setError("Failed to join event. Please try again.");
+ }
+ }
+
+ setJoining(null);
+ };
+
+ // View --------------------------------
+
+ return (
+
+
+
+
+
+
+ {error ? {error} : null}
+
+ {isLoading ? (
+
+
+ Loading public events…
+
+ ) : events.length === 0 ? (
+ No public events available.
+ ) : (
+ <>
+
+ Tap an event to join and explore its caches.
+
+
+ >
+ )}
+
+ {joining !== null && (
+
+
+
+ )}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ gap: 12,
+ },
+ center: {
+ flex: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 10,
+ },
+ loadingText: {
+ color: "#6b7280",
+ fontSize: 14,
+ },
+ empty: {
+ textAlign: "center",
+ color: "#9ca3af",
+ marginTop: 30,
+ fontSize: 15,
+ },
+ hint: {
+ fontSize: 13,
+ color: "#6b7280",
+ },
+ error: {
+ color: "#dc2626",
+ fontSize: 14,
+ },
+ overlay: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: "rgba(0,0,0,0.35)",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+});
+
+export default GlobalEventsScreen;
diff --git a/src/components/screens/global/GlobalLeaderboardScreen.js b/src/components/screens/global/GlobalLeaderboardScreen.js
new file mode 100644
index 0000000..cb4867b
--- /dev/null
+++ b/src/components/screens/global/GlobalLeaderboardScreen.js
@@ -0,0 +1,249 @@
+import { useCallback, useEffect, useState } from "react";
+import {
+ ActivityIndicator,
+ ScrollView,
+ StyleSheet,
+ Text,
+ View,
+} from "react-native";
+import Screen from "../../layout/Screen";
+import { Button, ButtonTray } from "../../UI/Button";
+import Card from "../../UI/Card";
+import FindList from "../../../entity/find/FindList";
+import useGlobalHook from "../../../hooks/useGlobalHook";
+import { getSession } from "../../../hooks/SessionStore";
+
+const GlobalLeaderboardScreen = ({ navigation, route }) => {
+ // Initialisations ---------------------
+
+ const { eventId } = route.params;
+ const { getFindsByEvent, getCachesByEvent, getPlayersByEvent } =
+ useGlobalHook();
+ const session = getSession();
+
+ // State -------------------------------
+
+ const [isLoading, setIsLoading] = useState(true);
+ const [rankings, setRankings] = useState([]);
+ const [recentFinds, setRecentFinds] = useState([]);
+ const [activeTab, setActiveTab] = useState("rankings");
+
+ // Handlers ----------------------------
+
+ const loadData = useCallback(async () => {
+ setIsLoading(true);
+
+ const [finds, caches, players] = await Promise.all([
+ getFindsByEvent(eventId),
+ getCachesByEvent(eventId),
+ getPlayersByEvent(eventId),
+ ]);
+
+ // Build cache points lookup
+ const cacheMap = {};
+ (caches || []).forEach((c) => {
+ cacheMap[c.CacheID] = c;
+ });
+
+ // Augment finds with cache info for FindList
+ const augmented = (finds || []).map((f) => ({
+ ...f,
+ FindCache: f.FindCache || cacheMap[f.FindCacheID] || null,
+ }));
+
+ // Sort recent first
+ const sorted = [...augmented].sort(
+ (a, b) => new Date(b.FindDatetime) - new Date(a.FindDatetime),
+ );
+ setRecentFinds(sorted.slice(0, 50));
+
+ // Aggregate points per player
+ const pointsMap = {};
+ augmented.forEach((f) => {
+ const pid = f.FindPlayerID;
+ const pts = f.FindCache?.CachePoints ?? 0;
+ if (!pointsMap[pid])
+ pointsMap[pid] = { playerId: pid, points: 0, finds: 0, name: null };
+ pointsMap[pid].points += pts;
+ pointsMap[pid].finds += 1;
+ });
+
+ // Attach usernames from players list
+ (players || []).forEach((p) => {
+ if (pointsMap[p.PlayerID]) {
+ pointsMap[p.PlayerID].name =
+ p.PlayerUser?.UserUsername || `Player #${p.PlayerUserID}`;
+ }
+ });
+
+ const ranked = Object.values(pointsMap).sort((a, b) => b.points - a.points);
+ setRankings(ranked);
+ setIsLoading(false);
+ }, [eventId]);
+
+ useEffect(() => {
+ loadData();
+ }, [loadData]);
+
+ // View --------------------------------
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ setActiveTab("rankings")}
+ />
+ setActiveTab("recentFinds")}
+ />
+
+
+ {activeTab === "rankings" ? (
+
+ {rankings.length === 0 ? (
+ No finds recorded yet.
+ ) : (
+ rankings.map((entry, index) => {
+ const isCurrent =
+ entry.playerId === session.currentGlobalPlayerId;
+ return (
+
+
+ #{index + 1}
+
+ {entry.name || `Player #${entry.playerId}`}
+ {isCurrent ? " (you)" : ""}
+
+
+
+ {entry.points} pts
+
+
+ {entry.finds} finds
+
+
+
+
+ );
+ })
+ )}
+
+ ) : recentFinds.length === 0 ? (
+ No finds recorded yet.
+ ) : (
+
+ )}
+
+
+
+
+
+
+ );
+};
+
+const TabButton = ({ label, active, onPress }) => (
+
+ {label}
+
+);
+
+const tabStyles = StyleSheet.create({
+ tab: {
+ flex: 1,
+ textAlign: "center",
+ paddingVertical: 8,
+ fontSize: 14,
+ fontWeight: "600",
+ color: "#6b7280",
+ borderBottomWidth: 2,
+ borderBottomColor: "transparent",
+ },
+ activeTab: {
+ color: "#111827",
+ borderBottomColor: "#111827",
+ },
+});
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ gap: 10,
+ },
+ center: {
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ tabs: {
+ flexDirection: "row",
+ borderBottomWidth: 1,
+ borderBottomColor: "#e5e7eb",
+ },
+ rankList: {
+ gap: 6,
+ paddingBottom: 8,
+ },
+ rankCard: {
+ marginBottom: 0,
+ },
+ myCard: {
+ borderColor: "#2563eb",
+ borderWidth: 2,
+ },
+ rankRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 10,
+ },
+ rankPos: {
+ fontSize: 16,
+ fontWeight: "700",
+ color: "#6b7280",
+ width: 32,
+ },
+ rankName: {
+ flex: 1,
+ fontSize: 15,
+ fontWeight: "600",
+ color: "#111827",
+ },
+ rankRight: {
+ alignItems: "flex-end",
+ },
+ rankPoints: {
+ fontSize: 16,
+ fontWeight: "700",
+ color: "#16a34a",
+ },
+ rankFinds: {
+ fontSize: 12,
+ color: "#9ca3af",
+ },
+ empty: {
+ textAlign: "center",
+ color: "#9ca3af",
+ marginTop: 30,
+ fontSize: 15,
+ },
+});
+
+export default GlobalLeaderboardScreen;
diff --git a/src/components/screens/global/GlobalMapScreen.js b/src/components/screens/global/GlobalMapScreen.js
new file mode 100644
index 0000000..566300c
--- /dev/null
+++ b/src/components/screens/global/GlobalMapScreen.js
@@ -0,0 +1,230 @@
+import { useCallback, useEffect, useState } from "react";
+import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
+import MapView, { Marker } from "react-native-maps";
+import * as Location from "expo-location";
+import Screen from "../../layout/Screen";
+import { Button, ButtonTray } from "../../UI/Button";
+import CacheList from "../../../entity/cache/CacheList";
+import useGlobalHook from "../../../hooks/useGlobalHook";
+import { getSession } from "../../../hooks/SessionStore";
+
+const DEFAULT_REGION = {
+ latitude: 51.5074,
+ longitude: -0.1278,
+ latitudeDelta: 0.05,
+ longitudeDelta: 0.05,
+};
+
+const GlobalMapScreen = ({ navigation, route }) => {
+ // Initialisations ---------------------
+
+ const { eventId } = route.params;
+ const { getCachesByEvent, getFindsByPlayer } = useGlobalHook();
+ const session = getSession();
+
+ // State -------------------------------
+
+ const [caches, setCaches] = useState([]);
+ const [foundCacheIds, setFoundCacheIds] = useState([]);
+ const [userLocation, setUserLocation] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [activeTab, setActiveTab] = useState("map");
+ const [error, setError] = useState("");
+
+ // Handlers ----------------------------
+
+ const loadData = useCallback(async () => {
+ setIsLoading(true);
+ setError("");
+
+ const [cacheData, findsData] = await Promise.all([
+ getCachesByEvent(eventId),
+ session.currentGlobalPlayerId
+ ? getFindsByPlayer(session.currentGlobalPlayerId)
+ : Promise.resolve([]),
+ ]);
+
+ setCaches(cacheData);
+ setFoundCacheIds((findsData || []).map((f) => f.FindCacheID));
+ setIsLoading(false);
+ }, [eventId]);
+
+ useEffect(() => {
+ loadData();
+ }, [loadData]);
+
+ useEffect(() => {
+ let sub;
+ const start = async () => {
+ const { status } = await Location.requestForegroundPermissionsAsync();
+ if (status !== "granted") {
+ setError("Location permission denied");
+ return;
+ }
+
+ 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,
+ });
+ }
+
+ sub = await Location.watchPositionAsync(
+ {
+ accuracy: Location.Accuracy.Balanced,
+ distanceInterval: 5,
+ timeInterval: 3000,
+ },
+ (pos) =>
+ setUserLocation({
+ latitude: pos.coords.latitude,
+ longitude: pos.coords.longitude,
+ }),
+ );
+ };
+ start();
+ return () => {
+ if (sub) sub.remove();
+ };
+ }, []);
+
+ const mapRegion = userLocation
+ ? { ...userLocation, latitudeDelta: 0.01, longitudeDelta: 0.01 }
+ : DEFAULT_REGION;
+
+ const handleCacheSelect = (cache) => {
+ navigation.navigate("GlobalCacheViewScreen", {
+ cache,
+ isFound: foundCacheIds.includes(cache.CacheID),
+ onFindLogged: loadData,
+ });
+ };
+
+ const handleGotoLeaderboard = () => {
+ navigation.navigate("GlobalLeaderboardScreen", { eventId });
+ };
+
+ // View --------------------------------
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Tab switcher */}
+
+ setActiveTab("map")}
+ />
+ setActiveTab("list")}
+ />
+
+
+ {error ? {error} : null}
+
+ {activeTab === "map" ? (
+
+ {caches.map((cache) => {
+ const found = foundCacheIds.includes(cache.CacheID);
+ return (
+ handleCacheSelect(cache)}
+ />
+ );
+ })}
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ );
+};
+
+const TabButton = ({ label, active, onPress }) => (
+
+ {label}
+
+);
+
+const tabStyles = StyleSheet.create({
+ tab: {
+ flex: 1,
+ textAlign: "center",
+ paddingVertical: 8,
+ fontSize: 14,
+ fontWeight: "600",
+ color: "#6b7280",
+ borderBottomWidth: 2,
+ borderBottomColor: "transparent",
+ },
+ activeTab: {
+ color: "#111827",
+ borderBottomColor: "#111827",
+ },
+});
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ gap: 10,
+ },
+ center: {
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ tabs: {
+ flexDirection: "row",
+ borderBottomWidth: 1,
+ borderBottomColor: "#e5e7eb",
+ },
+ map: {
+ flex: 1,
+ },
+ error: {
+ color: "#dc2626",
+ fontSize: 14,
+ },
+});
+
+export default GlobalMapScreen;
diff --git a/src/entity/cache/CacheItem.js b/src/entity/cache/CacheItem.js
new file mode 100644
index 0000000..7c3bf10
--- /dev/null
+++ b/src/entity/cache/CacheItem.js
@@ -0,0 +1,89 @@
+import { Pressable, StyleSheet, Text, View } from "react-native";
+
+const CacheItem = ({ cache, isFound, onSelect }) => {
+ // Initialisations ---------------------
+ // State -------------------------------
+ // Handlers ----------------------------
+
+ const handleSelect = () => {
+ onSelect(cache);
+ };
+
+ // View --------------------------------
+
+ return (
+ [styles.item, pressed && styles.pressedItem]}
+ >
+
+
+ {cache.CacheName}
+
+
+ {isFound ? "Found" : "Open"}
+
+
+ {cache.CacheClue ? (
+
+ {cache.CacheClue}
+
+ ) : null}
+ Points: {cache.CachePoints ?? 0}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ item: {
+ paddingVertical: 14,
+ paddingHorizontal: 2,
+ borderTopWidth: 1,
+ borderColor: "lightgray",
+ gap: 4,
+ },
+ pressedItem: {
+ backgroundColor: "#f0f9ff",
+ },
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ },
+ name: {
+ fontSize: 16,
+ fontWeight: "600",
+ color: "#111827",
+ flex: 1,
+ marginRight: 8,
+ },
+ clue: {
+ fontSize: 13,
+ color: "#6b7280",
+ },
+ points: {
+ fontSize: 12,
+ fontWeight: "500",
+ color: "#2563eb",
+ },
+ badge: {
+ paddingHorizontal: 8,
+ paddingVertical: 2,
+ borderRadius: 10,
+ },
+ badgeFound: {
+ backgroundColor: "#d1fae5",
+ },
+ badgeOpen: {
+ backgroundColor: "#fef3c7",
+ },
+ badgeText: {
+ fontSize: 11,
+ fontWeight: "600",
+ color: "#374151",
+ },
+});
+
+export default CacheItem;
diff --git a/src/entity/cache/CacheList.js b/src/entity/cache/CacheList.js
new file mode 100644
index 0000000..caee2fe
--- /dev/null
+++ b/src/entity/cache/CacheList.js
@@ -0,0 +1,28 @@
+import { ScrollView, StyleSheet } from "react-native";
+import CacheItem from "./CacheItem";
+
+const CacheList = ({ caches, foundCacheIds, onSelect }) => {
+ // Initialisations ---------------------
+ // State -------------------------------
+ // Handlers ----------------------------
+ // View --------------------------------
+
+ return (
+
+ {caches.map((cache) => (
+
+ ))}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {},
+});
+
+export default CacheList;
diff --git a/src/entity/event/EventItem.js b/src/entity/event/EventItem.js
new file mode 100644
index 0000000..a0e196b
--- /dev/null
+++ b/src/entity/event/EventItem.js
@@ -0,0 +1,100 @@
+import { Pressable, StyleSheet, Text, View } from "react-native";
+
+const EventItem = ({ event, onSelect }) => {
+ // Initialisations ---------------------
+ // State -------------------------------
+ // Handlers ----------------------------
+
+ const handleSelect = () => {
+ onSelect(event);
+ };
+
+ const formatDate = (dt) => {
+ if (!dt) return "—";
+ return new Date(dt).toLocaleDateString();
+ };
+
+ // View --------------------------------
+
+ return (
+ [styles.item, pressed && styles.pressedItem]}
+ >
+
+
+ {event.EventName}
+
+
+
+ {event.EventIspublic ? "Public" : "Private"}
+
+
+
+ {event.EventDescription ? (
+
+ {event.EventDescription}
+
+ ) : null}
+
+ {formatDate(event.EventStart)} — {formatDate(event.EventFinish)}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ item: {
+ paddingVertical: 14,
+ paddingHorizontal: 2,
+ borderTopWidth: 1,
+ borderColor: "lightgray",
+ gap: 4,
+ },
+ pressedItem: {
+ backgroundColor: "#f0f9ff",
+ },
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ },
+ name: {
+ fontSize: 16,
+ fontWeight: "600",
+ color: "#111827",
+ flex: 1,
+ marginRight: 8,
+ },
+ description: {
+ fontSize: 13,
+ color: "#6b7280",
+ },
+ meta: {
+ fontSize: 12,
+ color: "#9ca3af",
+ },
+ badge: {
+ paddingHorizontal: 8,
+ paddingVertical: 2,
+ borderRadius: 10,
+ },
+ badgePublic: {
+ backgroundColor: "#dcfce7",
+ },
+ badgePrivate: {
+ backgroundColor: "#f3f4f6",
+ },
+ badgeText: {
+ fontSize: 11,
+ fontWeight: "600",
+ color: "#374151",
+ },
+});
+
+export default EventItem;
diff --git a/src/entity/event/EventList.js b/src/entity/event/EventList.js
new file mode 100644
index 0000000..bbab75d
--- /dev/null
+++ b/src/entity/event/EventList.js
@@ -0,0 +1,23 @@
+import { ScrollView, StyleSheet } from "react-native";
+import EventItem from "./EventItem";
+
+const EventList = ({ events, onSelect }) => {
+ // Initialisations ---------------------
+ // State -------------------------------
+ // Handlers ----------------------------
+ // View --------------------------------
+
+ return (
+
+ {events.map((event) => (
+
+ ))}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {},
+});
+
+export default EventList;
diff --git a/src/entity/find/FindItem.js b/src/entity/find/FindItem.js
new file mode 100644
index 0000000..5e1dccf
--- /dev/null
+++ b/src/entity/find/FindItem.js
@@ -0,0 +1,65 @@
+import { StyleSheet, Text, View } from "react-native";
+
+const FindItem = ({ find }) => {
+ // Initialisations ---------------------
+ // State -------------------------------
+ // Handlers ----------------------------
+
+ const formatDate = (dt) => {
+ if (!dt) return "—";
+ return new Date(dt).toLocaleString();
+ };
+
+ const cacheName = find.FindCache?.CacheName ?? `Cache #${find.FindCacheID}`;
+ const points = find.FindCache?.CachePoints ?? 0;
+ const username =
+ find.FindPlayer?.PlayerUser?.UserUsername ?? `Player #${find.FindPlayerID}`;
+
+ // View --------------------------------
+
+ return (
+
+
+
+ {cacheName}
+
+ +{points} pts
+
+
+ {username} · {formatDate(find.FindDatetime)}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ item: {
+ paddingVertical: 12,
+ borderTopWidth: 1,
+ borderColor: "lightgray",
+ gap: 3,
+ },
+ row: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ },
+ cacheName: {
+ fontSize: 15,
+ fontWeight: "600",
+ color: "#111827",
+ flex: 1,
+ marginRight: 8,
+ },
+ points: {
+ fontSize: 14,
+ fontWeight: "700",
+ color: "#16a34a",
+ },
+ meta: {
+ fontSize: 12,
+ color: "#9ca3af",
+ },
+});
+
+export default FindItem;
diff --git a/src/entity/find/FindList.js b/src/entity/find/FindList.js
new file mode 100644
index 0000000..463c236
--- /dev/null
+++ b/src/entity/find/FindList.js
@@ -0,0 +1,23 @@
+import { ScrollView, StyleSheet } from "react-native";
+import FindItem from "./FindItem";
+
+const FindList = ({ finds }) => {
+ // Initialisations ---------------------
+ // State -------------------------------
+ // Handlers ----------------------------
+ // View --------------------------------
+
+ return (
+
+ {finds.map((find) => (
+
+ ))}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {},
+});
+
+export default FindList;
diff --git a/src/hooks/SessionStore.js b/src/hooks/SessionStore.js
index a36b3a8..47ded78 100644
--- a/src/hooks/SessionStore.js
+++ b/src/hooks/SessionStore.js
@@ -1,8 +1,8 @@
// --- Session Store (with AsyncStorage persistence) ---
-import AsyncStorage from '@react-native-async-storage/async-storage';
+import AsyncStorage from "@react-native-async-storage/async-storage";
-const STORAGE_KEY = '@mad_session';
+const STORAGE_KEY = "@mad_session";
const session = {
currentUid: null,
@@ -13,6 +13,9 @@ const session = {
isAcceptedAdmin: false,
isPendingAdmin: false,
teamsEnabled: false,
+ // Global gameplay
+ currentGlobalEventId: null,
+ currentGlobalPlayerId: null,
selectedCacheId: null,
};
@@ -61,6 +64,18 @@ const setSelectedCache = (cacheId) => {
const getSession = () => ({ ...session });
+const setGlobalSession = (eventId, playerId) => {
+ session.currentGlobalEventId = eventId ?? null;
+ session.currentGlobalPlayerId = playerId ?? null;
+ persistSession();
+};
+
+const clearGlobalSession = () => {
+ session.currentGlobalEventId = null;
+ session.currentGlobalPlayerId = null;
+ persistSession();
+};
+
const clearSession = () => {
session.currentUid = null;
session.currentGid = null;
@@ -68,6 +83,8 @@ const clearSession = () => {
session.currentTid = null;
session.isBusiness = null;
session.isAcceptedAdmin = false;
+ session.currentGlobalEventId = null;
+ session.currentGlobalPlayerId = null;
session.isPendingAdmin = false;
AsyncStorage.removeItem(STORAGE_KEY).catch(() => {});
};
@@ -103,4 +120,16 @@ const loadSession = async () => {
return { ...session };
};
+export {
+ setSessionUser,
+ setSessionGroup,
+ setSessionTeam,
+ setSessionTeamsEnabled,
+ getSession,
+ clearSession,
+ clearGameSession,
+ loadSession,
+ setGlobalSession,
+ clearGlobalSession,
+};
export { setSessionUser, setSessionGroup, setSessionTeam, setSessionTeamsEnabled, setPendingAdmin, setSelectedCache, getSession, clearSession, clearGameSession, loadSession };
diff --git a/src/hooks/useGlobalHook.js b/src/hooks/useGlobalHook.js
new file mode 100644
index 0000000..734c824
--- /dev/null
+++ b/src/hooks/useGlobalHook.js
@@ -0,0 +1,103 @@
+import API from "../components/API/API";
+
+const GLOBAL_BASE = process.env.EXPO_PUBLIC_GLOBAL_API_BASE;
+const GLOBAL_KEY = process.env.EXPO_PUBLIC_GLOBAL_API_KEY;
+
+// Appends the API key as a query param to any URL
+const withKey = (url) => {
+ const separator = url.includes("?") ? "&" : "?";
+ return `${url}${separator}key=${GLOBAL_KEY}`;
+};
+
+const useGlobalHook = () => {
+ // Initialisations ---------------------
+
+ const eventsEndpoint = `${GLOBAL_BASE}/api/events`;
+ const playersEndpoint = `${GLOBAL_BASE}/api/players`;
+ const cachesEndpoint = `${GLOBAL_BASE}/api/caches`;
+ const findsEndpoint = `${GLOBAL_BASE}/api/finds`;
+ const usersEndpoint = `${GLOBAL_BASE}/api/users`;
+
+ // Handlers ----------------------------
+
+ // Events
+ const getPublicEvents = async () => {
+ const response = await API.get(withKey(eventsEndpoint));
+ if (!response.isSuccess) return [];
+ return response.result.filter((e) => e.EventIspublic);
+ };
+
+ const getEvent = async (eventId) => {
+ const response = await API.get(withKey(`${eventsEndpoint}/${eventId}`));
+ return response.isSuccess ? response.result : null;
+ };
+
+ // Players
+ const getPlayersByEvent = async (eventId) => {
+ const response = await API.get(
+ withKey(`${playersEndpoint}/events/${eventId}`),
+ );
+ return response.isSuccess ? response.result : [];
+ };
+
+ const joinEvent = async (data) => {
+ const response = await API.post(withKey(playersEndpoint), data);
+ return response.isSuccess ? response.result : null;
+ };
+
+ // Caches
+ const getCachesByEvent = async (eventId) => {
+ const response = await API.get(
+ withKey(`${cachesEndpoint}/events/${eventId}`),
+ );
+ return response.isSuccess ? response.result : [];
+ };
+
+ const getCache = async (cacheId) => {
+ const response = await API.get(withKey(`${cachesEndpoint}/${cacheId}`));
+ return response.isSuccess ? response.result : null;
+ };
+
+ // Finds
+ const getFindsByEvent = async (eventId) => {
+ const response = await API.get(
+ withKey(`${findsEndpoint}/events/${eventId}`),
+ );
+ return response.isSuccess ? response.result : [];
+ };
+
+ const getFindsByPlayer = async (playerId) => {
+ const response = await API.get(
+ withKey(`${findsEndpoint}/players/${playerId}`),
+ );
+ return response.isSuccess ? response.result : [];
+ };
+
+ const logFind = async (data) => {
+ const response = await API.post(withKey(findsEndpoint), data);
+ return response.isSuccess ? response.result : null;
+ };
+
+ // Users
+ const getUser = async (userId) => {
+ const response = await API.get(withKey(`${usersEndpoint}/${userId}`));
+ return response.isSuccess ? response.result : null;
+ };
+
+ // Return --------------------------------
+
+ return {
+ getPublicEvents,
+ getEvent,
+ getPlayersByEvent,
+ joinEvent,
+ getCachesByEvent,
+ getCache,
+ getFindsByEvent,
+ getFindsByPlayer,
+ logFind,
+ getUser,
+ };
+};
+
+export default useGlobalHook;
diff --git a/src/template.js b/src/template.js
new file mode 100644
index 0000000..f707362
--- /dev/null
+++ b/src/template.js
@@ -0,0 +1,13 @@
+/*import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
+const Component = () => {
+// Initialisations ---------------------
+// State -------------------------------
+// Handlers ----------------------------
+// View --------------------------------
+return (
+);
+};
+const styles = StyleSheet.create({
+});
+export default Component;
+*/