mirror of
https://github.com/mudabbir-ahmad/MAD-Treasure-Hunt.git
synced 2026-10-07 19:00:20 +00:00
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:
1 parent
6e25fe6807
commit
157d69c21c
5 files changed
+800
No files matched your search
@@ -1,5 +1,9 @@
|
|||||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# Environment variables (contains secrets)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules/
|
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;
|
||||||
Reference in new issue
Block a user