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/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;