Add global events, map, cache and leaderboard screens

Introduce four new screens for global events: GlobalEventsScreen (lists public events and handles joining), GlobalMapScreen (map/list view of event caches with user location via expo-location and markers using react-native-maps), GlobalCacheViewScreen (cache detail view with ability to log discoveries), and GlobalLeaderboardScreen (rankings and recent finds). Screens use existing global hooks and SessionStore for fetching/joining events, caches, finds and for storing current global player context; they include loading/error states and navigation between screens. Also add simple tab components, styling, and refresh actions.
This commit is contained in:
suddenlyoranges committed 2026-04-09 15:29:29 +01:00
1 parent 6e25fe6807
commit 157d69c21c
5 files changed
+800

No files matched your search

+4
View File
@@ -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/
@@ -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 (
<Screen showBack>
<View style={styles.container}>
<Card style={styles.card}>
<Text style={styles.title}>{cache.CacheName}</Text>
<View
style={[
styles.statusBadge,
isFound ? styles.badgeFound : styles.badgeOpen,
]}
>
<Text style={styles.statusText}>
{isFound ? "Already Found" : "Not Yet Found"}
</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.label}>Points</Text>
<Text style={styles.value}>{cache.CachePoints ?? 0}</Text>
</View>
{cache.CacheDescription ? (
<View style={styles.detailRow}>
<Text style={styles.label}>Description</Text>
<Text style={styles.value}>{cache.CacheDescription}</Text>
</View>
) : null}
{cache.CacheClue ? (
<View style={styles.detailRow}>
<Text style={styles.label}>Clue</Text>
<Text style={styles.value}>{cache.CacheClue}</Text>
</View>
) : null}
</Card>
{isLogging ? (
<ActivityIndicator size="large" />
) : (
<ButtonTray>
{!isFound && (
<Button
label="Log Discovery"
onClick={handleLogFind}
styleButton={styles.logButton}
styleLabel={styles.logButtonLabel}
/>
)}
<Button label="Back" onClick={() => navigation.goBack()} />
</ButtonTray>
)}
</View>
</Screen>
);
};
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;
@@ -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 (
<Screen>
<View style={styles.container}>
<ButtonTray>
<Button label="Refresh" onClick={loadEvents} />
</ButtonTray>
{error ? <Text style={styles.error}>{error}</Text> : null}
{isLoading ? (
<View style={styles.center}>
<ActivityIndicator size="large" />
<Text style={styles.loadingText}>Loading public events…</Text>
</View>
) : events.length === 0 ? (
<Text style={styles.empty}>No public events available.</Text>
) : (
<>
<Text style={styles.hint}>
Tap an event to join and explore its caches.
</Text>
<EventList events={events} onSelect={handleSelect} />
</>
)}
{joining !== null && (
<View style={styles.overlay}>
<ActivityIndicator size="large" color="#fff" />
</View>
)}
</View>
</Screen>
);
};
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;
@@ -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 (
<Screen style={styles.center}>
<ActivityIndicator size="large" />
</Screen>
);
}
return (
<Screen showBack>
<View style={styles.container}>
<View style={styles.tabs}>
<TabButton
label="Rankings"
active={activeTab === "rankings"}
onPress={() => setActiveTab("rankings")}
/>
<TabButton
label="Recent Finds"
active={activeTab === "recentFinds"}
onPress={() => setActiveTab("recentFinds")}
/>
</View>
{activeTab === "rankings" ? (
<ScrollView contentContainerStyle={styles.rankList}>
{rankings.length === 0 ? (
<Text style={styles.empty}>No finds recorded yet.</Text>
) : (
rankings.map((entry, index) => {
const isCurrent =
entry.playerId === session.currentGlobalPlayerId;
return (
<Card
key={entry.playerId}
style={[styles.rankCard, isCurrent && styles.myCard]}
>
<View style={styles.rankRow}>
<Text style={styles.rankPos}>#{index + 1}</Text>
<Text style={styles.rankName} numberOfLines={1}>
{entry.name || `Player #${entry.playerId}`}
{isCurrent ? " (you)" : ""}
</Text>
<View style={styles.rankRight}>
<Text style={styles.rankPoints}>
{entry.points} pts
</Text>
<Text style={styles.rankFinds}>
{entry.finds} finds
</Text>
</View>
</View>
</Card>
);
})
)}
</ScrollView>
) : recentFinds.length === 0 ? (
<Text style={styles.empty}>No finds recorded yet.</Text>
) : (
<FindList finds={recentFinds} />
)}
<ButtonTray>
<Button label="Refresh" onClick={loadData} />
</ButtonTray>
</View>
</Screen>
);
};
const TabButton = ({ label, active, onPress }) => (
<Text
onPress={onPress}
style={[tabStyles.tab, active && tabStyles.activeTab]}
>
{label}
</Text>
);
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;
@@ -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 (
<Screen style={styles.center}>
<ActivityIndicator size="large" />
</Screen>
);
}
return (
<Screen showBack>
<View style={styles.container}>
{/* Tab switcher */}
<View style={styles.tabs}>
<TabButton
label="Map"
active={activeTab === "map"}
onPress={() => setActiveTab("map")}
/>
<TabButton
label="List"
active={activeTab === "list"}
onPress={() => setActiveTab("list")}
/>
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
{activeTab === "map" ? (
<MapView style={styles.map} region={mapRegion} showsUserLocation>
{caches.map((cache) => {
const found = foundCacheIds.includes(cache.CacheID);
return (
<Marker
key={cache.CacheID}
coordinate={{
latitude: cache.CacheLatitude,
longitude: cache.CacheLongitude,
}}
title={cache.CacheName}
description={`${cache.CachePoints ?? 0} pts${found ? " · Found" : ""}`}
pinColor={found ? "#22c55e" : "#ef4444"}
onCalloutPress={() => handleCacheSelect(cache)}
/>
);
})}
</MapView>
) : (
<CacheList
caches={caches}
foundCacheIds={foundCacheIds}
onSelect={handleCacheSelect}
/>
)}
<ButtonTray>
<Button label="Leaderboard" onClick={handleGotoLeaderboard} />
<Button label="Refresh" onClick={loadData} />
</ButtonTray>
</View>
</Screen>
);
};
const TabButton = ({ label, active, onPress }) => (
<Text
onPress={onPress}
style={[tabStyles.tab, active && tabStyles.activeTab]}
>
{label}
</Text>
);
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;