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 GameSettingsScreen from "./src/components/screens/GameSettingsScreen";
import PlayersScreen from "./src/components/screens/PlayersScreen";
import ExpandedMapScreen from "./src/components/screens/ExpandedMapScreen";
const RootStack = createNativeStackNavigator();
const AuthStackNav = createNativeStackNavigator();
@@ -47,6 +48,7 @@ const GameStack = () => (
<GameStackNav.Screen name="LeaderboardScreen" component={LeaderboardScreen}/>
<GameStackNav.Screen name="GameSettingsScreen" component={GameSettingsScreen}/>
<GameStackNav.Screen name="PlayersScreen" component={PlayersScreen}/>
<GameStackNav.Screen name="ExpandedMapScreen" component={ExpandedMapScreen}/>
<GameStackNav.Screen name="GameTypeScreen" component={GameTypeScreen}/>
<GameStackNav.Screen name="JoinPrivateGameScreen" component={JoinPrivateGameScreen}/>
<GameStackNav.Screen name="ManagePrivateGameScreen" component={ManagePrivateGameScreen}/>
@@ -67,6 +69,7 @@ const AdminStack = () => (
<AdminStackNav.Screen name="MapScreen" component={MapScreen}/>
<AdminStackNav.Screen name="LeaderboardScreen" component={LeaderboardScreen}/>
<AdminStackNav.Screen name="PlayersScreen" component={PlayersScreen}/>
<AdminStackNav.Screen name="ExpandedMapScreen" component={ExpandedMapScreen}/>
<AdminStackNav.Screen name="AdminHubScreen" component={AdminHubScreen}/>
<AdminStackNav.Screen name="ManageSubgroupsScreen" component={ManageSubgroupsScreen}/>
<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
const toCacheApi = (cache, gid) => ({
id: cache.CacheId,
name: cache.Name || '',
coordinates: { latitude: cache.Latitude, longitude: cache.Longitude },
radius: cache.TriggerMeters,
clue: cache.Title,
@@ -572,6 +573,7 @@ const toCacheApi = (cache, gid) => ({
// Helper: convert API payload to storage cache format
const toCacheStorage = (data, cacheId) => ({
CacheId: cacheId,
Name: data.name || data.Name || '',
Title: data.clue || data.Title || '',
Latitude: data.latitude != null ? data.latitude : (data.Latitude || 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 statusText = isClaimed ? 'Claimed' : 'Available';
const statusColor = isClaimed ? '#9ca3af' : '#16a34a';
const displayName = cache.name || cache.clue || 'Unnamed Cache';
// State ----------------------
// Handlers -------------------
@@ -16,7 +17,12 @@ const CacheCardItem = ({cache, isAdmin, onEdit, onDelete, onSelect}) => {
return (
<Card>
<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>
) : null}
</View>
<Text style={[styles.status, {color: statusColor}]}>{statusText}</Text>
</View>
<View style={styles.actions}>
@@ -55,7 +61,9 @@ const styles = StyleSheet.create({
alignItems: 'center',
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'},
actions: {flexDirection: 'row', gap: 8},
editBtn: {
+1 -2
View File
@@ -36,13 +36,12 @@ const ClaimTimerView = ({ cache, onClaimSuccess, isClaiming }) => {
const styles = StyleSheet.create({
overlay: {
position: 'absolute',
bottom: 24,
alignSelf: 'center',
backgroundColor: 'rgba(0,0,0,0.75)',
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 10,
marginTop: 8,
},
text: {
color: '#ffffff',
+35 -13
View File
@@ -1,8 +1,8 @@
import React from 'react';
import MapView, {Circle} from 'react-native-maps';
import ClaimTimerView from './ClaimTimerView';
import {StyleSheet, View} from 'react-native';
import MapView, {Circle, Marker} from 'react-native-maps';
const PlayerMapView = ({ userLocation, visibleCache, isClaiming, onClaimSuccess }) => {
const PlayerMapView = ({ userLocation, visibleCache, heading }) => {
// Initialisation -------------
// State ----------------------
// Handlers -------------------
@@ -11,14 +11,17 @@ const PlayerMapView = ({ userLocation, visibleCache, isClaiming, onClaimSuccess
if (!userLocation) return null;
return (
<>
<MapView
style={{flex: 1}}
initialRegion={{
region={{
...userLocation,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
}}
scrollEnabled={false}
zoomEnabled={true}
rotateEnabled={false}
pitchEnabled={false}
showsUserLocation
>
{visibleCache ? (
@@ -29,16 +32,35 @@ const PlayerMapView = ({ userLocation, visibleCache, isClaiming, onClaimSuccess
strokeColor="rgba(250, 204, 21, 0.90)"
/>
) : 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>
<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",
GameSettingsScreen: "Game Settings",
PlayersScreen: "Players",
ExpandedMapScreen: "Map View",
};
// 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 {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 {Button, ButtonTray} from '../UI/Button';
import useGameHook from '../../hooks/useGameHook';
@@ -76,6 +76,7 @@ const GameSettingsScreen = () => {
return (
<Screen>
<ScrollView contentContainerStyle={styles.formContainer} showsVerticalScrollIndicator={false}>
<Text style={styles.sectionTitle}>Game Configuration</Text>
<Text style={styles.label}>Game Name</Text>
@@ -84,10 +85,11 @@ const GameSettingsScreen = () => {
value={groupName}
onChangeText={setGroupName}
placeholder="Enter game name"
placeholderTextColor="#9ca3af"
/>
<View style={styles.toggleRow}>
<Text style={styles.label}>Teams Enabled</Text>
<Text style={styles.toggleLabel}>Teams Enabled</Text>
<Switch
value={teamsEnabled}
onValueChange={setTeamsEnabled}
@@ -105,6 +107,7 @@ const GameSettingsScreen = () => {
onChangeText={setMaxSubgroups}
keyboardType="numeric"
placeholder="1"
placeholderTextColor="#9ca3af"
/>
</>
)}
@@ -119,6 +122,7 @@ const GameSettingsScreen = () => {
<Text style={styles.codeValue}>{memberJoinCode || '—'}</Text>
</View>
<View style={styles.saveWrap}>
<ButtonTray>
<Button
label={saving ? 'Saving...' : 'Save Settings'}
@@ -127,14 +131,17 @@ const GameSettingsScreen = () => {
styleLabel={styles.saveLabel}
/>
</ButtonTray>
</View>
</ScrollView>
</Screen>
);
};
const styles = StyleSheet.create({
center: {justifyContent: 'center', alignItems: 'center'},
sectionTitle: {fontSize: 20, fontWeight: '700', color: '#1f2937', marginBottom: 15},
label: {fontSize: 14, fontWeight: '600', color: '#374151', marginBottom: 4, marginTop: 10},
formContainer: {paddingBottom: 20},
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},
input: {
borderWidth: 1,
@@ -143,29 +150,32 @@ const styles = StyleSheet.create({
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
color: '#1f2937',
backgroundColor: '#ffffff',
},
toggleRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 10,
paddingVertical: 8,
marginTop: 16,
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
toggleLabel: {fontSize: 14, fontWeight: '600', color: '#374151'},
codeSection: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 14,
paddingVertical: 10,
marginTop: 16,
paddingVertical: 12,
paddingHorizontal: 12,
backgroundColor: '#f3f4f6',
borderRadius: 8,
},
codeLabel: {fontSize: 14, fontWeight: '600', color: '#374151'},
codeValue: {fontSize: 16, fontWeight: '700', color: '#2563eb', letterSpacing: 2},
saveWrap: {marginTop: 24},
saveButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'},
saveLabel: {color: '#ffffff', fontWeight: '600'},
});
+4
View File
@@ -39,6 +39,7 @@ const LoginScreen = ({ navigation }) => {
<TextInput
style={styles.input}
placeholder="Email"
placeholderTextColor="#9ca3af"
autoCapitalize="none"
keyboardType="email-address"
value={email}
@@ -48,6 +49,7 @@ const LoginScreen = ({ navigation }) => {
<TextInput
style={styles.input}
placeholder="Password"
placeholderTextColor="#9ca3af"
secureTextEntry
value={password}
onChangeText={setPassword}
@@ -93,6 +95,8 @@ const styles = StyleSheet.create({
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
color: '#1f2937',
backgroundColor: '#ffffff',
},
errorText: {
color: '#dc2626',
+108 -27
View File
@@ -1,22 +1,20 @@
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 * as Location from 'expo-location';
import {Magnetometer} from 'expo-sensors';
import Screen from '../layout/Screen';
import {Button, ButtonTray} from '../UI/Button';
import CacheCardItem from '../gameplay/CacheCardItem';
import ClaimTimerView from '../gameplay/ClaimTimerView';
import PlayerMapView from '../gameplay/PlayerMapView';
import useGameHook from '../../hooks/useGameHook';
import usePlayerGame from '../../hooks/usePlayerGame';
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 toHeading = ({x, y}) => {
const angle = Math.atan2(y, x) * (180 / Math.PI);
return angle < 0 ? angle + 360 : angle;
};
const MapScreen = ({navigation}) => {
// Initialisation ------------
@@ -40,6 +38,7 @@ const MapScreen = ({navigation}) => {
const [isCreating, setIsCreating] = useState(false);
const [editingCacheId, setEditingCacheId] = useState(null);
const [newCoord, setNewCoord] = useState(null);
const [newName, setNewName] = useState('');
const [newClue, setNewClue] = useState('');
const [newRadius, setNewRadius] = useState('20');
@@ -66,8 +65,7 @@ const MapScreen = ({navigation}) => {
setCacheRecords([]);
return;
}
const sgFilter = isAdmin ? null : session.currentSGid;
const rows = await getCaches(session.currentGid, sgFilter);
const rows = await getCaches(session.currentGid, null);
setCacheRecords(rows || []);
}, [inGame, isAdmin]);
@@ -94,10 +92,9 @@ const MapScreen = ({navigation}) => {
(next) => setUserLocation({latitude: next.coords.latitude, longitude: next.coords.longitude}),
);
if (isPlayer) {
// Track heading for all in-game users (admin + player)
Magnetometer.setUpdateInterval(500);
headingSub = Magnetometer.addListener((data) => setHeading(toHeading(data)));
}
setPlayerLoading(false);
};
@@ -152,6 +149,7 @@ const MapScreen = ({navigation}) => {
const fallback = userLocation || {latitude: mapRegion.latitude, longitude: mapRegion.longitude};
setEditingCacheId(null);
setNewCoord(fallback);
setNewName('');
setNewClue('');
setNewRadius('20');
setIsCreating(true);
@@ -161,6 +159,7 @@ const MapScreen = ({navigation}) => {
const handleEditCache = (cache) => {
setEditingCacheId(cache.id);
setNewCoord(cache.coordinates);
setNewName(cache.name || '');
setNewClue(cache.clue || '');
setNewRadius(String(cache.radius || 20));
setIsCreating(true);
@@ -178,6 +177,7 @@ const MapScreen = ({navigation}) => {
if (!newCoord || !newClue.trim() || !session.currentGid) return;
const payload = {
gid: session.currentGid,
name: newName.trim(),
latitude: newCoord.latitude,
longitude: newCoord.longitude,
radius: parseInt(newRadius) || 20,
@@ -196,7 +196,16 @@ const MapScreen = ({navigation}) => {
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) => {
// Future: scroll map to cache location
};
@@ -211,6 +220,7 @@ const MapScreen = ({navigation}) => {
<TextInput
style={styles.codeInput}
placeholder="Enter Game Code"
placeholderTextColor="#9ca3af"
value={gameCode}
onChangeText={setGameCode}
autoCapitalize="characters"
@@ -283,7 +293,15 @@ const MapScreen = ({navigation}) => {
<ScrollView style={styles.formSection}>
<TextInput
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}
onChangeText={setNewClue}
/>
@@ -293,6 +311,7 @@ const MapScreen = ({navigation}) => {
<TextInput
style={styles.formInput}
placeholder="Latitude"
placeholderTextColor="#9ca3af"
value={newCoord ? String(newCoord.latitude.toFixed(6)) : ''}
editable={false}
/>
@@ -302,6 +321,7 @@ const MapScreen = ({navigation}) => {
<TextInput
style={styles.formInput}
placeholder="Longitude"
placeholderTextColor="#9ca3af"
value={newCoord ? String(newCoord.longitude.toFixed(6)) : ''}
editable={false}
/>
@@ -310,6 +330,7 @@ const MapScreen = ({navigation}) => {
<TextInput
style={styles.formInput}
placeholder="Claim Radius (metres)"
placeholderTextColor="#9ca3af"
value={newRadius}
onChangeText={setNewRadius}
keyboardType="numeric"
@@ -337,15 +358,17 @@ const MapScreen = ({navigation}) => {
if (isAdmin) {
return (
<Screen style={styles.containerMap}>
<View style={styles.mapWrap}>
<View style={styles.mapContainer}>
<MapView
style={{flex: 1}}
initialRegion={mapRegion}
region={mapRegion}
scrollEnabled={true}
zoomEnabled={true}
showsUserLocation
>
{cacheRecords.map((cache) => (
<React.Fragment key={cache.id}>
<Marker coordinate={cache.coordinates} title={cache.clue}/>
<Marker coordinate={cache.coordinates} title={cache.name || cache.clue}/>
<Circle
center={cache.coordinates}
radius={cache.radius}
@@ -354,7 +377,23 @@ const MapScreen = ({navigation}) => {
/>
</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>
<Pressable style={styles.expandButton} onPress={handleExpandMap}>
<Text style={styles.expandIcon}>⛶</Text>
</Pressable>
</View>
<View style={styles.createCacheWrap}>
<Button
@@ -364,8 +403,8 @@ const MapScreen = ({navigation}) => {
styleLabel={styles.createCacheLabel}
/>
</View>
<View style={styles.cacheSection}>
<ScrollView>
<View style={styles.cacheListWrap}>
<ScrollView contentContainerStyle={styles.cacheListContent}>
{cacheRecords.map((cache) => (
<CacheCardItem
key={cache.id}
@@ -411,14 +450,21 @@ const MapScreen = ({navigation}) => {
{teamsWarning && (
<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
userLocation={userLocation}
visibleCache={visibleCache}
heading={heading}
/>
<Pressable style={styles.expandButton} onPress={handleExpandMap}>
<Text style={styles.expandIcon}>⛶</Text>
</Pressable>
</View>
<ClaimTimerView
cache={visibleCache}
isClaiming={isClaiming}
onClaimSuccess={handleClaim}
/>
</View>
<View style={styles.cacheSection}>
<ScrollView>
{cacheRecords.map((cache) => (
@@ -441,7 +487,7 @@ const MapScreen = ({navigation}) => {
const styles = StyleSheet.create({
center: {justifyContent: 'center', alignItems: 'center'},
containerMap: {padding: 0},
mapWrap: {flex: 1},
mapContainer: {height: 200},
error: {color: '#dc2626', fontSize: 15},
loadingText: {color: '#6b7280', fontSize: 14, marginTop: 10},
inputRow: {flexDirection: 'row', gap: 10, marginBottom: 15, width: '100%', paddingHorizontal: 20},
@@ -454,6 +500,8 @@ const styles = StyleSheet.create({
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
color: '#1f2937',
backgroundColor: '#ffffff',
},
joinButton: {backgroundColor: '#2563eb', borderColor: '#2563eb', flex: 0, paddingHorizontal: 20},
joinLabel: {color: '#ffffff', fontWeight: '600'},
@@ -466,9 +514,32 @@ const styles = StyleSheet.create({
paddingVertical: 8,
fontSize: 14,
},
createCacheWrap: {paddingHorizontal: 12, paddingVertical: 8},
createCacheButton: {backgroundColor: '#2563eb', borderColor: '#2563eb'},
expandButton: {
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'},
cacheListWrap: {flex: 1, backgroundColor: '#f3f4f6'},
cacheListContent: {paddingHorizontal: 12, paddingTop: 10, paddingBottom: 12},
// Cache editor form
formSection: {flex: 1, paddingHorizontal: 12, paddingVertical: 10},
formInput: {
borderWidth: 1,
@@ -477,6 +548,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
color: '#1f2937',
backgroundColor: '#ffffff',
marginBottom: 10,
},
@@ -487,13 +559,22 @@ const styles = StyleSheet.create({
saveLabel: {color: '#ffffff', fontWeight: '600'},
cancelButton: {backgroundColor: '#6b7280', borderColor: '#6b7280'},
cancelLabel: {color: '#ffffff', fontWeight: '600'},
cacheSection: {
maxHeight: 160,
backgroundColor: '#f3f4f6',
paddingHorizontal: 12,
paddingTop: 10,
},
// Player cache section
cacheSection: {flex: 1, backgroundColor: '#f3f4f6', paddingHorizontal: 12, paddingTop: 10},
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;
+42 -9
View File
@@ -10,7 +10,7 @@ const PlayersScreen = () => {
// Initialisation ------------
const session = getSession();
const {getLobby, getTeams, getTeamMembers, getGroupMembers, getUser, getCaches} = useGameHook();
const {getLobby, getTeams, getTeamMembers, getGroupMembers, removeMember, getUser, getCaches} = useGameHook();
// State ----------------------
@@ -35,17 +35,15 @@ const PlayersScreen = () => {
setGroupInfo(group);
if (group && group.TeamsEnabled) {
// Teams enabled — load all teams for this group
const teamRows = await getTeams(session.currentGid);
setTeams(teamRows || []);
} else {
// Teams disabled — load all members for this group
const memberships = await getGroupMembers(session.currentGid);
const nonAdminMembers = (memberships || []).filter((m) => !m.IsAcceptedAdmin);
const allMembers = await getGroupMembers(session.currentGid);
const nonAdminMembers = (allMembers || []).filter((m) => !m.IsAcceptedAdmin);
const playerList = [];
for (const m of nonAdminMembers) {
const user = await getUser(m.Uid);
if (user) playerList.push(user);
if (user) playerList.push({...user, membershipId: m.id});
}
setPlayers(playerList);
}
@@ -58,7 +56,6 @@ const PlayersScreen = () => {
setSelectedTeam(team);
setDetailLoading(true);
// Load team members and their usernames
const ms = await getTeamMembers(team.Tid);
setTeamMembers(ms || []);
@@ -69,7 +66,6 @@ const PlayersScreen = () => {
}
setMemberNames(names);
// Load all caches to count individual contributions
const caches = await getCaches(session.currentGid, null);
const counts = {};
for (const m of (ms || [])) {
@@ -86,6 +82,21 @@ const PlayersScreen = () => {
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 -----------------------
if (loading) {
@@ -130,6 +141,14 @@ const PlayersScreen = () => {
{memberCacheCounts[m.Uid] || 0} cache(s) claimed
</Text>
</View>
<View style={styles.removeWrap}>
<Button
label="Remove"
onClick={() => handleRemoveTeamMember(m)}
styleButton={styles.removeButton}
styleLabel={styles.removeLabel}
/>
</View>
</Card>
))}
{teamMembers.length === 0 && (
@@ -185,7 +204,17 @@ const PlayersScreen = () => {
<ScrollView style={styles.listSection}>
{players.map((player) => (
<Card key={player.Uid}>
<View style={styles.memberRow}>
<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>
))}
{players.length === 0 && (
@@ -204,7 +233,7 @@ const styles = StyleSheet.create({
fontSize: 20,
fontWeight: '700',
color: '#1f2937',
marginBottom: 10,
marginBottom: 12,
paddingHorizontal: 15,
paddingTop: 15,
},
@@ -233,6 +262,10 @@ const styles = StyleSheet.create({
},
memberName: {fontSize: 16, fontWeight: '600', color: '#1f2937'},
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},
backButton: {backgroundColor: '#6b7280', borderColor: '#6b7280'},
backLabel: {color: '#ffffff', fontWeight: '600'},
+6
View File
@@ -42,6 +42,7 @@ const RegisterScreen = ({navigation}) => {
<TextInput
style={styles.input}
placeholder="Username"
placeholderTextColor="#9ca3af"
value={username}
onChangeText={setUsername}
editable={!isLoading}
@@ -49,6 +50,7 @@ const RegisterScreen = ({navigation}) => {
<TextInput
style={styles.input}
placeholder="Email"
placeholderTextColor="#9ca3af"
autoCapitalize="none"
keyboardType="email-address"
value={email}
@@ -58,6 +60,7 @@ const RegisterScreen = ({navigation}) => {
<TextInput
style={styles.input}
placeholder="Password"
placeholderTextColor="#9ca3af"
secureTextEntry
value={password}
onChangeText={setPassword}
@@ -66,6 +69,7 @@ const RegisterScreen = ({navigation}) => {
<TextInput
style={styles.input}
placeholder="Confirm Password"
placeholderTextColor="#9ca3af"
secureTextEntry
value={confirmPassword}
onChangeText={setConfirmPassword}
@@ -132,6 +136,8 @@ const styles = StyleSheet.create({
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
color: '#1f2937',
backgroundColor: '#ffffff',
},
typeRow: {
flexDirection: 'row',
+2
View File
@@ -160,6 +160,8 @@ const styles = StyleSheet.create({
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
color: '#1f2937',
backgroundColor: '#ffffff',
},
joinButton: {backgroundColor: '#2563eb', borderColor: '#2563eb', flex: 0, paddingHorizontal: 20},
joinLabel: {color: '#ffffff', fontWeight: '600'},
+6
View File
@@ -69,6 +69,11 @@ const useGameHook = () => {
return response.isSuccess ? response.result : [];
};
const removeMember = async (membershipId) => {
const response = await API.delete(`${subgroupMembershipsEndpoint}/${membershipId}`);
return response.isSuccess;
};
const joinAsAdmin = async (payload) => {
const response = await API.post(adminWaitlistEndpoint, payload);
return response.isSuccess ? response.result : null;
@@ -156,6 +161,7 @@ const useGameHook = () => {
joinPrivateGame,
joinAsAdmin,
getGroupMembers,
removeMember,
getTeams,
getTeam,
createTeam,
+7 -1
View File
@@ -1,5 +1,11 @@
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 distance = geolib.getDistance(playerCoords, cacheCoords);
return distance <= radius;
@@ -14,5 +20,5 @@ const isLookingAtCache = (playerHeading, playerCoords, cacheCoords, tolerance =
return diff <= tolerance || diff >= 360 - tolerance;
};
export { isWithinRadius, isLookingAtCache };
export { toHeading, isWithinRadius, isLookingAtCache };