Add ExpandedMapScreen and integrate it into navigation; enhance CacheCardItem and GameSettingsScreen with improved UI elements

This commit is contained in:
bobbert committed 2026-04-07 14:38:18 +01:00
1 parent 60cf6baad2
commit d34bb650f4
15 files changed
+361 -61

No files matched your search

+3
View File
@@ -16,6 +16,7 @@ import AdminHubScreen from "./src/components/screens/AdminHubScreen";
import ManageSubgroupsScreen from "./src/components/screens/ManageSubgroupsScreen"; import ManageSubgroupsScreen from "./src/components/screens/ManageSubgroupsScreen";
import GameSettingsScreen from "./src/components/screens/GameSettingsScreen"; import GameSettingsScreen from "./src/components/screens/GameSettingsScreen";
import PlayersScreen from "./src/components/screens/PlayersScreen"; import PlayersScreen from "./src/components/screens/PlayersScreen";
import ExpandedMapScreen from "./src/components/screens/ExpandedMapScreen";
const RootStack = createNativeStackNavigator(); const RootStack = createNativeStackNavigator();
const AuthStackNav = createNativeStackNavigator(); const AuthStackNav = createNativeStackNavigator();
@@ -47,6 +48,7 @@ const GameStack = () => (
<GameStackNav.Screen name="LeaderboardScreen" component={LeaderboardScreen}/> <GameStackNav.Screen name="LeaderboardScreen" component={LeaderboardScreen}/>
<GameStackNav.Screen name="GameSettingsScreen" component={GameSettingsScreen}/> <GameStackNav.Screen name="GameSettingsScreen" component={GameSettingsScreen}/>
<GameStackNav.Screen name="PlayersScreen" component={PlayersScreen}/> <GameStackNav.Screen name="PlayersScreen" component={PlayersScreen}/>
<GameStackNav.Screen name="ExpandedMapScreen" component={ExpandedMapScreen}/>
<GameStackNav.Screen name="GameTypeScreen" component={GameTypeScreen}/> <GameStackNav.Screen name="GameTypeScreen" component={GameTypeScreen}/>
<GameStackNav.Screen name="JoinPrivateGameScreen" component={JoinPrivateGameScreen}/> <GameStackNav.Screen name="JoinPrivateGameScreen" component={JoinPrivateGameScreen}/>
<GameStackNav.Screen name="ManagePrivateGameScreen" component={ManagePrivateGameScreen}/> <GameStackNav.Screen name="ManagePrivateGameScreen" component={ManagePrivateGameScreen}/>
@@ -67,6 +69,7 @@ const AdminStack = () => (
<AdminStackNav.Screen name="MapScreen" component={MapScreen}/> <AdminStackNav.Screen name="MapScreen" component={MapScreen}/>
<AdminStackNav.Screen name="LeaderboardScreen" component={LeaderboardScreen}/> <AdminStackNav.Screen name="LeaderboardScreen" component={LeaderboardScreen}/>
<AdminStackNav.Screen name="PlayersScreen" component={PlayersScreen}/> <AdminStackNav.Screen name="PlayersScreen" component={PlayersScreen}/>
<AdminStackNav.Screen name="ExpandedMapScreen" component={ExpandedMapScreen}/>
<AdminStackNav.Screen name="AdminHubScreen" component={AdminHubScreen}/> <AdminStackNav.Screen name="AdminHubScreen" component={AdminHubScreen}/>
<AdminStackNav.Screen name="ManageSubgroupsScreen" component={ManageSubgroupsScreen}/> <AdminStackNav.Screen name="ManageSubgroupsScreen" component={ManageSubgroupsScreen}/>
<AdminStackNav.Screen name="TeamScreen" component={TeamScreen}/> <AdminStackNav.Screen name="TeamScreen" component={TeamScreen}/>
+2
View File
@@ -560,6 +560,7 @@ addRoute('GET', '/game-data/:gid', (req, res, params) => {
// Helper: convert storage cache to API response format // Helper: convert storage cache to API response format
const toCacheApi = (cache, gid) => ({ const toCacheApi = (cache, gid) => ({
id: cache.CacheId, id: cache.CacheId,
name: cache.Name || '',
coordinates: { latitude: cache.Latitude, longitude: cache.Longitude }, coordinates: { latitude: cache.Latitude, longitude: cache.Longitude },
radius: cache.TriggerMeters, radius: cache.TriggerMeters,
clue: cache.Title, clue: cache.Title,
@@ -572,6 +573,7 @@ const toCacheApi = (cache, gid) => ({
// Helper: convert API payload to storage cache format // Helper: convert API payload to storage cache format
const toCacheStorage = (data, cacheId) => ({ const toCacheStorage = (data, cacheId) => ({
CacheId: cacheId, CacheId: cacheId,
Name: data.name || data.Name || '',
Title: data.clue || data.Title || '', Title: data.clue || data.Title || '',
Latitude: data.latitude != null ? data.latitude : (data.Latitude || 0), Latitude: data.latitude != null ? data.latitude : (data.Latitude || 0),
Longitude: data.longitude != null ? data.longitude : (data.Longitude || 0), Longitude: data.longitude != null ? data.longitude : (data.Longitude || 0),
+9 -1
View File
@@ -8,6 +8,7 @@ const CacheCardItem = ({cache, isAdmin, onEdit, onDelete, onSelect}) => {
const isClaimed = Boolean(cache.ClaimedByUid); const isClaimed = Boolean(cache.ClaimedByUid);
const statusText = isClaimed ? 'Claimed' : 'Available'; const statusText = isClaimed ? 'Claimed' : 'Available';
const statusColor = isClaimed ? '#9ca3af' : '#16a34a'; const statusColor = isClaimed ? '#9ca3af' : '#16a34a';
const displayName = cache.name || cache.clue || 'Unnamed Cache';
// State ---------------------- // State ----------------------
// Handlers ------------------- // Handlers -------------------
@@ -16,7 +17,12 @@ const CacheCardItem = ({cache, isAdmin, onEdit, onDelete, onSelect}) => {
return ( return (
<Card> <Card>
<View style={styles.topRow}> <View style={styles.topRow}>
<View style={styles.nameWrap}>
<Text style={styles.name} numberOfLines={1}>{displayName}</Text>
{cache.name ? (
<Text style={styles.clue} numberOfLines={1}>{cache.clue}</Text> <Text style={styles.clue} numberOfLines={1}>{cache.clue}</Text>
) : null}
</View>
<Text style={[styles.status, {color: statusColor}]}>{statusText}</Text> <Text style={[styles.status, {color: statusColor}]}>{statusText}</Text>
</View> </View>
<View style={styles.actions}> <View style={styles.actions}>
@@ -55,7 +61,9 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
marginBottom: 8, marginBottom: 8,
}, },
clue: {fontSize: 15, fontWeight: '600', color: '#1f2937', flex: 1, marginRight: 8}, clue: {fontSize: 13, color: '#6b7280', marginTop: 2},
nameWrap: {flex: 1, marginRight: 8},
name: {fontSize: 15, fontWeight: '600', color: '#1f2937'},
status: {fontSize: 13, fontWeight: '600'}, status: {fontSize: 13, fontWeight: '600'},
actions: {flexDirection: 'row', gap: 8}, actions: {flexDirection: 'row', gap: 8},
editBtn: { editBtn: {
+1 -2
View File
@@ -36,13 +36,12 @@ const ClaimTimerView = ({ cache, onClaimSuccess, isClaiming }) => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
overlay: { overlay: {
position: 'absolute',
bottom: 24,
alignSelf: 'center', alignSelf: 'center',
backgroundColor: 'rgba(0,0,0,0.75)', backgroundColor: 'rgba(0,0,0,0.75)',
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 12, paddingVertical: 12,
borderRadius: 10, borderRadius: 10,
marginTop: 8,
}, },
text: { text: {
color: '#ffffff', color: '#ffffff',
+35 -13
View File
@@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import MapView, {Circle} from 'react-native-maps'; import {StyleSheet, View} from 'react-native';
import ClaimTimerView from './ClaimTimerView'; import MapView, {Circle, Marker} from 'react-native-maps';
const PlayerMapView = ({ userLocation, visibleCache, isClaiming, onClaimSuccess }) => { const PlayerMapView = ({ userLocation, visibleCache, heading }) => {
// Initialisation ------------- // Initialisation -------------
// State ---------------------- // State ----------------------
// Handlers ------------------- // Handlers -------------------
@@ -11,14 +11,17 @@ const PlayerMapView = ({ userLocation, visibleCache, isClaiming, onClaimSuccess
if (!userLocation) return null; if (!userLocation) return null;
return ( return (
<>
<MapView <MapView
style={{flex: 1}} style={{flex: 1}}
initialRegion={{ region={{
...userLocation, ...userLocation,
latitudeDelta: 0.01, latitudeDelta: 0.01,
longitudeDelta: 0.01, longitudeDelta: 0.01,
}} }}
scrollEnabled={false}
zoomEnabled={true}
rotateEnabled={false}
pitchEnabled={false}
showsUserLocation showsUserLocation
> >
{visibleCache ? ( {visibleCache ? (
@@ -29,16 +32,35 @@ const PlayerMapView = ({ userLocation, visibleCache, isClaiming, onClaimSuccess
strokeColor="rgba(250, 204, 21, 0.90)" strokeColor="rgba(250, 204, 21, 0.90)"
/> />
) : null} ) : null}
{heading !== null && heading !== undefined && (
<Marker
coordinate={userLocation}
flat={true}
rotation={heading}
anchor={{x: 0.5, y: 1.0}}
tracksViewChanges={false}
>
<View style={styles.coneWrap}>
<View style={styles.cone}/>
</View>
</Marker>
)}
</MapView> </MapView>
<ClaimTimerView
cache={visibleCache}
isClaiming={isClaiming}
onClaimSuccess={onClaimSuccess}
/>
</>
); );
}; };
export default PlayerMapView; const styles = StyleSheet.create({
coneWrap: {width: 22, height: 24, alignItems: 'center'},
cone: {
width: 0,
height: 0,
borderTopWidth: 24,
borderLeftWidth: 11,
borderRightWidth: 11,
borderTopColor: 'rgba(37,99,235,0.70)',
borderLeftColor: 'transparent',
borderRightColor: 'transparent',
},
});
export default PlayerMapView;
+1
View File
@@ -26,6 +26,7 @@ const Screen = ({ children, style, showBack = false }) => {
ManageSubgroupsScreen: "Manage Subgroups", ManageSubgroupsScreen: "Manage Subgroups",
GameSettingsScreen: "Game Settings", GameSettingsScreen: "Game Settings",
PlayersScreen: "Players", PlayersScreen: "Players",
ExpandedMapScreen: "Map View",
}; };
// State ---------------------- // State ----------------------
+117
View File
@@ -0,0 +1,117 @@
import React, {useEffect, useState} from 'react';
import {StyleSheet, View} from 'react-native';
import MapView, {Circle, Marker} from 'react-native-maps';
import * as Location from 'expo-location';
import {Magnetometer} from 'expo-sensors';
import Screen from '../layout/Screen';
import {toHeading} from '../../utils/geoMath';
const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.01, longitudeDelta: 0.01};
const ExpandedMapScreen = ({route}) => {
// Initialisation ------------
const {isAdmin, cacheRecords: cacheStr} = route.params || {};
const caches = cacheStr ? JSON.parse(cacheStr) : [];
// State ----------------------
const [userLocation, setUserLocation] = useState(null);
const [heading, setHeading] = useState(null);
// Handlers -------------------
useEffect(() => {
let locationSub;
let headingSub;
const start = async () => {
const {status} = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') return;
const pos = await Location.getCurrentPositionAsync({accuracy: Location.Accuracy.Balanced});
setUserLocation({latitude: pos.coords.latitude, longitude: pos.coords.longitude});
locationSub = await Location.watchPositionAsync(
{accuracy: Location.Accuracy.Balanced, distanceInterval: 2, timeInterval: 1000},
(next) => setUserLocation({latitude: next.coords.latitude, longitude: next.coords.longitude}),
);
Magnetometer.setUpdateInterval(500);
headingSub = Magnetometer.addListener((data) => setHeading(toHeading(data)));
};
start();
return () => {
if (locationSub) locationSub.remove();
if (headingSub) headingSub.remove();
};
}, []);
const region = userLocation
? {...userLocation, latitudeDelta: 0.01, longitudeDelta: 0.01}
: DEFAULT_REGION;
// View -----------------------
return (
<Screen showBack={true} style={styles.container}>
<View style={styles.mapWrap}>
<MapView
style={{flex: 1}}
initialRegion={region}
showsUserLocation
>
{/* Only admins see cache pin locations */}
{isAdmin && caches.map((cache) => (
<React.Fragment key={cache.id}>
<Marker
coordinate={cache.coordinates}
title={cache.name || cache.clue}
pinColor="#2563eb"
/>
<Circle
center={cache.coordinates}
radius={cache.radius}
fillColor="rgba(59,130,246,0.15)"
strokeColor="rgba(59,130,246,0.85)"
/>
</React.Fragment>
))}
{/* Heading cone for both admin and player */}
{userLocation && heading !== null && (
<Marker
coordinate={userLocation}
flat={true}
rotation={heading}
anchor={{x: 0.5, y: 1.0}}
tracksViewChanges={false}
>
<View style={styles.coneWrap}>
<View style={styles.cone}/>
</View>
</Marker>
)}
</MapView>
</View>
</Screen>
);
};
const styles = StyleSheet.create({
container: {padding: 0},
mapWrap: {flex: 1},
coneWrap: {width: 22, height: 24, alignItems: 'center'},
cone: {
width: 0,
height: 0,
borderTopWidth: 24,
borderLeftWidth: 11,
borderRightWidth: 11,
borderTopColor: 'rgba(37,99,235,0.70)',
borderLeftColor: 'transparent',
borderRightColor: 'transparent',
},
});
export default ExpandedMapScreen;
+18 -8
View File
@@ -1,5 +1,5 @@
import React, {useEffect, useState} from 'react'; import React, {useEffect, useState} from 'react';
import {ActivityIndicator, StyleSheet, Switch, Text, TextInput, View} from 'react-native'; import {ActivityIndicator, ScrollView, StyleSheet, Switch, Text, TextInput, View} from 'react-native';
import Screen from '../layout/Screen'; import Screen from '../layout/Screen';
import {Button, ButtonTray} from '../UI/Button'; import {Button, ButtonTray} from '../UI/Button';
import useGameHook from '../../hooks/useGameHook'; import useGameHook from '../../hooks/useGameHook';
@@ -76,6 +76,7 @@ const GameSettingsScreen = () => {
return ( return (
<Screen> <Screen>
<ScrollView contentContainerStyle={styles.formContainer} showsVerticalScrollIndicator={false}>
<Text style={styles.sectionTitle}>Game Configuration</Text> <Text style={styles.sectionTitle}>Game Configuration</Text>
<Text style={styles.label}>Game Name</Text> <Text style={styles.label}>Game Name</Text>
@@ -84,10 +85,11 @@ const GameSettingsScreen = () => {
value={groupName} value={groupName}
onChangeText={setGroupName} onChangeText={setGroupName}
placeholder="Enter game name" placeholder="Enter game name"
placeholderTextColor="#9ca3af"
/> />
<View style={styles.toggleRow}> <View style={styles.toggleRow}>
<Text style={styles.label}>Teams Enabled</Text> <Text style={styles.toggleLabel}>Teams Enabled</Text>
<Switch <Switch
value={teamsEnabled} value={teamsEnabled}
onValueChange={setTeamsEnabled} onValueChange={setTeamsEnabled}
@@ -105,6 +107,7 @@ const GameSettingsScreen = () => {
onChangeText={setMaxSubgroups} onChangeText={setMaxSubgroups}
keyboardType="numeric" keyboardType="numeric"
placeholder="1" placeholder="1"
placeholderTextColor="#9ca3af"
/> />
</> </>
)} )}
@@ -119,6 +122,7 @@ const GameSettingsScreen = () => {
<Text style={styles.codeValue}>{memberJoinCode || '—'}</Text> <Text style={styles.codeValue}>{memberJoinCode || '—'}</Text>
</View> </View>
<View style={styles.saveWrap}>
<ButtonTray> <ButtonTray>
<Button <Button
label={saving ? 'Saving...' : 'Save Settings'} label={saving ? 'Saving...' : 'Save Settings'}
@@ -127,14 +131,17 @@ const GameSettingsScreen = () => {
styleLabel={styles.saveLabel} styleLabel={styles.saveLabel}
/> />
</ButtonTray> </ButtonTray>
</View>
</ScrollView>
</Screen> </Screen>
); );
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
center: {justifyContent: 'center', alignItems: 'center'}, center: {justifyContent: 'center', alignItems: 'center'},
sectionTitle: {fontSize: 20, fontWeight: '700', color: '#1f2937', marginBottom: 15}, formContainer: {paddingBottom: 20},
label: {fontSize: 14, fontWeight: '600', color: '#374151', marginBottom: 4, marginTop: 10}, sectionTitle: {fontSize: 20, fontWeight: '700', color: '#1f2937', marginBottom: 16},
label: {fontSize: 14, fontWeight: '600', color: '#374151', marginBottom: 6, marginTop: 14},
body: {color: '#4b5563', fontSize: 15}, body: {color: '#4b5563', fontSize: 15},
input: { input: {
borderWidth: 1, borderWidth: 1,
@@ -143,29 +150,32 @@ const styles = StyleSheet.create({
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
fontSize: 16, fontSize: 16,
color: '#1f2937',
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
}, },
toggleRow: { toggleRow: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
marginTop: 10, marginTop: 16,
paddingVertical: 8, paddingVertical: 10,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: '#e5e7eb', borderBottomColor: '#e5e7eb',
}, },
toggleLabel: {fontSize: 14, fontWeight: '600', color: '#374151'},
codeSection: { codeSection: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
marginTop: 14, marginTop: 16,
paddingVertical: 10, paddingVertical: 12,
paddingHorizontal: 12, paddingHorizontal: 12,
backgroundColor: '#f3f4f6', backgroundColor: '#f3f4f6',
borderRadius: 8, borderRadius: 8,
}, },
codeLabel: {fontSize: 14, fontWeight: '600', color: '#374151'}, codeLabel: {fontSize: 14, fontWeight: '600', color: '#374151'},
codeValue: {fontSize: 16, fontWeight: '700', color: '#2563eb', letterSpacing: 2}, codeValue: {fontSize: 16, fontWeight: '700', color: '#2563eb', letterSpacing: 2},
saveWrap: {marginTop: 24},
saveButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'}, saveButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'},
saveLabel: {color: '#ffffff', fontWeight: '600'}, saveLabel: {color: '#ffffff', fontWeight: '600'},
}); });
+4
View File
@@ -39,6 +39,7 @@ const LoginScreen = ({ navigation }) => {
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Email" placeholder="Email"
placeholderTextColor="#9ca3af"
autoCapitalize="none" autoCapitalize="none"
keyboardType="email-address" keyboardType="email-address"
value={email} value={email}
@@ -48,6 +49,7 @@ const LoginScreen = ({ navigation }) => {
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Password" placeholder="Password"
placeholderTextColor="#9ca3af"
secureTextEntry secureTextEntry
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
@@ -93,6 +95,8 @@ const styles = StyleSheet.create({
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
fontSize: 16, fontSize: 16,
color: '#1f2937',
backgroundColor: '#ffffff',
}, },
errorText: { errorText: {
color: '#dc2626', color: '#dc2626',
+108 -27
View File
@@ -1,22 +1,20 @@
import React, {useCallback, useEffect, useState} from 'react'; import React, {useCallback, useEffect, useState} from 'react';
import {ActivityIndicator, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native'; import {ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native';
import MapView, {Circle, Marker} from 'react-native-maps'; import MapView, {Circle, Marker} from 'react-native-maps';
import * as Location from 'expo-location'; import * as Location from 'expo-location';
import {Magnetometer} from 'expo-sensors'; import {Magnetometer} from 'expo-sensors';
import Screen from '../layout/Screen'; import Screen from '../layout/Screen';
import {Button, ButtonTray} from '../UI/Button'; import {Button, ButtonTray} from '../UI/Button';
import CacheCardItem from '../gameplay/CacheCardItem'; import CacheCardItem from '../gameplay/CacheCardItem';
import ClaimTimerView from '../gameplay/ClaimTimerView';
import PlayerMapView from '../gameplay/PlayerMapView'; import PlayerMapView from '../gameplay/PlayerMapView';
import useGameHook from '../../hooks/useGameHook'; import useGameHook from '../../hooks/useGameHook';
import usePlayerGame from '../../hooks/usePlayerGame'; import usePlayerGame from '../../hooks/usePlayerGame';
import {getSession, setSessionGroup, setSessionUser} from '../../hooks/SessionStore'; import {getSession, setSessionGroup, setSessionUser} from '../../hooks/SessionStore';
import {toHeading} from '../../utils/geoMath';
const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.05, longitudeDelta: 0.05}; const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.05, longitudeDelta: 0.05};
const toHeading = ({x, y}) => {
const angle = Math.atan2(y, x) * (180 / Math.PI);
return angle < 0 ? angle + 360 : angle;
};
const MapScreen = ({navigation}) => { const MapScreen = ({navigation}) => {
// Initialisation ------------ // Initialisation ------------
@@ -40,6 +38,7 @@ const MapScreen = ({navigation}) => {
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [editingCacheId, setEditingCacheId] = useState(null); const [editingCacheId, setEditingCacheId] = useState(null);
const [newCoord, setNewCoord] = useState(null); const [newCoord, setNewCoord] = useState(null);
const [newName, setNewName] = useState('');
const [newClue, setNewClue] = useState(''); const [newClue, setNewClue] = useState('');
const [newRadius, setNewRadius] = useState('20'); const [newRadius, setNewRadius] = useState('20');
@@ -66,8 +65,7 @@ const MapScreen = ({navigation}) => {
setCacheRecords([]); setCacheRecords([]);
return; return;
} }
const sgFilter = isAdmin ? null : session.currentSGid; const rows = await getCaches(session.currentGid, null);
const rows = await getCaches(session.currentGid, sgFilter);
setCacheRecords(rows || []); setCacheRecords(rows || []);
}, [inGame, isAdmin]); }, [inGame, isAdmin]);
@@ -94,10 +92,9 @@ const MapScreen = ({navigation}) => {
(next) => setUserLocation({latitude: next.coords.latitude, longitude: next.coords.longitude}), (next) => setUserLocation({latitude: next.coords.latitude, longitude: next.coords.longitude}),
); );
if (isPlayer) { // Track heading for all in-game users (admin + player)
Magnetometer.setUpdateInterval(500); Magnetometer.setUpdateInterval(500);
headingSub = Magnetometer.addListener((data) => setHeading(toHeading(data))); headingSub = Magnetometer.addListener((data) => setHeading(toHeading(data)));
}
setPlayerLoading(false); setPlayerLoading(false);
}; };
@@ -152,6 +149,7 @@ const MapScreen = ({navigation}) => {
const fallback = userLocation || {latitude: mapRegion.latitude, longitude: mapRegion.longitude}; const fallback = userLocation || {latitude: mapRegion.latitude, longitude: mapRegion.longitude};
setEditingCacheId(null); setEditingCacheId(null);
setNewCoord(fallback); setNewCoord(fallback);
setNewName('');
setNewClue(''); setNewClue('');
setNewRadius('20'); setNewRadius('20');
setIsCreating(true); setIsCreating(true);
@@ -161,6 +159,7 @@ const MapScreen = ({navigation}) => {
const handleEditCache = (cache) => { const handleEditCache = (cache) => {
setEditingCacheId(cache.id); setEditingCacheId(cache.id);
setNewCoord(cache.coordinates); setNewCoord(cache.coordinates);
setNewName(cache.name || '');
setNewClue(cache.clue || ''); setNewClue(cache.clue || '');
setNewRadius(String(cache.radius || 20)); setNewRadius(String(cache.radius || 20));
setIsCreating(true); setIsCreating(true);
@@ -178,6 +177,7 @@ const MapScreen = ({navigation}) => {
if (!newCoord || !newClue.trim() || !session.currentGid) return; if (!newCoord || !newClue.trim() || !session.currentGid) return;
const payload = { const payload = {
gid: session.currentGid, gid: session.currentGid,
name: newName.trim(),
latitude: newCoord.latitude, latitude: newCoord.latitude,
longitude: newCoord.longitude, longitude: newCoord.longitude,
radius: parseInt(newRadius) || 20, radius: parseInt(newRadius) || 20,
@@ -196,7 +196,16 @@ const MapScreen = ({navigation}) => {
setEditingCacheId(null); setEditingCacheId(null);
}; };
// Player — select cache (scroll to / highlight) // Navigate to expanded map view
const handleExpandMap = () => {
navigation.navigate('ExpandedMapScreen', {
isAdmin,
cacheRecords: JSON.stringify(cacheRecords),
heading: heading || 0,
});
};
// Player — select cache
const handleSelectCache = (cache) => { const handleSelectCache = (cache) => {
// Future: scroll map to cache location // Future: scroll map to cache location
}; };
@@ -211,6 +220,7 @@ const MapScreen = ({navigation}) => {
<TextInput <TextInput
style={styles.codeInput} style={styles.codeInput}
placeholder="Enter Game Code" placeholder="Enter Game Code"
placeholderTextColor="#9ca3af"
value={gameCode} value={gameCode}
onChangeText={setGameCode} onChangeText={setGameCode}
autoCapitalize="characters" autoCapitalize="characters"
@@ -283,7 +293,15 @@ const MapScreen = ({navigation}) => {
<ScrollView style={styles.formSection}> <ScrollView style={styles.formSection}>
<TextInput <TextInput
style={styles.formInput} style={styles.formInput}
placeholder="Cache's Clue" placeholder="Cache Name"
placeholderTextColor="#9ca3af"
value={newName}
onChangeText={setNewName}
/>
<TextInput
style={styles.formInput}
placeholder="Cache Clue"
placeholderTextColor="#9ca3af"
value={newClue} value={newClue}
onChangeText={setNewClue} onChangeText={setNewClue}
/> />
@@ -293,6 +311,7 @@ const MapScreen = ({navigation}) => {
<TextInput <TextInput
style={styles.formInput} style={styles.formInput}
placeholder="Latitude" placeholder="Latitude"
placeholderTextColor="#9ca3af"
value={newCoord ? String(newCoord.latitude.toFixed(6)) : ''} value={newCoord ? String(newCoord.latitude.toFixed(6)) : ''}
editable={false} editable={false}
/> />
@@ -302,6 +321,7 @@ const MapScreen = ({navigation}) => {
<TextInput <TextInput
style={styles.formInput} style={styles.formInput}
placeholder="Longitude" placeholder="Longitude"
placeholderTextColor="#9ca3af"
value={newCoord ? String(newCoord.longitude.toFixed(6)) : ''} value={newCoord ? String(newCoord.longitude.toFixed(6)) : ''}
editable={false} editable={false}
/> />
@@ -310,6 +330,7 @@ const MapScreen = ({navigation}) => {
<TextInput <TextInput
style={styles.formInput} style={styles.formInput}
placeholder="Claim Radius (metres)" placeholder="Claim Radius (metres)"
placeholderTextColor="#9ca3af"
value={newRadius} value={newRadius}
onChangeText={setNewRadius} onChangeText={setNewRadius}
keyboardType="numeric" keyboardType="numeric"
@@ -337,15 +358,17 @@ const MapScreen = ({navigation}) => {
if (isAdmin) { if (isAdmin) {
return ( return (
<Screen style={styles.containerMap}> <Screen style={styles.containerMap}>
<View style={styles.mapWrap}> <View style={styles.mapContainer}>
<MapView <MapView
style={{flex: 1}} style={{flex: 1}}
initialRegion={mapRegion} region={mapRegion}
scrollEnabled={true}
zoomEnabled={true}
showsUserLocation showsUserLocation
> >
{cacheRecords.map((cache) => ( {cacheRecords.map((cache) => (
<React.Fragment key={cache.id}> <React.Fragment key={cache.id}>
<Marker coordinate={cache.coordinates} title={cache.clue}/> <Marker coordinate={cache.coordinates} title={cache.name || cache.clue}/>
<Circle <Circle
center={cache.coordinates} center={cache.coordinates}
radius={cache.radius} radius={cache.radius}
@@ -354,7 +377,23 @@ const MapScreen = ({navigation}) => {
/> />
</React.Fragment> </React.Fragment>
))} ))}
{userLocation && heading !== null && (
<Marker
coordinate={userLocation}
flat={true}
rotation={heading}
anchor={{x: 0.5, y: 1.0}}
tracksViewChanges={false}
>
<View style={styles.headingConeWrap}>
<View style={styles.headingCone}/>
</View>
</Marker>
)}
</MapView> </MapView>
<Pressable style={styles.expandButton} onPress={handleExpandMap}>
<Text style={styles.expandIcon}>⛶</Text>
</Pressable>
</View> </View>
<View style={styles.createCacheWrap}> <View style={styles.createCacheWrap}>
<Button <Button
@@ -364,8 +403,8 @@ const MapScreen = ({navigation}) => {
styleLabel={styles.createCacheLabel} styleLabel={styles.createCacheLabel}
/> />
</View> </View>
<View style={styles.cacheSection}> <View style={styles.cacheListWrap}>
<ScrollView> <ScrollView contentContainerStyle={styles.cacheListContent}>
{cacheRecords.map((cache) => ( {cacheRecords.map((cache) => (
<CacheCardItem <CacheCardItem
key={cache.id} key={cache.id}
@@ -411,14 +450,21 @@ const MapScreen = ({navigation}) => {
{teamsWarning && ( {teamsWarning && (
<Text style={styles.warning}>You are not in a team. Teams are required for this game!</Text> <Text style={styles.warning}>You are not in a team. Teams are required for this game!</Text>
)} )}
<View style={styles.mapWrap}> <View style={styles.mapContainer}>
<PlayerMapView <PlayerMapView
userLocation={userLocation} userLocation={userLocation}
visibleCache={visibleCache} visibleCache={visibleCache}
heading={heading}
/>
<Pressable style={styles.expandButton} onPress={handleExpandMap}>
<Text style={styles.expandIcon}>⛶</Text>
</Pressable>
</View>
<ClaimTimerView
cache={visibleCache}
isClaiming={isClaiming} isClaiming={isClaiming}
onClaimSuccess={handleClaim} onClaimSuccess={handleClaim}
/> />
</View>
<View style={styles.cacheSection}> <View style={styles.cacheSection}>
<ScrollView> <ScrollView>
{cacheRecords.map((cache) => ( {cacheRecords.map((cache) => (
@@ -441,7 +487,7 @@ const MapScreen = ({navigation}) => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
center: {justifyContent: 'center', alignItems: 'center'}, center: {justifyContent: 'center', alignItems: 'center'},
containerMap: {padding: 0}, containerMap: {padding: 0},
mapWrap: {flex: 1}, mapContainer: {height: 200},
error: {color: '#dc2626', fontSize: 15}, error: {color: '#dc2626', fontSize: 15},
loadingText: {color: '#6b7280', fontSize: 14, marginTop: 10}, loadingText: {color: '#6b7280', fontSize: 14, marginTop: 10},
inputRow: {flexDirection: 'row', gap: 10, marginBottom: 15, width: '100%', paddingHorizontal: 20}, inputRow: {flexDirection: 'row', gap: 10, marginBottom: 15, width: '100%', paddingHorizontal: 20},
@@ -454,6 +500,8 @@ const styles = StyleSheet.create({
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
fontSize: 16, fontSize: 16,
color: '#1f2937',
backgroundColor: '#ffffff',
}, },
joinButton: {backgroundColor: '#2563eb', borderColor: '#2563eb', flex: 0, paddingHorizontal: 20}, joinButton: {backgroundColor: '#2563eb', borderColor: '#2563eb', flex: 0, paddingHorizontal: 20},
joinLabel: {color: '#ffffff', fontWeight: '600'}, joinLabel: {color: '#ffffff', fontWeight: '600'},
@@ -466,9 +514,32 @@ const styles = StyleSheet.create({
paddingVertical: 8, paddingVertical: 8,
fontSize: 14, fontSize: 14,
}, },
createCacheWrap: {paddingHorizontal: 12, paddingVertical: 8}, expandButton: {
createCacheButton: {backgroundColor: '#2563eb', borderColor: '#2563eb'}, position: 'absolute',
top: 10,
left: 10,
backgroundColor: 'rgba(0,0,0,0.6)',
borderRadius: 6,
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
zIndex: 10,
},
expandIcon: {color: '#ffffff', fontSize: 18, fontWeight: '700'},
// Admin create button + scrollable cache list
createCacheWrap: {
paddingHorizontal: 12,
paddingVertical: 10,
backgroundColor: '#ffffff',
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
createCacheButton: {backgroundColor: '#2563eb', borderColor: '#2563eb', flex: 0},
createCacheLabel: {color: '#ffffff', fontWeight: '600'}, createCacheLabel: {color: '#ffffff', fontWeight: '600'},
cacheListWrap: {flex: 1, backgroundColor: '#f3f4f6'},
cacheListContent: {paddingHorizontal: 12, paddingTop: 10, paddingBottom: 12},
// Cache editor form
formSection: {flex: 1, paddingHorizontal: 12, paddingVertical: 10}, formSection: {flex: 1, paddingHorizontal: 12, paddingVertical: 10},
formInput: { formInput: {
borderWidth: 1, borderWidth: 1,
@@ -477,6 +548,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
fontSize: 16, fontSize: 16,
color: '#1f2937',
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
marginBottom: 10, marginBottom: 10,
}, },
@@ -487,13 +559,22 @@ const styles = StyleSheet.create({
saveLabel: {color: '#ffffff', fontWeight: '600'}, saveLabel: {color: '#ffffff', fontWeight: '600'},
cancelButton: {backgroundColor: '#6b7280', borderColor: '#6b7280'}, cancelButton: {backgroundColor: '#6b7280', borderColor: '#6b7280'},
cancelLabel: {color: '#ffffff', fontWeight: '600'}, cancelLabel: {color: '#ffffff', fontWeight: '600'},
cacheSection: { // Player cache section
maxHeight: 160, cacheSection: {flex: 1, backgroundColor: '#f3f4f6', paddingHorizontal: 12, paddingTop: 10},
backgroundColor: '#f3f4f6',
paddingHorizontal: 12,
paddingTop: 10,
},
emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 20, fontSize: 14}, emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 20, fontSize: 14},
mapWrap: {flex: 1},
// Heading direction cone (like Google Maps)
headingConeWrap: {width: 22, height: 24, alignItems: 'center'},
headingCone: {
width: 0,
height: 0,
borderTopWidth: 24,
borderLeftWidth: 11,
borderRightWidth: 11,
borderTopColor: 'rgba(37,99,235,0.70)',
borderLeftColor: 'transparent',
borderRightColor: 'transparent',
},
}); });
export default MapScreen; export default MapScreen;
+42 -9
View File
@@ -10,7 +10,7 @@ const PlayersScreen = () => {
// Initialisation ------------ // Initialisation ------------
const session = getSession(); const session = getSession();
const {getLobby, getTeams, getTeamMembers, getGroupMembers, getUser, getCaches} = useGameHook(); const {getLobby, getTeams, getTeamMembers, getGroupMembers, removeMember, getUser, getCaches} = useGameHook();
// State ---------------------- // State ----------------------
@@ -35,17 +35,15 @@ const PlayersScreen = () => {
setGroupInfo(group); setGroupInfo(group);
if (group && group.TeamsEnabled) { if (group && group.TeamsEnabled) {
// Teams enabled — load all teams for this group
const teamRows = await getTeams(session.currentGid); const teamRows = await getTeams(session.currentGid);
setTeams(teamRows || []); setTeams(teamRows || []);
} else { } else {
// Teams disabled — load all members for this group const allMembers = await getGroupMembers(session.currentGid);
const memberships = await getGroupMembers(session.currentGid); const nonAdminMembers = (allMembers || []).filter((m) => !m.IsAcceptedAdmin);
const nonAdminMembers = (memberships || []).filter((m) => !m.IsAcceptedAdmin);
const playerList = []; const playerList = [];
for (const m of nonAdminMembers) { for (const m of nonAdminMembers) {
const user = await getUser(m.Uid); const user = await getUser(m.Uid);
if (user) playerList.push(user); if (user) playerList.push({...user, membershipId: m.id});
} }
setPlayers(playerList); setPlayers(playerList);
} }
@@ -58,7 +56,6 @@ const PlayersScreen = () => {
setSelectedTeam(team); setSelectedTeam(team);
setDetailLoading(true); setDetailLoading(true);
// Load team members and their usernames
const ms = await getTeamMembers(team.Tid); const ms = await getTeamMembers(team.Tid);
setTeamMembers(ms || []); setTeamMembers(ms || []);
@@ -69,7 +66,6 @@ const PlayersScreen = () => {
} }
setMemberNames(names); setMemberNames(names);
// Load all caches to count individual contributions
const caches = await getCaches(session.currentGid, null); const caches = await getCaches(session.currentGid, null);
const counts = {}; const counts = {};
for (const m of (ms || [])) { for (const m of (ms || [])) {
@@ -86,6 +82,21 @@ const PlayersScreen = () => {
setMemberCacheCounts({}); setMemberCacheCounts({});
}; };
const handleRemovePlayer = async (membershipId) => {
await removeMember(membershipId);
await loadData();
};
const handleRemoveTeamMember = async (member) => {
// Find this user's subgroup membership to remove them from the game
const allMembers = await getGroupMembers(session.currentGid);
const membership = (allMembers || []).find((m) => m.Uid === member.Uid && !m.IsAcceptedAdmin);
if (membership) {
await removeMember(membership.id);
await handleSelectTeam(selectedTeam);
}
};
// View ----------------------- // View -----------------------
if (loading) { if (loading) {
@@ -130,6 +141,14 @@ const PlayersScreen = () => {
{memberCacheCounts[m.Uid] || 0} cache(s) claimed {memberCacheCounts[m.Uid] || 0} cache(s) claimed
</Text> </Text>
</View> </View>
<View style={styles.removeWrap}>
<Button
label="Remove"
onClick={() => handleRemoveTeamMember(m)}
styleButton={styles.removeButton}
styleLabel={styles.removeLabel}
/>
</View>
</Card> </Card>
))} ))}
{teamMembers.length === 0 && ( {teamMembers.length === 0 && (
@@ -185,7 +204,17 @@ const PlayersScreen = () => {
<ScrollView style={styles.listSection}> <ScrollView style={styles.listSection}>
{players.map((player) => ( {players.map((player) => (
<Card key={player.Uid}> <Card key={player.Uid}>
<View style={styles.memberRow}>
<Text style={styles.playerName}>{player.username}</Text> <Text style={styles.playerName}>{player.username}</Text>
</View>
<View style={styles.removeWrap}>
<Button
label="Remove"
onClick={() => handleRemovePlayer(player.membershipId)}
styleButton={styles.removeButton}
styleLabel={styles.removeLabel}
/>
</View>
</Card> </Card>
))} ))}
{players.length === 0 && ( {players.length === 0 && (
@@ -204,7 +233,7 @@ const styles = StyleSheet.create({
fontSize: 20, fontSize: 20,
fontWeight: '700', fontWeight: '700',
color: '#1f2937', color: '#1f2937',
marginBottom: 10, marginBottom: 12,
paddingHorizontal: 15, paddingHorizontal: 15,
paddingTop: 15, paddingTop: 15,
}, },
@@ -233,6 +262,10 @@ const styles = StyleSheet.create({
}, },
memberName: {fontSize: 16, fontWeight: '600', color: '#1f2937'}, memberName: {fontSize: 16, fontWeight: '600', color: '#1f2937'},
memberCaches: {fontSize: 13, fontWeight: '600', color: '#16a34a'}, memberCaches: {fontSize: 13, fontWeight: '600', color: '#16a34a'},
// Remove button
removeWrap: {marginTop: 8},
removeButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36},
removeLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
backWrap: {padding: 15}, backWrap: {padding: 15},
backButton: {backgroundColor: '#6b7280', borderColor: '#6b7280'}, backButton: {backgroundColor: '#6b7280', borderColor: '#6b7280'},
backLabel: {color: '#ffffff', fontWeight: '600'}, backLabel: {color: '#ffffff', fontWeight: '600'},
+6
View File
@@ -42,6 +42,7 @@ const RegisterScreen = ({navigation}) => {
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Username" placeholder="Username"
placeholderTextColor="#9ca3af"
value={username} value={username}
onChangeText={setUsername} onChangeText={setUsername}
editable={!isLoading} editable={!isLoading}
@@ -49,6 +50,7 @@ const RegisterScreen = ({navigation}) => {
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Email" placeholder="Email"
placeholderTextColor="#9ca3af"
autoCapitalize="none" autoCapitalize="none"
keyboardType="email-address" keyboardType="email-address"
value={email} value={email}
@@ -58,6 +60,7 @@ const RegisterScreen = ({navigation}) => {
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Password" placeholder="Password"
placeholderTextColor="#9ca3af"
secureTextEntry secureTextEntry
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
@@ -66,6 +69,7 @@ const RegisterScreen = ({navigation}) => {
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Confirm Password" placeholder="Confirm Password"
placeholderTextColor="#9ca3af"
secureTextEntry secureTextEntry
value={confirmPassword} value={confirmPassword}
onChangeText={setConfirmPassword} onChangeText={setConfirmPassword}
@@ -132,6 +136,8 @@ const styles = StyleSheet.create({
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
fontSize: 16, fontSize: 16,
color: '#1f2937',
backgroundColor: '#ffffff',
}, },
typeRow: { typeRow: {
flexDirection: 'row', flexDirection: 'row',
+2
View File
@@ -160,6 +160,8 @@ const styles = StyleSheet.create({
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
fontSize: 16, fontSize: 16,
color: '#1f2937',
backgroundColor: '#ffffff',
}, },
joinButton: {backgroundColor: '#2563eb', borderColor: '#2563eb', flex: 0, paddingHorizontal: 20}, joinButton: {backgroundColor: '#2563eb', borderColor: '#2563eb', flex: 0, paddingHorizontal: 20},
joinLabel: {color: '#ffffff', fontWeight: '600'}, joinLabel: {color: '#ffffff', fontWeight: '600'},
+6
View File
@@ -69,6 +69,11 @@ const useGameHook = () => {
return response.isSuccess ? response.result : []; return response.isSuccess ? response.result : [];
}; };
const removeMember = async (membershipId) => {
const response = await API.delete(`${subgroupMembershipsEndpoint}/${membershipId}`);
return response.isSuccess;
};
const joinAsAdmin = async (payload) => { const joinAsAdmin = async (payload) => {
const response = await API.post(adminWaitlistEndpoint, payload); const response = await API.post(adminWaitlistEndpoint, payload);
return response.isSuccess ? response.result : null; return response.isSuccess ? response.result : null;
@@ -156,6 +161,7 @@ const useGameHook = () => {
joinPrivateGame, joinPrivateGame,
joinAsAdmin, joinAsAdmin,
getGroupMembers, getGroupMembers,
removeMember,
getTeams, getTeams,
getTeam, getTeam,
createTeam, createTeam,
+7 -1
View File
@@ -1,5 +1,11 @@
import * as geolib from 'geolib'; import * as geolib from 'geolib';
// Convert raw Magnetometer {x, y} reading to a 0-360° heading
const toHeading = ({x, y}) => {
const angle = Math.atan2(y, x) * (180 / Math.PI);
return angle < 0 ? angle + 360 : angle;
};
const isWithinRadius = (playerCoords, cacheCoords, radius) => { const isWithinRadius = (playerCoords, cacheCoords, radius) => {
const distance = geolib.getDistance(playerCoords, cacheCoords); const distance = geolib.getDistance(playerCoords, cacheCoords);
return distance <= radius; return distance <= radius;
@@ -14,5 +20,5 @@ const isLookingAtCache = (playerHeading, playerCoords, cacheCoords, tolerance =
return diff <= tolerance || diff >= 360 - tolerance; return diff <= tolerance || diff >= 360 - tolerance;
}; };
export { isWithinRadius, isLookingAtCache }; export { toHeading, isWithinRadius, isLookingAtCache };