mirror of
https://github.com/mudabbir-ahmad/MAD-Treasure-Hunt.git
synced 2026-10-08 03:00:20 +00:00
Merge branch 'main' into global-game
This commit is contained in:
30 files changed
+2445
-1237
No files matched your search
@@ -9,6 +9,7 @@ import TeamScreen from "./src/components/screens/TeamScreen";
|
|||||||
import MapScreen from "./src/components/screens/MapScreen";
|
import MapScreen from "./src/components/screens/MapScreen";
|
||||||
import LeaderboardScreen from "./src/components/screens/LeaderboardScreen";
|
import LeaderboardScreen from "./src/components/screens/LeaderboardScreen";
|
||||||
import GameSettingsScreen from "./src/components/screens/GameSettingsScreen";
|
import GameSettingsScreen from "./src/components/screens/GameSettingsScreen";
|
||||||
|
import DepartmentSettingsScreen from "./src/components/screens/DepartmentSettingsScreen";
|
||||||
import PlayersScreen from "./src/components/screens/PlayersScreen";
|
import PlayersScreen from "./src/components/screens/PlayersScreen";
|
||||||
import ExpandedMapScreen from "./src/components/screens/ExpandedMapScreen";
|
import ExpandedMapScreen from "./src/components/screens/ExpandedMapScreen";
|
||||||
import GameSelectionScreen from "./src/components/screens/GameSelectionScreen";
|
import GameSelectionScreen from "./src/components/screens/GameSelectionScreen";
|
||||||
@@ -105,6 +106,15 @@ const AdminStack = () => (
|
|||||||
component={GlobalLeaderboardScreen}
|
component={GlobalLeaderboardScreen}
|
||||||
/>
|
/>
|
||||||
</AdminStackNav.Navigator>
|
</AdminStackNav.Navigator>
|
||||||
|
<AdminStackNav.Navigator screenOptions={screenOptions}>
|
||||||
|
<AdminStackNav.Screen name="GameSettingsScreen" component={GameSettingsScreen}/>
|
||||||
|
<AdminStackNav.Screen name="DepartmentSettingsScreen" component={DepartmentSettingsScreen}/>
|
||||||
|
<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="TeamScreen" component={TeamScreen}/>
|
||||||
|
</AdminStackNav.Navigator>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const App = () => {
|
export const App = () => {
|
||||||
|
|||||||
@@ -6,12 +6,32 @@ Treasure Hunt mobile app built with React Native and Expo.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm i
|
npm i
|
||||||
npx expo start --android
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- The app accesses game/auth data through the centralized API endpoint via `src/components/API/API.js`.
|
- The app accesses game/auth data through the centralized API endpoint via `src/components/API/API.js`.
|
||||||
- Navigation is handled with React Navigation native stack.
|
- IF encountering network issues, ensure the API endpoint is set to 'http://api.bobby.ip-ddns.com' and not 'http://192.168.8.100:3000'.
|
||||||
- Sometimes you might have to switch the API Endpoint URL. This is just Because of the fact that when I'm using the app I can't access the API endpoint, from the global URL. Therefore, I need my local API Server's IP as the URL
|
- The reason for 'http://192.168.8.100:3000' is due to the API server being on my (Mudabbir's) home network. I need to use local ip due to loop back restrictions with home network(needed for security).
|
||||||
- Just Uncomment the `API_ENDPOINT` variable in `src/components/API/API.js` to use the actual API Server Which is `api.bobby.ip-ddns.com` [Do not add any port thing here as it uses port 80 by default].
|
- Just uncomment line 1 in `src/components/API/API.js` to use the actual API Server Which is `api.bobby.ip-ddns.com` [Do not add any port thing here as it uses port 80 by default].
|
||||||
|
|
||||||
|
|
||||||
|
# DB Structure:
|
||||||
|
|
||||||
|
API endpoint: `http://api.bobby.ip-ddns.com/`
|
||||||
|
|
||||||
|
### Tables:
|
||||||
|
- `users`
|
||||||
|
- `teams`
|
||||||
|
- `team-members`
|
||||||
|
- `subgroups`
|
||||||
|
- `subgroup-members`
|
||||||
|
- `groups`
|
||||||
|
- `game-types`
|
||||||
|
- `db`
|
||||||
|
- `caches`
|
||||||
|
- `admin-waitlist`
|
||||||
|
|
||||||
|
#### Append Table to API URL to view table's json data on browser.
|
||||||
|
I.e: `http://api.bobby.ip-ddns.com/users` or `http://api.bobby.ip-ddns.com/subgroups-members`
|
||||||
@@ -16,6 +16,9 @@
|
|||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"NSLocationWhenInUseUsageDescription": "This app needs your location for the treasure hunt."
|
"NSLocationWhenInUseUsageDescription": "This app needs your location for the treasure hunt."
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"googleMapsApiKey": "AIzaSyA-G0tSzzcM5z6HyI23s8dzKDLCOrekhbw"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "mad-treasure-hunt-server",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "RESTful CRUDL API server for MAD Treasure Hunt",
|
|
||||||
"private": true,
|
|
||||||
"main": "server.js",
|
|
||||||
"scripts": {
|
|
||||||
"start": "node server.js",
|
|
||||||
"dev": "node --watch server.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,770 +0,0 @@
|
|||||||
const http = require('http');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// --- Configuration ---
|
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
|
||||||
const DATA_DIR = path.join(__dirname, 'data');
|
|
||||||
|
|
||||||
// --- Data Helpers ---
|
|
||||||
|
|
||||||
const readJson = (fileName) => JSON.parse(fs.readFileSync(path.join(DATA_DIR, fileName), 'utf-8'));
|
|
||||||
const writeJson = (fileName, data) => fs.writeFileSync(path.join(DATA_DIR, fileName), JSON.stringify(data, null, 2));
|
|
||||||
|
|
||||||
// --- Utility Helpers ---
|
|
||||||
|
|
||||||
const hashPassword = (value) => {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < value.length; i += 1) {
|
|
||||||
hash = (hash << 5) - hash + value.charCodeAt(i);
|
|
||||||
hash |= 0;
|
|
||||||
}
|
|
||||||
return `H_${Math.abs(hash)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextId = (rows, key) => rows.reduce((max, row) => {
|
|
||||||
const val = Number(row[key] || 0);
|
|
||||||
return val > max ? val : max;
|
|
||||||
}, 0) + 1;
|
|
||||||
|
|
||||||
const generateCode = (len = 5) => {
|
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
||||||
let code = '';
|
|
||||||
for (let i = 0; i < len; i++) code += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
||||||
return code;
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Request / Response Helpers ---
|
|
||||||
|
|
||||||
const parseBody = (req) => new Promise((resolve, reject) => {
|
|
||||||
let body = '';
|
|
||||||
req.on('data', (chunk) => { body += chunk.toString(); });
|
|
||||||
req.on('end', () => {
|
|
||||||
try { resolve(body ? JSON.parse(body) : {}); }
|
|
||||||
catch { reject(new Error('Invalid JSON body')); }
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
const sendJson = (res, statusCode, payload) => {
|
|
||||||
const headers = {
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS',
|
|
||||||
'Access-Control-Allow-Headers': 'Content-Type',
|
|
||||||
};
|
|
||||||
if (statusCode === 204) {
|
|
||||||
res.writeHead(204, headers);
|
|
||||||
res.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
headers['Content-Type'] = 'application/json';
|
|
||||||
res.writeHead(statusCode, headers);
|
|
||||||
res.end(JSON.stringify(payload));
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Simple Router ---
|
|
||||||
|
|
||||||
const routes = [];
|
|
||||||
|
|
||||||
const addRoute = (method, pattern, handler) => {
|
|
||||||
const keys = [];
|
|
||||||
const regexStr = pattern.replace(/:([a-zA-Z]+)/g, (_, key) => {
|
|
||||||
keys.push(key);
|
|
||||||
return '([^/]+)';
|
|
||||||
});
|
|
||||||
routes.push({ method, regex: new RegExp(`^${regexStr}$`), keys, handler });
|
|
||||||
};
|
|
||||||
|
|
||||||
const matchRoute = (method, pathname) => {
|
|
||||||
for (const r of routes) {
|
|
||||||
if (r.method !== method) continue;
|
|
||||||
const match = pathname.match(r.regex);
|
|
||||||
if (match) {
|
|
||||||
const params = {};
|
|
||||||
r.keys.forEach((key, i) => { params[key] = match[i + 1]; });
|
|
||||||
return { handler: r.handler, params };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ======================================================================
|
|
||||||
// AUTH ROUTES
|
|
||||||
// ======================================================================
|
|
||||||
|
|
||||||
addRoute('POST', '/auth/login', async (req, res) => {
|
|
||||||
const payload = await parseBody(req);
|
|
||||||
const users = readJson('users.json');
|
|
||||||
const user = users.find((u) => u.email.toLowerCase() === String(payload.email || '').toLowerCase());
|
|
||||||
if (!user) return sendJson(res, 400, { message: 'Email not found' });
|
|
||||||
if (user.passwordHash !== hashPassword(String(payload.password || '')))
|
|
||||||
return sendJson(res, 400, { message: 'Invalid password' });
|
|
||||||
sendJson(res, 200, user);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('POST', '/auth/register', async (req, res) => {
|
|
||||||
const payload = await parseBody(req);
|
|
||||||
const username = String(payload.username || '').trim();
|
|
||||||
const email = String(payload.email || '').trim().toLowerCase();
|
|
||||||
const password = String(payload.password || '');
|
|
||||||
const confirmPassword = String(payload.confirmPassword || '');
|
|
||||||
const accountType = String(payload.accountType || 'Individual');
|
|
||||||
|
|
||||||
if (!username || !email || !password || !confirmPassword)
|
|
||||||
return sendJson(res, 400, { message: 'Missing required registration fields' });
|
|
||||||
if (password !== confirmPassword)
|
|
||||||
return sendJson(res, 400, { message: 'Passwords do not match' });
|
|
||||||
|
|
||||||
const users = readJson('users.json');
|
|
||||||
if (users.some((u) => u.email.toLowerCase() === email))
|
|
||||||
return sendJson(res, 400, { message: 'Email already in use' });
|
|
||||||
|
|
||||||
const user = {
|
|
||||||
Uid: nextId(users, 'Uid'),
|
|
||||||
username,
|
|
||||||
email,
|
|
||||||
passwordHash: hashPassword(password),
|
|
||||||
isBusiness: accountType === 'Business/School',
|
|
||||||
IsAcceptedAdmin: false,
|
|
||||||
Gid: null,
|
|
||||||
SGid: null,
|
|
||||||
TGid: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
users.push(user);
|
|
||||||
writeJson('users.json', users);
|
|
||||||
sendJson(res, 201, user);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ======================================================================
|
|
||||||
// USERS CRUDL
|
|
||||||
// ======================================================================
|
|
||||||
|
|
||||||
addRoute('GET', '/users', (req, res) => {
|
|
||||||
sendJson(res, 200, readJson('users.json'));
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('GET', '/users/:id', (req, res, params) => {
|
|
||||||
const users = readJson('users.json');
|
|
||||||
const user = users.find((u) => String(u.Uid) === params.id);
|
|
||||||
if (!user) return sendJson(res, 404, { message: 'User not found' });
|
|
||||||
sendJson(res, 200, user);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('PUT', '/users/:id', async (req, res, params) => {
|
|
||||||
const payload = await parseBody(req);
|
|
||||||
const users = readJson('users.json');
|
|
||||||
const idx = users.findIndex((u) => String(u.Uid) === params.id);
|
|
||||||
if (idx === -1) return sendJson(res, 404, { message: 'User not found' });
|
|
||||||
users[idx] = { ...users[idx], ...payload, Uid: users[idx].Uid };
|
|
||||||
writeJson('users.json', users);
|
|
||||||
sendJson(res, 200, users[idx]);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('DELETE', '/users/:id', (req, res, params) => {
|
|
||||||
const users = readJson('users.json');
|
|
||||||
const idx = users.findIndex((u) => String(u.Uid) === params.id);
|
|
||||||
if (idx === -1) return sendJson(res, 404, { message: 'User not found' });
|
|
||||||
users.splice(idx, 1);
|
|
||||||
writeJson('users.json', users);
|
|
||||||
sendJson(res, 204, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ======================================================================
|
|
||||||
// GAME TYPES (read-only list)
|
|
||||||
// ======================================================================
|
|
||||||
|
|
||||||
addRoute('GET', '/game-types', (req, res) => {
|
|
||||||
sendJson(res, 200, readJson('game-types.json'));
|
|
||||||
});
|
|
||||||
|
|
||||||
// ======================================================================
|
|
||||||
// GROUPS CRUDL
|
|
||||||
// ======================================================================
|
|
||||||
|
|
||||||
addRoute('GET', '/groups', (req, res, params, query) => {
|
|
||||||
let groups = readJson('groups.json');
|
|
||||||
if (query.CreatedByUid) groups = groups.filter((g) => String(g.CreatedByUid) === query.CreatedByUid);
|
|
||||||
sendJson(res, 200, groups);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('GET', '/groups/:gid', (req, res, params) => {
|
|
||||||
const groups = readJson('groups.json');
|
|
||||||
const group = groups.find((g) => String(g.Gid) === params.gid);
|
|
||||||
if (!group) return sendJson(res, 404, { message: 'Group not found' });
|
|
||||||
sendJson(res, 200, group);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('POST', '/groups', async (req, res) => {
|
|
||||||
const payload = await parseBody(req);
|
|
||||||
const groups = readJson('groups.json');
|
|
||||||
|
|
||||||
const group = {
|
|
||||||
Gid: nextId(groups, 'Gid'),
|
|
||||||
GroupName: payload.GroupName || 'Unnamed Group',
|
|
||||||
BusinessOrSchoolName: payload.BusinessOrSchoolName || null,
|
|
||||||
GameType: payload.GameType || 'private',
|
|
||||||
AdminJoinCode: generateCode(),
|
|
||||||
CreatedByUid: payload.CreatedByUid,
|
|
||||||
MaxMemberSubgroups: payload.MaxMemberSubgroups || 1,
|
|
||||||
TeamsEnabled: payload.TeamsEnabled !== undefined ? payload.TeamsEnabled : true,
|
|
||||||
CacheTriggerMeters: payload.CacheTriggerMeters || 20,
|
|
||||||
ApprovedAdmins: [payload.CreatedByUid],
|
|
||||||
CreatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
groups.push(group);
|
|
||||||
writeJson('groups.json', groups);
|
|
||||||
|
|
||||||
// Create default admin and member subgroups
|
|
||||||
const subgroups = readJson('subgroups.json');
|
|
||||||
const adminSg = {
|
|
||||||
SGid: nextId(subgroups, 'SGid'),
|
|
||||||
SubGroupName: 'Admins',
|
|
||||||
Gid: group.Gid,
|
|
||||||
JoinCode: null,
|
|
||||||
IsAdminGroup: true,
|
|
||||||
CacheTriggerMeters: 20,
|
|
||||||
};
|
|
||||||
subgroups.push(adminSg);
|
|
||||||
|
|
||||||
const memberSg = {
|
|
||||||
SGid: nextId(subgroups, 'SGid'),
|
|
||||||
SubGroupName: 'Members',
|
|
||||||
Gid: group.Gid,
|
|
||||||
JoinCode: generateCode(),
|
|
||||||
IsAdminGroup: false,
|
|
||||||
CacheTriggerMeters: 20,
|
|
||||||
};
|
|
||||||
subgroups.push(memberSg);
|
|
||||||
writeJson('subgroups.json', subgroups);
|
|
||||||
|
|
||||||
// Add creator as admin membership
|
|
||||||
const memberships = readJson('subgroup-memberships.json');
|
|
||||||
memberships.push({
|
|
||||||
id: nextId(memberships, 'id'),
|
|
||||||
Uid: payload.CreatedByUid,
|
|
||||||
Gid: group.Gid,
|
|
||||||
SGid: adminSg.SGid,
|
|
||||||
IsAcceptedAdmin: true,
|
|
||||||
});
|
|
||||||
writeJson('subgroup-memberships.json', memberships);
|
|
||||||
|
|
||||||
// Initialise empty game-data entry
|
|
||||||
const gameData = readJson('game-data.json');
|
|
||||||
gameData.push({ Gid: group.Gid, GeoCaches: [] });
|
|
||||||
writeJson('game-data.json', gameData);
|
|
||||||
|
|
||||||
// Update the creating user
|
|
||||||
const users = readJson('users.json');
|
|
||||||
const uIdx = users.findIndex((u) => u.Uid === payload.CreatedByUid);
|
|
||||||
if (uIdx !== -1) {
|
|
||||||
users[uIdx].Gid = group.Gid;
|
|
||||||
users[uIdx].SGid = adminSg.SGid;
|
|
||||||
users[uIdx].IsAcceptedAdmin = true;
|
|
||||||
writeJson('users.json', users);
|
|
||||||
}
|
|
||||||
|
|
||||||
sendJson(res, 201, group);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('PUT', '/groups/:gid', async (req, res, params) => {
|
|
||||||
const payload = await parseBody(req);
|
|
||||||
const groups = readJson('groups.json');
|
|
||||||
const idx = groups.findIndex((g) => String(g.Gid) === params.gid);
|
|
||||||
if (idx === -1) return sendJson(res, 404, { message: 'Group not found' });
|
|
||||||
groups[idx] = { ...groups[idx], ...payload, Gid: groups[idx].Gid };
|
|
||||||
writeJson('groups.json', groups);
|
|
||||||
sendJson(res, 200, groups[idx]);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('DELETE', '/groups/:gid', (req, res, params) => {
|
|
||||||
const groups = readJson('groups.json');
|
|
||||||
const idx = groups.findIndex((g) => String(g.Gid) === params.gid);
|
|
||||||
if (idx === -1) return sendJson(res, 404, { message: 'Group not found' });
|
|
||||||
groups.splice(idx, 1);
|
|
||||||
writeJson('groups.json', groups);
|
|
||||||
sendJson(res, 204, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ======================================================================
|
|
||||||
// SUBGROUPS CRUDL
|
|
||||||
// ======================================================================
|
|
||||||
|
|
||||||
addRoute('GET', '/subgroups', (req, res, params, query) => {
|
|
||||||
let rows = readJson('subgroups.json');
|
|
||||||
if (query.Gid) rows = rows.filter((r) => String(r.Gid) === query.Gid);
|
|
||||||
sendJson(res, 200, rows);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('GET', '/subgroups/:id', (req, res, params) => {
|
|
||||||
const rows = readJson('subgroups.json');
|
|
||||||
const row = rows.find((r) => String(r.SGid) === params.id);
|
|
||||||
if (!row) return sendJson(res, 404, { message: 'Subgroup not found' });
|
|
||||||
sendJson(res, 200, row);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('POST', '/subgroups', async (req, res) => {
|
|
||||||
const payload = await parseBody(req);
|
|
||||||
const rows = readJson('subgroups.json');
|
|
||||||
payload.SGid = nextId(rows, 'SGid');
|
|
||||||
if (!payload.JoinCode) payload.JoinCode = generateCode();
|
|
||||||
rows.push(payload);
|
|
||||||
writeJson('subgroups.json', rows);
|
|
||||||
sendJson(res, 201, payload);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('PUT', '/subgroups/:id', async (req, res, params) => {
|
|
||||||
const payload = await parseBody(req);
|
|
||||||
const rows = readJson('subgroups.json');
|
|
||||||
const idx = rows.findIndex((r) => String(r.SGid) === params.id);
|
|
||||||
if (idx === -1) return sendJson(res, 404, { message: 'Subgroup not found' });
|
|
||||||
rows[idx] = { ...rows[idx], ...payload, SGid: rows[idx].SGid };
|
|
||||||
writeJson('subgroups.json', rows);
|
|
||||||
sendJson(res, 200, rows[idx]);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('DELETE', '/subgroups/:id', (req, res, params) => {
|
|
||||||
const rows = readJson('subgroups.json');
|
|
||||||
const idx = rows.findIndex((r) => String(r.SGid) === params.id);
|
|
||||||
if (idx === -1) return sendJson(res, 404, { message: 'Subgroup not found' });
|
|
||||||
rows.splice(idx, 1);
|
|
||||||
writeJson('subgroups.json', rows);
|
|
||||||
sendJson(res, 204, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ======================================================================
|
|
||||||
// SUBGROUP MEMBERSHIPS CRUDL
|
|
||||||
// ======================================================================
|
|
||||||
|
|
||||||
addRoute('GET', '/subgroup-memberships', (req, res, params, query) => {
|
|
||||||
let rows = readJson('subgroup-memberships.json');
|
|
||||||
if (query.Gid) rows = rows.filter((r) => String(r.Gid) === query.Gid);
|
|
||||||
if (query.Uid) rows = rows.filter((r) => String(r.Uid) === query.Uid);
|
|
||||||
if (query.SGid) rows = rows.filter((r) => String(r.SGid) === query.SGid);
|
|
||||||
sendJson(res, 200, rows);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('GET', '/subgroup-memberships/:id', (req, res, params) => {
|
|
||||||
const rows = readJson('subgroup-memberships.json');
|
|
||||||
const row = rows.find((r) => String(r.id) === params.id);
|
|
||||||
if (!row) return sendJson(res, 404, { message: 'Membership not found' });
|
|
||||||
sendJson(res, 200, row);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('POST', '/subgroup-memberships', async (req, res) => {
|
|
||||||
const payload = await parseBody(req);
|
|
||||||
const rows = readJson('subgroup-memberships.json');
|
|
||||||
|
|
||||||
// If a JoinCode was supplied, look up the matching subgroup
|
|
||||||
if (payload.JoinCode) {
|
|
||||||
const subgroups = readJson('subgroups.json');
|
|
||||||
const sg = subgroups.find((s) => s.JoinCode === String(payload.JoinCode).toUpperCase());
|
|
||||||
if (!sg) return sendJson(res, 400, { message: 'Invalid join code' });
|
|
||||||
payload.Gid = sg.Gid;
|
|
||||||
payload.SGid = sg.SGid;
|
|
||||||
}
|
|
||||||
|
|
||||||
payload.id = nextId(rows, 'id');
|
|
||||||
payload.IsAcceptedAdmin = payload.IsAcceptedAdmin || false;
|
|
||||||
rows.push(payload);
|
|
||||||
writeJson('subgroup-memberships.json', rows);
|
|
||||||
|
|
||||||
// Update user record with group / subgroup
|
|
||||||
const users = readJson('users.json');
|
|
||||||
const uIdx = users.findIndex((u) => u.Uid === payload.Uid);
|
|
||||||
if (uIdx !== -1) {
|
|
||||||
users[uIdx].Gid = payload.Gid;
|
|
||||||
users[uIdx].SGid = payload.SGid;
|
|
||||||
writeJson('users.json', users);
|
|
||||||
}
|
|
||||||
|
|
||||||
sendJson(res, 201, payload);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('PUT', '/subgroup-memberships/:id', async (req, res, params) => {
|
|
||||||
const payload = await parseBody(req);
|
|
||||||
const rows = readJson('subgroup-memberships.json');
|
|
||||||
const idx = rows.findIndex((r) => String(r.id) === params.id);
|
|
||||||
if (idx === -1) return sendJson(res, 404, { message: 'Membership not found' });
|
|
||||||
rows[idx] = { ...rows[idx], ...payload, id: rows[idx].id };
|
|
||||||
writeJson('subgroup-memberships.json', rows);
|
|
||||||
sendJson(res, 200, rows[idx]);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('DELETE', '/subgroup-memberships/:id', (req, res, params) => {
|
|
||||||
const rows = readJson('subgroup-memberships.json');
|
|
||||||
const idx = rows.findIndex((r) => String(r.id) === params.id);
|
|
||||||
if (idx === -1) return sendJson(res, 404, { message: 'Membership not found' });
|
|
||||||
|
|
||||||
const removed = rows[idx];
|
|
||||||
rows.splice(idx, 1);
|
|
||||||
writeJson('subgroup-memberships.json', rows);
|
|
||||||
|
|
||||||
// Cascade: remove from team if in one
|
|
||||||
const teamMembers = readJson('team-members.json');
|
|
||||||
const tmIdx = teamMembers.findIndex((r) => r.Uid === removed.Uid);
|
|
||||||
if (tmIdx !== -1) {
|
|
||||||
const tmRemoved = teamMembers[tmIdx];
|
|
||||||
teamMembers.splice(tmIdx, 1);
|
|
||||||
// Transfer leadership to longest-serving member if leader left
|
|
||||||
if (tmRemoved.IsLeader) {
|
|
||||||
const remaining = teamMembers.filter((r) => r.Tid === tmRemoved.Tid);
|
|
||||||
if (remaining.length > 0) {
|
|
||||||
remaining.sort((a, b) => new Date(a.JoinedAt || 0) - new Date(b.JoinedAt || 0));
|
|
||||||
remaining[0].IsLeader = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeJson('team-members.json', teamMembers);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear user game data
|
|
||||||
const users = readJson('users.json');
|
|
||||||
const uIdx = users.findIndex((u) => u.Uid === removed.Uid);
|
|
||||||
if (uIdx !== -1) {
|
|
||||||
users[uIdx].Gid = null;
|
|
||||||
users[uIdx].SGid = null;
|
|
||||||
users[uIdx].TGid = null;
|
|
||||||
users[uIdx].IsAcceptedAdmin = false;
|
|
||||||
writeJson('users.json', users);
|
|
||||||
}
|
|
||||||
|
|
||||||
sendJson(res, 204, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ======================================================================
|
|
||||||
// TEAMS CRUDL
|
|
||||||
// ======================================================================
|
|
||||||
|
|
||||||
addRoute('GET', '/teams', (req, res, params, query) => {
|
|
||||||
let rows = readJson('teams.json');
|
|
||||||
if (query.Gid) rows = rows.filter((r) => String(r.Gid) === query.Gid);
|
|
||||||
sendJson(res, 200, rows);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('GET', '/teams/:id', (req, res, params) => {
|
|
||||||
const rows = readJson('teams.json');
|
|
||||||
const row = rows.find((r) => String(r.Tid) === params.id);
|
|
||||||
if (!row) return sendJson(res, 404, { message: 'Team not found' });
|
|
||||||
sendJson(res, 200, row);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('POST', '/teams', async (req, res) => {
|
|
||||||
const payload = await parseBody(req);
|
|
||||||
const rows = readJson('teams.json');
|
|
||||||
payload.Tid = nextId(rows, 'Tid');
|
|
||||||
if (!payload.JoinCode) payload.JoinCode = generateCode();
|
|
||||||
rows.push(payload);
|
|
||||||
writeJson('teams.json', rows);
|
|
||||||
sendJson(res, 201, payload);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('PUT', '/teams/:id', async (req, res, params) => {
|
|
||||||
const payload = await parseBody(req);
|
|
||||||
const rows = readJson('teams.json');
|
|
||||||
const idx = rows.findIndex((r) => String(r.Tid) === params.id);
|
|
||||||
if (idx === -1) return sendJson(res, 404, { message: 'Team not found' });
|
|
||||||
rows[idx] = { ...rows[idx], ...payload, Tid: rows[idx].Tid };
|
|
||||||
writeJson('teams.json', rows);
|
|
||||||
sendJson(res, 200, rows[idx]);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('DELETE', '/teams/:id', (req, res, params) => {
|
|
||||||
const rows = readJson('teams.json');
|
|
||||||
const idx = rows.findIndex((r) => String(r.Tid) === params.id);
|
|
||||||
if (idx === -1) return sendJson(res, 404, { message: 'Team not found' });
|
|
||||||
rows.splice(idx, 1);
|
|
||||||
writeJson('teams.json', rows);
|
|
||||||
sendJson(res, 204, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ======================================================================
|
|
||||||
// TEAM MEMBERS CRUDL
|
|
||||||
// ======================================================================
|
|
||||||
|
|
||||||
addRoute('GET', '/team-members', (req, res, params, query) => {
|
|
||||||
let rows = readJson('team-members.json');
|
|
||||||
if (query.Tid) rows = rows.filter((r) => String(r.Tid) === query.Tid);
|
|
||||||
if (query.Uid) rows = rows.filter((r) => String(r.Uid) === query.Uid);
|
|
||||||
sendJson(res, 200, rows);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('GET', '/team-members/:id', (req, res, params) => {
|
|
||||||
const rows = readJson('team-members.json');
|
|
||||||
const row = rows.find((r) => String(r.id) === params.id);
|
|
||||||
if (!row) return sendJson(res, 404, { message: 'Team member not found' });
|
|
||||||
sendJson(res, 200, row);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('POST', '/team-members', async (req, res) => {
|
|
||||||
const payload = await parseBody(req);
|
|
||||||
const rows = readJson('team-members.json');
|
|
||||||
|
|
||||||
// If a JoinCode was supplied, look up the matching team
|
|
||||||
if (payload.JoinCode) {
|
|
||||||
const teams = readJson('teams.json');
|
|
||||||
const team = teams.find((t) => t.JoinCode === String(payload.JoinCode).toUpperCase());
|
|
||||||
if (!team) return sendJson(res, 400, { message: 'Invalid team join code' });
|
|
||||||
payload.Tid = team.Tid;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-set leader if first member in team
|
|
||||||
const existing = rows.filter((r) => r.Tid === payload.Tid);
|
|
||||||
payload.id = nextId(rows, 'id');
|
|
||||||
payload.JoinedAt = new Date().toISOString();
|
|
||||||
payload.IsLeader = existing.length === 0;
|
|
||||||
rows.push(payload);
|
|
||||||
writeJson('team-members.json', rows);
|
|
||||||
|
|
||||||
// Update user TGid
|
|
||||||
const users = readJson('users.json');
|
|
||||||
const uIdx = users.findIndex((u) => u.Uid === payload.Uid);
|
|
||||||
if (uIdx !== -1) {
|
|
||||||
users[uIdx].TGid = payload.Tid;
|
|
||||||
writeJson('users.json', users);
|
|
||||||
}
|
|
||||||
|
|
||||||
sendJson(res, 201, payload);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('PUT', '/team-members/:id', async (req, res, params) => {
|
|
||||||
const payload = await parseBody(req);
|
|
||||||
const rows = readJson('team-members.json');
|
|
||||||
const idx = rows.findIndex((r) => String(r.id) === params.id);
|
|
||||||
if (idx === -1) return sendJson(res, 404, { message: 'Team member not found' });
|
|
||||||
rows[idx] = { ...rows[idx], ...payload, id: rows[idx].id };
|
|
||||||
writeJson('team-members.json', rows);
|
|
||||||
sendJson(res, 200, rows[idx]);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('DELETE', '/team-members/:id', (req, res, params) => {
|
|
||||||
const rows = readJson('team-members.json');
|
|
||||||
const idx = rows.findIndex((r) => String(r.id) === params.id);
|
|
||||||
if (idx === -1) return sendJson(res, 404, { message: 'Team member not found' });
|
|
||||||
|
|
||||||
const removed = rows[idx];
|
|
||||||
rows.splice(idx, 1);
|
|
||||||
|
|
||||||
// Transfer leadership to longest-serving member if leader left
|
|
||||||
if (removed.IsLeader) {
|
|
||||||
const remaining = rows.filter((r) => r.Tid === removed.Tid);
|
|
||||||
if (remaining.length > 0) {
|
|
||||||
remaining.sort((a, b) => new Date(a.JoinedAt || 0) - new Date(b.JoinedAt || 0));
|
|
||||||
remaining[0].IsLeader = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJson('team-members.json', rows);
|
|
||||||
|
|
||||||
// Clear user TGid
|
|
||||||
const users = readJson('users.json');
|
|
||||||
const uIdx = users.findIndex((u) => u.Uid === removed.Uid);
|
|
||||||
if (uIdx !== -1) {
|
|
||||||
users[uIdx].TGid = null;
|
|
||||||
writeJson('users.json', users);
|
|
||||||
}
|
|
||||||
|
|
||||||
sendJson(res, 204, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ======================================================================
|
|
||||||
// ADMIN WAITLIST CRUDL
|
|
||||||
// ======================================================================
|
|
||||||
|
|
||||||
addRoute('GET', '/admin-waitlist', (req, res, params, query) => {
|
|
||||||
let rows = readJson('admin-waitlist.json');
|
|
||||||
if (query.Gid) rows = rows.filter((r) => String(r.Gid) === query.Gid);
|
|
||||||
sendJson(res, 200, rows);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('POST', '/admin-waitlist', async (req, res) => {
|
|
||||||
const payload = await parseBody(req);
|
|
||||||
const rows = readJson('admin-waitlist.json');
|
|
||||||
payload.id = nextId(rows, 'id');
|
|
||||||
rows.push(payload);
|
|
||||||
writeJson('admin-waitlist.json', rows);
|
|
||||||
sendJson(res, 201, payload);
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('DELETE', '/admin-waitlist/:id', (req, res, params) => {
|
|
||||||
const rows = readJson('admin-waitlist.json');
|
|
||||||
const idx = rows.findIndex((r) => String(r.id) === params.id);
|
|
||||||
if (idx === -1) return sendJson(res, 404, { message: 'Waitlist entry not found' });
|
|
||||||
rows.splice(idx, 1);
|
|
||||||
writeJson('admin-waitlist.json', rows);
|
|
||||||
sendJson(res, 204, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ======================================================================
|
|
||||||
// GAME DATA CRUDL
|
|
||||||
// ======================================================================
|
|
||||||
|
|
||||||
addRoute('GET', '/game-data', (req, res) => {
|
|
||||||
sendJson(res, 200, readJson('game-data.json'));
|
|
||||||
});
|
|
||||||
|
|
||||||
addRoute('GET', '/game-data/:gid', (req, res, params) => {
|
|
||||||
const rows = readJson('game-data.json');
|
|
||||||
const row = rows.find((r) => String(r.Gid) === params.gid);
|
|
||||||
if (!row) return sendJson(res, 404, { message: 'Game data not found' });
|
|
||||||
sendJson(res, 200, row);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ======================================================================
|
|
||||||
// CACHES (nested under game-data) CRUDL
|
|
||||||
// ======================================================================
|
|
||||||
|
|
||||||
// Helper: convert storage cache to API response format
|
|
||||||
const toCacheApi = (cache, gid) => ({
|
|
||||||
id: cache.CacheId,
|
|
||||||
coordinates: { latitude: cache.Latitude, longitude: cache.Longitude },
|
|
||||||
radius: cache.TriggerMeters,
|
|
||||||
clue: cache.Title,
|
|
||||||
groupId: Number(gid),
|
|
||||||
subgroupId: cache.SGid,
|
|
||||||
ClaimedByUid: cache.ClaimedByUid || null,
|
|
||||||
ClaimedByTid: cache.ClaimedByTid || null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper: convert API payload to storage cache format
|
|
||||||
const toCacheStorage = (data, cacheId) => ({
|
|
||||||
CacheId: cacheId,
|
|
||||||
Title: data.clue || data.Title || '',
|
|
||||||
Latitude: data.latitude != null ? data.latitude : (data.Latitude || 0),
|
|
||||||
Longitude: data.longitude != null ? data.longitude : (data.Longitude || 0),
|
|
||||||
TriggerMeters: data.radius || data.TriggerMeters || 20,
|
|
||||||
SGid: data.subgroupId != null ? data.subgroupId : (data.SGid || null),
|
|
||||||
ClaimedByUid: data.ClaimedByUid || null,
|
|
||||||
ClaimedByTid: data.ClaimedByTid || null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /game-data/:gid/caches — list caches for a group
|
|
||||||
addRoute('GET', '/game-data/:gid/caches', (req, res, params, query) => {
|
|
||||||
const rows = readJson('game-data.json');
|
|
||||||
const entry = rows.find((r) => String(r.Gid) === params.gid);
|
|
||||||
if (!entry) return sendJson(res, 404, { message: 'Game data not found' });
|
|
||||||
|
|
||||||
let caches = (entry.GeoCaches || []).map((c) => toCacheApi(c, params.gid));
|
|
||||||
if (query.SGid) caches = caches.filter((c) => String(c.subgroupId) === query.SGid);
|
|
||||||
sendJson(res, 200, caches);
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /game-data/:gid/caches/:cacheId — read single cache
|
|
||||||
addRoute('GET', '/game-data/:gid/caches/:cacheId', (req, res, params) => {
|
|
||||||
const rows = readJson('game-data.json');
|
|
||||||
const entry = rows.find((r) => String(r.Gid) === params.gid);
|
|
||||||
if (!entry) return sendJson(res, 404, { message: 'Game data not found' });
|
|
||||||
|
|
||||||
const cache = (entry.GeoCaches || []).find((c) => c.CacheId === params.cacheId);
|
|
||||||
if (!cache) return sendJson(res, 404, { message: 'Cache not found' });
|
|
||||||
sendJson(res, 200, toCacheApi(cache, params.gid));
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /game-data/:gid/caches — create a new cache
|
|
||||||
addRoute('POST', '/game-data/:gid/caches', async (req, res, params) => {
|
|
||||||
const payload = await parseBody(req);
|
|
||||||
const rows = readJson('game-data.json');
|
|
||||||
const entry = rows.find((r) => String(r.Gid) === params.gid);
|
|
||||||
if (!entry) return sendJson(res, 404, { message: 'Game data not found' });
|
|
||||||
|
|
||||||
const existingIds = (entry.GeoCaches || []).map((c) => {
|
|
||||||
const num = parseInt(String(c.CacheId).replace(/\D/g, ''), 10);
|
|
||||||
return isNaN(num) ? 0 : num;
|
|
||||||
});
|
|
||||||
const nextNum = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 1;
|
|
||||||
const cacheId = `C${nextNum}`;
|
|
||||||
|
|
||||||
const stored = toCacheStorage(payload, cacheId);
|
|
||||||
if (!entry.GeoCaches) entry.GeoCaches = [];
|
|
||||||
entry.GeoCaches.push(stored);
|
|
||||||
writeJson('game-data.json', rows);
|
|
||||||
sendJson(res, 201, toCacheApi(stored, params.gid));
|
|
||||||
});
|
|
||||||
|
|
||||||
// PUT /game-data/:gid/caches/:cacheId — update a cache
|
|
||||||
addRoute('PUT', '/game-data/:gid/caches/:cacheId', async (req, res, params) => {
|
|
||||||
const payload = await parseBody(req);
|
|
||||||
const rows = readJson('game-data.json');
|
|
||||||
const entry = rows.find((r) => String(r.Gid) === params.gid);
|
|
||||||
if (!entry) return sendJson(res, 404, { message: 'Game data not found' });
|
|
||||||
|
|
||||||
const idx = (entry.GeoCaches || []).findIndex((c) => c.CacheId === params.cacheId);
|
|
||||||
if (idx === -1) return sendJson(res, 404, { message: 'Cache not found' });
|
|
||||||
|
|
||||||
const existing = entry.GeoCaches[idx];
|
|
||||||
const updated = { ...existing, ...toCacheStorage(payload, params.cacheId) };
|
|
||||||
entry.GeoCaches[idx] = updated;
|
|
||||||
writeJson('game-data.json', rows);
|
|
||||||
sendJson(res, 200, toCacheApi(updated, params.gid));
|
|
||||||
});
|
|
||||||
|
|
||||||
// DELETE /game-data/:gid/caches/:cacheId — delete a cache
|
|
||||||
addRoute('DELETE', '/game-data/:gid/caches/:cacheId', (req, res, params) => {
|
|
||||||
const rows = readJson('game-data.json');
|
|
||||||
const entry = rows.find((r) => String(r.Gid) === params.gid);
|
|
||||||
if (!entry) return sendJson(res, 404, { message: 'Game data not found' });
|
|
||||||
|
|
||||||
const idx = (entry.GeoCaches || []).findIndex((c) => c.CacheId === params.cacheId);
|
|
||||||
if (idx === -1) return sendJson(res, 404, { message: 'Cache not found' });
|
|
||||||
|
|
||||||
entry.GeoCaches.splice(idx, 1);
|
|
||||||
writeJson('game-data.json', rows);
|
|
||||||
sendJson(res, 204, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ======================================================================
|
|
||||||
// HEALTH CHECK
|
|
||||||
// ======================================================================
|
|
||||||
|
|
||||||
addRoute('GET', '/health', (req, res) => {
|
|
||||||
sendJson(res, 200, { ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ======================================================================
|
|
||||||
// SERVER
|
|
||||||
// ======================================================================
|
|
||||||
|
|
||||||
const server = http.createServer(async (req, res) => {
|
|
||||||
// CORS preflight
|
|
||||||
if (req.method === 'OPTIONS') return sendJson(res, 200, { ok: true });
|
|
||||||
|
|
||||||
const url = new URL(req.url, 'http://localhost');
|
|
||||||
const pathname = url.pathname.replace(/\/+$/, '') || '/';
|
|
||||||
const query = Object.fromEntries(url.searchParams);
|
|
||||||
|
|
||||||
const matched = matchRoute(req.method, pathname);
|
|
||||||
if (matched) {
|
|
||||||
try {
|
|
||||||
await matched.handler(req, res, matched.params, query);
|
|
||||||
} catch (err) {
|
|
||||||
sendJson(res, 500, { message: err.message });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendJson(res, 404, { message: 'Not found' });
|
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(PORT, '0.0.0.0', () => {
|
|
||||||
process.stdout.write(`Server running on http://0.0.0.0:${PORT}\n`);
|
|
||||||
process.stdout.write(` -> Local: http://localhost:${PORT}\n`);
|
|
||||||
// Log the LAN IP so users know what to point to from other devices
|
|
||||||
const nets = require('os').networkInterfaces();
|
|
||||||
for (const name of Object.keys(nets)) {
|
|
||||||
for (const net of nets[name]) {
|
|
||||||
if (net.family === 'IPv4' && !net.internal) {
|
|
||||||
process.stdout.write(` -> Network: http://${net.address}:${PORT}\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
server.on('error', (err) => {
|
|
||||||
if (err.code === 'EADDRINUSE') {
|
|
||||||
process.stderr.write(`ERROR: Port ${PORT} is already in use.\n`);
|
|
||||||
} else {
|
|
||||||
process.stderr.write(`ERROR: ${err.message}\n`);
|
|
||||||
}
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import {Pressable, StyleSheet, Text, View} from "react-native";
|
import {Pressable, StyleSheet, Text, View} from "react-native";
|
||||||
|
|
||||||
export const Button = ({ label, icon, onClick, styleLabel, styleButton }) => {
|
export const Button = ({ label, icon, onClick, styleLabel, styleButton, disabled }) => {
|
||||||
// Initialisation -------------
|
// Initialisation -------------
|
||||||
// State ----------------------
|
// State ----------------------
|
||||||
// Handlers -------------------
|
// Handlers -------------------
|
||||||
// View -----------------------
|
// View -----------------------
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={onClick} style={[styles.button, styleButton]}>
|
<Pressable onPress={onClick} disabled={disabled} style={[styles.button, styleButton, disabled && styles.buttonDisabled]}>
|
||||||
{icon ? icon : null}
|
{icon ? icon : null}
|
||||||
<Text style={[styles.label, styleLabel]}>{label}</Text>
|
<Text style={[styles.label, styleLabel, disabled && styles.labelDisabled]}>{label}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -44,5 +44,12 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
gap: 15,
|
gap: 15,
|
||||||
},
|
},
|
||||||
|
// Greyed-out state for buttons that should not be interactive
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.4,
|
||||||
|
},
|
||||||
|
labelDisabled: {
|
||||||
|
color: '#9ca3af',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -13,12 +13,12 @@ const Card = ({children, style}) => {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
card: {
|
card: {
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#313244',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: 12,
|
padding: 12,
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#e5e7eb',
|
borderColor: '#45475a',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import {StyleSheet, Text} from 'react-native';
|
||||||
|
import Screen from '../layout/Screen';
|
||||||
|
|
||||||
|
const PendingApprovalView = ({message = 'Waiting for admin approval. You will get access once approved.'}) => (
|
||||||
|
<Screen style={styles.center}>
|
||||||
|
<Text style={styles.body}>{message}</Text>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
center: {justifyContent: 'center', alignItems: 'center', flex: 1},
|
||||||
|
body: {color: '#bac2de', fontSize: 15, textAlign: 'center', paddingHorizontal: 20},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default PendingApprovalView;
|
||||||
|
|
||||||
@@ -2,28 +2,27 @@ import {StyleSheet, Text, View} from 'react-native';
|
|||||||
import Card from '../UI/Card';
|
import Card from '../UI/Card';
|
||||||
import {Button} from '../UI/Button';
|
import {Button} from '../UI/Button';
|
||||||
|
|
||||||
const CacheCardItem = ({cache, isAdmin, onEdit, onDelete, onSelect}) => {
|
const CacheCardItem = ({cache, isAdmin, isClaimed, isSelected, onEdit, onDelete, onSelect, disabled, departmentName}) => {
|
||||||
// Initialisation -------------
|
// Display name fallback for cache without a name
|
||||||
|
|
||||||
const isClaimed = Boolean(cache.ClaimedByUid);
|
|
||||||
const statusText = isClaimed ? 'Claimed' : 'Available';
|
|
||||||
const statusColor = isClaimed ? '#9ca3af' : '#16a34a';
|
|
||||||
const displayName = cache.name || cache.clue || 'Unnamed Cache';
|
const displayName = cache.name || cache.clue || 'Unnamed Cache';
|
||||||
|
|
||||||
// State ----------------------
|
|
||||||
// Handlers -------------------
|
|
||||||
// View -----------------------
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card style={[isSelected && styles.selectedCard, isClaimed && styles.claimedCard, disabled && styles.disabledCard]}>
|
||||||
<View style={styles.topRow}>
|
<View style={styles.topRow}>
|
||||||
<View style={styles.nameWrap}>
|
<View style={styles.nameWrap}>
|
||||||
<Text style={styles.name} numberOfLines={1}>{displayName}</Text>
|
<Text style={[styles.name, isClaimed && styles.claimedText, disabled && styles.disabledText]} numberOfLines={1}>{displayName}</Text>
|
||||||
|
{isAdmin && departmentName ? (
|
||||||
|
<Text style={styles.departmentText} numberOfLines={1}>Department: {departmentName}</Text>
|
||||||
|
) : null}
|
||||||
{cache.name ? (
|
{cache.name ? (
|
||||||
<Text style={styles.clue} numberOfLines={1}>{cache.clue}</Text>
|
<Text style={styles.clue} numberOfLines={1}>{cache.clue}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.status, {color: statusColor}]}>{statusText}</Text>
|
{!isAdmin && !disabled && (
|
||||||
|
<Text style={[styles.status, {color: isClaimed ? '#585b70' : '#a6e3a1'}]}>
|
||||||
|
{isClaimed ? 'Claimed' : 'Available'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
@@ -41,12 +40,35 @@ const CacheCardItem = ({cache, isAdmin, onEdit, onDelete, onSelect}) => {
|
|||||||
styleLabel={styles.btnLabel}
|
styleLabel={styles.btnLabel}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
) : disabled ? (
|
||||||
|
<Button
|
||||||
|
label="Join a Team First"
|
||||||
|
onClick={() => {}}
|
||||||
|
disabled={true}
|
||||||
|
styleButton={styles.claimedBtn}
|
||||||
|
styleLabel={styles.claimedBtnLabel}
|
||||||
|
/>
|
||||||
|
) : isClaimed ? (
|
||||||
|
<Button
|
||||||
|
label="Already Claimed"
|
||||||
|
onClick={() => {}}
|
||||||
|
disabled={true}
|
||||||
|
styleButton={styles.claimedBtn}
|
||||||
|
styleLabel={styles.claimedBtnLabel}
|
||||||
|
/>
|
||||||
|
) : isSelected ? (
|
||||||
|
<Button
|
||||||
|
label="Cache Currently Selected"
|
||||||
|
onClick={() => {}}
|
||||||
|
styleButton={styles.selectedBtn}
|
||||||
|
styleLabel={styles.selectedBtnLabel}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
label="Select Cache"
|
label="Select Cache"
|
||||||
onClick={() => onSelect(cache)}
|
onClick={() => onSelect(cache)}
|
||||||
styleButton={styles.selectBtn}
|
styleButton={styles.selectBtn}
|
||||||
styleLabel={styles.btnLabel}
|
styleLabel={styles.selectBtnLabel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -61,33 +83,56 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
clue: {fontSize: 13, color: '#6b7280', marginTop: 2},
|
clue: {fontSize: 13, color: '#9ca3af', marginTop: 2},
|
||||||
|
departmentText: {fontSize: 13, color: '#bd93f9', marginTop: 2, fontWeight: '600'},
|
||||||
nameWrap: {flex: 1, marginRight: 8},
|
nameWrap: {flex: 1, marginRight: 8},
|
||||||
name: {fontSize: 15, fontWeight: '600', color: '#1f2937'},
|
name: {fontSize: 15, fontWeight: '600', color: '#cdd6f4'},
|
||||||
|
claimedText: {color: '#6c7086'},
|
||||||
status: {fontSize: 13, fontWeight: '600'},
|
status: {fontSize: 13, fontWeight: '600'},
|
||||||
actions: {flexDirection: 'row', gap: 8},
|
actions: {flexDirection: 'row', gap: 8},
|
||||||
editBtn: {
|
editBtn: {
|
||||||
backgroundColor: '#2563eb',
|
backgroundColor: '#bd93f9',
|
||||||
borderColor: '#2563eb',
|
borderColor: '#bd93f9',
|
||||||
minHeight: 36,
|
minHeight: 36,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
deleteBtn: {
|
deleteBtn: {
|
||||||
backgroundColor: '#dc2626',
|
backgroundColor: '#D92800',
|
||||||
borderColor: '#dc2626',
|
borderColor: '#D92800',
|
||||||
minHeight: 36,
|
minHeight: 36,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
selectBtn: {
|
selectBtn: {
|
||||||
backgroundColor: '#16a34a',
|
backgroundColor: '#a6e3a1',
|
||||||
borderColor: '#16a34a',
|
borderColor: '#a6e3a1',
|
||||||
minHeight: 36,
|
minHeight: 36,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
btnLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
|
selectedBtn: {
|
||||||
|
backgroundColor: '#6c7086',
|
||||||
|
borderColor: '#6c7086',
|
||||||
|
minHeight: 36,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
claimedBtn: {
|
||||||
|
backgroundColor: '#45475a',
|
||||||
|
borderColor: '#45475a',
|
||||||
|
minHeight: 36,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
claimedBtnLabel: {color: '#6c7086', fontWeight: '600', fontSize: 13},
|
||||||
|
selectedBtnLabel: {color: '#cdd6f4', fontWeight: '600', fontSize: 13},
|
||||||
|
selectBtnLabel: {color: '#000000', fontWeight: '600', fontSize: 13},
|
||||||
|
selectedCard: {borderColor: '#bd93f9', borderWidth: 2},
|
||||||
|
claimedCard: {opacity: 0.5},
|
||||||
|
disabledCard: {opacity: 0.45},
|
||||||
|
disabledText: {color: '#6c7086'},
|
||||||
|
btnLabel: {color: '#cdd6f4', fontWeight: '600', fontSize: 13},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default CacheCardItem;
|
export default CacheCardItem;
|
||||||
|
|||||||
@@ -1,30 +1,37 @@
|
|||||||
import React, {useEffect, useState} from 'react';
|
import React, {useEffect, useRef, useState} from 'react';
|
||||||
import {StyleSheet, Text, View} from 'react-native';
|
import {StyleSheet, Text, View} from 'react-native';
|
||||||
import {CACHE_CLAIM_TIMER} from '../../utils/geoMath';
|
import {CACHE_CLAIM_TIMER} from '../../utils/geoMath';
|
||||||
|
|
||||||
const ClaimTimerView = ({ cache, onClaimSuccess, isClaiming }) => {
|
const ClaimTimerView = ({ cache, onClaimSuccess, isClaiming, showClaimedPopup }) => {
|
||||||
// Initialisation -------------
|
|
||||||
// State ----------------------
|
|
||||||
|
|
||||||
const [timeLeft, setTimeLeft] = useState(CACHE_CLAIM_TIMER);
|
const [timeLeft, setTimeLeft] = useState(CACHE_CLAIM_TIMER);
|
||||||
|
// Use a ref for the callback so it does not appear in the dependency array
|
||||||
|
const onClaimRef = useRef(onClaimSuccess);
|
||||||
|
onClaimRef.current = onClaimSuccess;
|
||||||
|
|
||||||
// Handlers -------------------
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timer;
|
let timer;
|
||||||
if (isClaiming && timeLeft > 0) {
|
if (isClaiming && cache && timeLeft > 0) {
|
||||||
timer = setTimeout(() => setTimeLeft((prev) => prev - 1), 1000);
|
timer = setTimeout(() => setTimeLeft((prev) => prev - 1), 1000);
|
||||||
} else if (isClaiming && timeLeft === 0 && cache) {
|
} else if (isClaiming && timeLeft === 0 && cache) {
|
||||||
onClaimSuccess(cache.id);
|
onClaimRef.current(cache.id);
|
||||||
} else {
|
} else if (!isClaiming) {
|
||||||
setTimeLeft(CACHE_CLAIM_TIMER);
|
setTimeLeft(CACHE_CLAIM_TIMER);
|
||||||
}
|
}
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [isClaiming, timeLeft, cache, onClaimSuccess]);
|
}, [isClaiming, timeLeft, cache]);
|
||||||
|
|
||||||
// View -----------------------
|
|
||||||
|
|
||||||
if (!isClaiming) {
|
// Show "Cache Claimed!" popup after a successful claim
|
||||||
|
if (showClaimedPopup) {
|
||||||
|
return (
|
||||||
|
<View style={styles.claimedOverlay}>
|
||||||
|
<Text style={styles.claimedText}>Cache Claimed!</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isClaiming || !cache) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,17 +45,30 @@ const ClaimTimerView = ({ cache, onClaimSuccess, isClaiming }) => {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
overlay: {
|
overlay: {
|
||||||
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,
|
marginTop: 8,
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
color: '#ffffff',
|
color: '#cdd6f4',
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
|
claimedOverlay: {
|
||||||
|
alignSelf: 'center',
|
||||||
|
backgroundColor: '#a6e3a1',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 10,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
claimedText: {
|
||||||
|
color: '#1e1e2e',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default ClaimTimerView;
|
export default ClaimTimerView;
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
import MapView, {Circle, Polygon} from 'react-native-maps';
|
import React from 'react';
|
||||||
|
import MapView, {Marker, Polygon} from 'react-native-maps';
|
||||||
import {getFovCone} from '../../utils/geoMath';
|
import {getFovCone} from '../../utils/geoMath';
|
||||||
|
|
||||||
const PlayerMapView = ({userLocation, visibleCache, heading, claimDistance}) => {
|
const PlayerMapView = ({userLocation, visibleCaches, heading}) => {
|
||||||
// Initialisation -------------
|
|
||||||
// State ----------------------
|
|
||||||
// Handlers -------------------
|
|
||||||
// View -----------------------
|
|
||||||
|
|
||||||
if (!userLocation) return null;
|
if (!userLocation) return null;
|
||||||
|
|
||||||
const coneCoords = (heading !== null && heading !== undefined)
|
const coneCoords = (heading !== null && heading !== undefined)
|
||||||
@@ -16,21 +12,22 @@ const PlayerMapView = ({userLocation, visibleCache, heading, claimDistance}) =>
|
|||||||
return (
|
return (
|
||||||
<MapView
|
<MapView
|
||||||
style={{flex: 1}}
|
style={{flex: 1}}
|
||||||
region={{...userLocation, latitudeDelta: 0.01, longitudeDelta: 0.01}}
|
provider="google"
|
||||||
scrollEnabled={false}
|
initialRegion={{...userLocation, latitudeDelta: 0.01, longitudeDelta: 0.01}}
|
||||||
|
scrollEnabled={true}
|
||||||
zoomEnabled={true}
|
zoomEnabled={true}
|
||||||
rotateEnabled={false}
|
rotateEnabled={false}
|
||||||
pitchEnabled={false}
|
pitchEnabled={false}
|
||||||
showsUserLocation
|
showsUserLocation
|
||||||
>
|
>
|
||||||
{visibleCache && (
|
{(visibleCaches || []).map((cache) => (
|
||||||
<Circle
|
<Marker
|
||||||
center={visibleCache.coordinates}
|
key={cache.id}
|
||||||
radius={claimDistance}
|
coordinate={cache.coordinates}
|
||||||
fillColor="rgba(250, 204, 21, 0.20)"
|
pinColor="orange"
|
||||||
strokeColor="rgba(250, 204, 21, 0.90)"
|
title={cache.clue}
|
||||||
/>
|
/>
|
||||||
)}
|
))}
|
||||||
{coneCoords && (
|
{coneCoords && (
|
||||||
<Polygon
|
<Polygon
|
||||||
coordinates={coneCoords}
|
coordinates={coneCoords}
|
||||||
@@ -44,3 +41,5 @@ const PlayerMapView = ({userLocation, visibleCache, heading, claimDistance}) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default PlayerMapView;
|
export default PlayerMapView;
|
||||||
|
|
||||||
|
|
||||||
@@ -5,6 +5,8 @@ import API, { API_BASE_URL } from "../API/API";
|
|||||||
|
|
||||||
const BottomNavbar = ({ navigation, routeName }) => {
|
const BottomNavbar = ({ navigation, routeName }) => {
|
||||||
// Initialisation ------------
|
// Initialisation ------------
|
||||||
|
const BottomNavbar = ({navigation, routeName, routeParams}) => {
|
||||||
|
// Initialisation ------------
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
@@ -73,6 +75,48 @@ const BottomNavbar = ({ navigation, routeName }) => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const tabs = session.isAcceptedAdmin ? adminTabs : playerTabs;
|
const tabs = session.isAcceptedAdmin ? adminTabs : playerTabs;
|
||||||
|
const selectedDepartment = routeName === 'DepartmentSettingsScreen' && routeParams?.SGid
|
||||||
|
? {SGid: routeParams.SGid, SubGroupName: routeParams.SubGroupName || routeParams.selectedDepartmentName || 'Department'}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const adminTabs = [
|
||||||
|
{label: 'Settings', route: 'GameSettingsScreen', onPress: () => navigation.navigate('GameSettingsScreen')},
|
||||||
|
{
|
||||||
|
label: 'Map',
|
||||||
|
route: 'MapScreen',
|
||||||
|
onPress: () => navigation.navigate('MapScreen', {
|
||||||
|
selectedDepartmentSGid: null,
|
||||||
|
selectedDepartmentName: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Players',
|
||||||
|
route: 'PlayersScreen',
|
||||||
|
onPress: () => navigation.navigate('PlayersScreen', selectedDepartment ? {department: selectedDepartment} : undefined),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Leaderboard',
|
||||||
|
route: 'LeaderboardScreen',
|
||||||
|
onPress: () => navigation.navigate('LeaderboardScreen', selectedDepartment ? {department: selectedDepartment} : undefined),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Only show the Leave tab when the player is actually in a game
|
||||||
|
const inGame = Boolean(session.currentGid);
|
||||||
|
|
||||||
|
const playerTabs = [
|
||||||
|
{label: 'Team', route: 'TeamScreen', onPress: () => navigation.navigate('TeamScreen')},
|
||||||
|
{label: 'Map', route: 'MapScreen', onPress: () => navigation.navigate('MapScreen')},
|
||||||
|
{label: 'Leaderboard', route: 'LeaderboardScreen', onPress: () => navigation.navigate('LeaderboardScreen')},
|
||||||
|
...(inGame ? [{label: 'Leave', route: '__leave__', onPress: () => handleLeaveGame()}] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tabs = session.isAcceptedAdmin ? adminTabs : playerTabs;
|
||||||
|
const pendingTabs = [
|
||||||
|
{label: 'Pending', route: 'TeamScreen', onPress: () => navigation.navigate('TeamScreen')},
|
||||||
|
...(inGame ? [{label: 'Leave', route: '__leave__', onPress: () => handleLeaveGame()}] : []),
|
||||||
|
];
|
||||||
|
const visibleTabs = (session.isPendingAdmin && !session.isAcceptedAdmin) ? pendingTabs : tabs;
|
||||||
|
|
||||||
// State ----------------------
|
// State ----------------------
|
||||||
// Handlers -------------------
|
// Handlers -------------------
|
||||||
@@ -98,6 +142,25 @@ const BottomNavbar = ({ navigation, routeName }) => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
const handleLeaveGame = () => {
|
||||||
|
Alert.alert('Leave Game', 'Are you sure you want to leave this game?', [
|
||||||
|
{text: 'Cancel', style: 'cancel'},
|
||||||
|
{
|
||||||
|
text: 'Leave',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
const s = getSession();
|
||||||
|
const endpoint = `${API_BASE_URL}/subgroup-members?Uid=${s.currentUid}&Gid=${s.currentGid}`;
|
||||||
|
const res = await API.get(endpoint);
|
||||||
|
if (res.isSuccess && res.result.length > 0) {
|
||||||
|
await API.delete(`${API_BASE_URL}/subgroup-members/${res.result[0].id}`);
|
||||||
|
}
|
||||||
|
clearGameSession();
|
||||||
|
navigation.reset({index: 0, routes: [{name: 'Game'}]});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
// View -----------------------
|
// View -----------------------
|
||||||
|
|
||||||
@@ -169,6 +232,64 @@ const styles = StyleSheet.create({
|
|||||||
tabTextLeave: {
|
tabTextLeave: {
|
||||||
color: "#fca5a5",
|
color: "#fca5a5",
|
||||||
},
|
},
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, {paddingBottom: insets.bottom}]}>
|
||||||
|
{visibleTabs.map((tab) => {
|
||||||
|
const isActive = routeName === tab.route;
|
||||||
|
const isLeave = tab.route === '__leave__';
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={tab.label}
|
||||||
|
onPress={tab.onPress}
|
||||||
|
style={[styles.tab, isActive && styles.tabActive, isLeave && styles.tabLeave]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.tabText, isActive && styles.tabTextActive, isLeave && styles.tabTextLeave]}>
|
||||||
|
{tab.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
minHeight: 64,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#45475a',
|
||||||
|
backgroundColor: '#313244',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
tab: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
marginHorizontal: 4,
|
||||||
|
},
|
||||||
|
tabActive: {
|
||||||
|
backgroundColor: '#45475a',
|
||||||
|
},
|
||||||
|
tabLeave: {
|
||||||
|
backgroundColor: 'rgba(243, 139, 168, 0.15)',
|
||||||
|
},
|
||||||
|
tabText: {
|
||||||
|
color: '#bac2de',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
tabTextActive: {
|
||||||
|
color: '#cdd6f4',
|
||||||
|
},
|
||||||
|
tabTextLeave: {
|
||||||
|
color: '#D92800',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default BottomNavbar;
|
export default BottomNavbar;
|
||||||
@@ -18,6 +18,7 @@ const Screen = ({ children, style, showBack = false }) => {
|
|||||||
MapScreen: "Map",
|
MapScreen: "Map",
|
||||||
GameSettingsScreen: "Game Settings",
|
GameSettingsScreen: "Game Settings",
|
||||||
GameSelectionScreen: "Games",
|
GameSelectionScreen: "Games",
|
||||||
|
DepartmentSettingsScreen: "Department Settings",
|
||||||
PlayersScreen: "Players",
|
PlayersScreen: "Players",
|
||||||
ExpandedMapScreen: "Map View",
|
ExpandedMapScreen: "Map View",
|
||||||
GlobalEventsScreen: "Global Events",
|
GlobalEventsScreen: "Global Events",
|
||||||
@@ -57,6 +58,7 @@ const Screen = ({ children, style, showBack = false }) => {
|
|||||||
{!isAuthScreen && (
|
{!isAuthScreen && (
|
||||||
<BottomNavbar navigation={navigation} routeName={routeName} />
|
<BottomNavbar navigation={navigation} routeName={routeName} />
|
||||||
)}
|
)}
|
||||||
|
{!isAuthScreen && <BottomNavbar navigation={navigation} routeName={routeName} routeParams={route.params} />}
|
||||||
<StatusBar style="light" />
|
<StatusBar style="light" />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -65,11 +67,13 @@ const Screen = ({ children, style, showBack = false }) => {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
screen: {
|
screen: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: "#fff",
|
backgroundColor: '#1e1e2e',
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: 15,
|
padding: 15,
|
||||||
|
zIndex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -47,12 +47,13 @@ const styles = StyleSheet.create({
|
|||||||
container: {
|
container: {
|
||||||
minHeight: 56,
|
minHeight: 56,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: '#333333',
|
borderBottomColor: '#45475a',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
backgroundColor: '#000000',
|
backgroundColor: '#313244',
|
||||||
|
zIndex: 10,
|
||||||
},
|
},
|
||||||
leftSlot: {
|
leftSlot: {
|
||||||
width: 72,
|
width: 72,
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import React, {useEffect, useState} from 'react';
|
||||||
|
import {ActivityIndicator, Alert, 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';
|
||||||
|
|
||||||
|
const DepartmentSettingsScreen = ({route, navigation}) => {
|
||||||
|
const {SGid, Gid} = route.params;
|
||||||
|
const {getSubgroup, updateSubgroup, getCaches, resetGame} = useGameHook();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deptName, setDeptName] = useState('');
|
||||||
|
const [joinCode, setJoinCode] = useState('');
|
||||||
|
const [teamsEnabled, setTeamsEnabled] = useState(false);
|
||||||
|
const [cacheTriggerMeters, setCacheTriggerMeters] = useState('20');
|
||||||
|
const [cacheCount, setCacheCount] = useState(0);
|
||||||
|
|
||||||
|
// Handlers -------------------
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
const sg = await getSubgroup(SGid);
|
||||||
|
if (sg) {
|
||||||
|
setDeptName(sg.SubGroupName || '');
|
||||||
|
setJoinCode(sg.JoinCode || '');
|
||||||
|
setTeamsEnabled(Boolean(sg.TeamsEnabled));
|
||||||
|
setCacheTriggerMeters(String(sg.CacheTriggerMeters || 20));
|
||||||
|
}
|
||||||
|
// Load cache count for this department
|
||||||
|
const caches = await getCaches(Gid, SGid);
|
||||||
|
setCacheCount((caches || []).length);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
await updateSubgroup(SGid, {
|
||||||
|
SubGroupName: deptName.trim(),
|
||||||
|
TeamsEnabled: teamsEnabled,
|
||||||
|
CacheTriggerMeters: parseInt(cacheTriggerMeters) || 20,
|
||||||
|
});
|
||||||
|
setSaving(false);
|
||||||
|
Alert.alert('Saved', 'Department settings updated.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetDepartment = () => {
|
||||||
|
Alert.alert(
|
||||||
|
'Reset Department',
|
||||||
|
'This will reset all cache claims for this department. Are you sure?',
|
||||||
|
[
|
||||||
|
{text: 'Cancel', style: 'cancel'},
|
||||||
|
{
|
||||||
|
text: 'Reset',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
await resetGame(Gid, SGid);
|
||||||
|
Alert.alert('Done', 'Department progress has been reset.');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManageCaches = () => {
|
||||||
|
navigation.navigate('MapScreen', {
|
||||||
|
selectedDepartmentSGid: SGid,
|
||||||
|
selectedDepartmentName: deptName || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// View -----------------------
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Screen showBack={true} style={styles.center}>
|
||||||
|
<ActivityIndicator size="large"/>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen showBack={true}>
|
||||||
|
<ScrollView contentContainerStyle={styles.formContainer} showsVerticalScrollIndicator={false}>
|
||||||
|
<Text style={styles.sectionTitle}>Department Settings</Text>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Department Name</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={deptName}
|
||||||
|
onChangeText={setDeptName}
|
||||||
|
placeholder="Enter department name"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.codeSection}>
|
||||||
|
<Text style={styles.codeLabel}>Department Join Code</Text>
|
||||||
|
<Text style={styles.codeValue}>{joinCode || '—'}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.toggleRow}>
|
||||||
|
<Text style={styles.toggleLabel}>Teams Enabled</Text>
|
||||||
|
<Switch
|
||||||
|
value={teamsEnabled}
|
||||||
|
onValueChange={setTeamsEnabled}
|
||||||
|
trackColor={{false: '#d1d5db', true: '#93c5fd'}}
|
||||||
|
thumbColor={teamsEnabled ? '#2563eb' : '#9ca3af'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Cache Claim Distance (metres)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={cacheTriggerMeters}
|
||||||
|
onChangeText={setCacheTriggerMeters}
|
||||||
|
keyboardType="numeric"
|
||||||
|
placeholder="20"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Text style={styles.infoLabel}>Total Caches</Text>
|
||||||
|
<Text style={styles.infoValue}>{cacheCount}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.saveWrap}>
|
||||||
|
<ButtonTray>
|
||||||
|
<Button
|
||||||
|
label={saving ? 'Saving...' : 'Save Settings'}
|
||||||
|
onClick={handleSave}
|
||||||
|
styleButton={styles.saveButton}
|
||||||
|
styleLabel={styles.saveLabel}
|
||||||
|
/>
|
||||||
|
</ButtonTray>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.mapWrap}>
|
||||||
|
<ButtonTray>
|
||||||
|
<Button
|
||||||
|
label="Manage Caches"
|
||||||
|
onClick={handleManageCaches}
|
||||||
|
styleButton={styles.mapButton}
|
||||||
|
styleLabel={styles.mapLabel}
|
||||||
|
/>
|
||||||
|
</ButtonTray>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.resetWrap}>
|
||||||
|
<Button
|
||||||
|
label="Reset Department"
|
||||||
|
onClick={handleResetDepartment}
|
||||||
|
styleButton={styles.resetButton}
|
||||||
|
styleLabel={styles.resetLabel}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
center: {justifyContent: 'center', alignItems: 'center'},
|
||||||
|
formContainer: {paddingBottom: 20},
|
||||||
|
sectionTitle: {fontSize: 20, fontWeight: '700', color: '#cdd6f4', marginBottom: 16},
|
||||||
|
label: {fontSize: 14, fontWeight: '600', color: '#bac2de', marginBottom: 6, marginTop: 14},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#45475a',
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#cdd6f4',
|
||||||
|
backgroundColor: '#313244',
|
||||||
|
},
|
||||||
|
codeSection: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
backgroundColor: '#313244',
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
codeLabel: {fontSize: 14, fontWeight: '600', color: '#bac2de'},
|
||||||
|
codeValue: {fontSize: 16, fontWeight: '700', color: '#bd93f9', letterSpacing: 2},
|
||||||
|
toggleRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#45475a',
|
||||||
|
},
|
||||||
|
toggleLabel: {fontSize: 14, fontWeight: '600', color: '#bac2de'},
|
||||||
|
infoRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
backgroundColor: '#313244',
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
infoLabel: {fontSize: 14, fontWeight: '600', color: '#bac2de'},
|
||||||
|
infoValue: {fontSize: 16, fontWeight: '700', color: '#cdd6f4'},
|
||||||
|
saveWrap: {marginTop: 24},
|
||||||
|
saveButton: {backgroundColor: '#a6e3a1', borderColor: '#a6e3a1'},
|
||||||
|
saveLabel: {color: '#1e1e2e', fontWeight: '600'},
|
||||||
|
mapWrap: {marginTop: 16},
|
||||||
|
mapButton: {backgroundColor: '#bd93f9', borderColor: '#bd93f9'},
|
||||||
|
mapLabel: {color: '#1e1e2e', fontWeight: '600'},
|
||||||
|
resetWrap: {marginTop: 30},
|
||||||
|
resetButton: {backgroundColor: '#86efac', borderColor: '#86efac'},
|
||||||
|
resetLabel: {color: '#1e1e2e', fontWeight: '600'},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default DepartmentSettingsScreen;
|
||||||
|
|
||||||
@@ -3,25 +3,18 @@ import {StyleSheet, View} from 'react-native';
|
|||||||
import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
|
import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
|
||||||
import * as Location from 'expo-location';
|
import * as Location from 'expo-location';
|
||||||
import Screen from '../layout/Screen';
|
import Screen from '../layout/Screen';
|
||||||
import {getFovCone} from '../../utils/geoMath';
|
import {getFovCone, isInClaimCone} from '../../utils/geoMath';
|
||||||
|
|
||||||
const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.01, longitudeDelta: 0.01};
|
const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.01, longitudeDelta: 0.01};
|
||||||
|
|
||||||
const ExpandedMapScreen = ({route}) => {
|
const ExpandedMapScreen = ({route}) => {
|
||||||
// Initialisation ------------
|
const {isAdmin, cacheRecords: cacheStr, claimDistance: routeClaimDistance, userLocation: routeLocation, selectedCacheId} = route.params || {};
|
||||||
|
|
||||||
const {isAdmin, cacheRecords: cacheStr, claimDistance: routeClaimDistance, userLocation: routeLocation} = route.params || {};
|
|
||||||
const caches = cacheStr ? JSON.parse(cacheStr) : [];
|
const caches = cacheStr ? JSON.parse(cacheStr) : [];
|
||||||
const claimDistance = routeClaimDistance || 20;
|
const claimDistance = routeClaimDistance || 20;
|
||||||
|
|
||||||
// State ----------------------
|
|
||||||
|
|
||||||
// Seed with the location passed from MapScreen so the map opens centred on the user immediately
|
|
||||||
const [userLocation, setUserLocation] = useState(routeLocation || null);
|
const [userLocation, setUserLocation] = useState(routeLocation || null);
|
||||||
const [heading, setHeading] = useState(null);
|
const [heading, setHeading] = useState(null);
|
||||||
|
|
||||||
// Handlers -------------------
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let locationSub;
|
let locationSub;
|
||||||
let headingSub;
|
let headingSub;
|
||||||
@@ -30,7 +23,6 @@ const ExpandedMapScreen = ({route}) => {
|
|||||||
const {status} = await Location.requestForegroundPermissionsAsync();
|
const {status} = await Location.requestForegroundPermissionsAsync();
|
||||||
if (status !== 'granted') return;
|
if (status !== 'granted') return;
|
||||||
|
|
||||||
// Only do a fresh one-shot fix if MapScreen didn't supply a location
|
|
||||||
if (!routeLocation) {
|
if (!routeLocation) {
|
||||||
const last = await Location.getLastKnownPositionAsync();
|
const last = await Location.getLastKnownPositionAsync();
|
||||||
if (last) {
|
if (last) {
|
||||||
@@ -46,7 +38,6 @@ const ExpandedMapScreen = ({route}) => {
|
|||||||
(next) => setUserLocation({latitude: next.coords.latitude, longitude: next.coords.longitude}),
|
(next) => setUserLocation({latitude: next.coords.latitude, longitude: next.coords.longitude}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use OS-fused heading from expo-location
|
|
||||||
headingSub = await Location.watchHeadingAsync((headingData) => {
|
headingSub = await Location.watchHeadingAsync((headingData) => {
|
||||||
const raw = headingData.trueHeading >= 0 ? headingData.trueHeading : headingData.magHeading;
|
const raw = headingData.trueHeading >= 0 ? headingData.trueHeading : headingData.magHeading;
|
||||||
setHeading(raw);
|
setHeading(raw);
|
||||||
@@ -64,28 +55,25 @@ const ExpandedMapScreen = ({route}) => {
|
|||||||
? {...userLocation, latitudeDelta: 0.01, longitudeDelta: 0.01}
|
? {...userLocation, latitudeDelta: 0.01, longitudeDelta: 0.01}
|
||||||
: DEFAULT_REGION;
|
: DEFAULT_REGION;
|
||||||
|
|
||||||
// View -----------------------
|
const hasHeading = heading !== null;
|
||||||
|
const coneCoords = (userLocation && hasHeading) ? getFovCone(userLocation, heading) : null;
|
||||||
|
|
||||||
const coneCoords = (userLocation && heading !== null)
|
const visiblePlayerCaches = (!isAdmin && userLocation && hasHeading && selectedCacheId)
|
||||||
? getFovCone(userLocation, heading)
|
? caches.filter((c) => c.id === selectedCacheId && isInClaimCone(heading, userLocation, c.coordinates, claimDistance))
|
||||||
: null;
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen showBack={true} style={styles.container}>
|
<Screen showBack={true} style={styles.container}>
|
||||||
<View style={styles.mapWrap}>
|
<View style={styles.mapWrap}>
|
||||||
<MapView
|
<MapView
|
||||||
style={{flex: 1}}
|
style={{flex: 1}}
|
||||||
|
provider="google"
|
||||||
initialRegion={region}
|
initialRegion={region}
|
||||||
showsUserLocation
|
showsUserLocation
|
||||||
>
|
>
|
||||||
{/* Only admins see cache pin locations */}
|
|
||||||
{isAdmin && caches.map((cache) => (
|
{isAdmin && caches.map((cache) => (
|
||||||
<React.Fragment key={cache.id}>
|
<React.Fragment key={cache.id}>
|
||||||
<Marker
|
<Marker coordinate={cache.coordinates} pinColor="#9ca3af" title={cache.name || cache.clue} />
|
||||||
coordinate={cache.coordinates}
|
|
||||||
title={cache.name || cache.clue}
|
|
||||||
pinColor="#2563eb"
|
|
||||||
/>
|
|
||||||
<Circle
|
<Circle
|
||||||
center={cache.coordinates}
|
center={cache.coordinates}
|
||||||
radius={claimDistance}
|
radius={claimDistance}
|
||||||
@@ -94,7 +82,14 @@ const ExpandedMapScreen = ({route}) => {
|
|||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
{/* Heading FOV cone */}
|
{!isAdmin && visiblePlayerCaches.map((cache) => (
|
||||||
|
<Marker
|
||||||
|
key={cache.id}
|
||||||
|
coordinate={cache.coordinates}
|
||||||
|
pinColor="orange"
|
||||||
|
title={cache.clue}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
{coneCoords && (
|
{coneCoords && (
|
||||||
<Polygon
|
<Polygon
|
||||||
coordinates={coneCoords}
|
coordinates={coneCoords}
|
||||||
@@ -115,3 +110,5 @@ const styles = StyleSheet.create({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default ExpandedMapScreen;
|
export default ExpandedMapScreen;
|
||||||
|
|
||||||
|
|
||||||
@@ -1,69 +1,226 @@
|
|||||||
import React, {useEffect, useState} from 'react';
|
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
||||||
import {ActivityIndicator, ScrollView, StyleSheet, Switch, Text, TextInput, View} from 'react-native';
|
import {ActivityIndicator, Alert, ScrollView, StyleSheet, Switch, Text, TextInput, View} from 'react-native';
|
||||||
|
import {useFocusEffect} from '@react-navigation/native';
|
||||||
import Screen from '../layout/Screen';
|
import Screen from '../layout/Screen';
|
||||||
|
import Card from '../UI/Card';
|
||||||
import {Button, ButtonTray} from '../UI/Button';
|
import {Button, ButtonTray} from '../UI/Button';
|
||||||
import useGameHook from '../../hooks/useGameHook';
|
import useGameHook from '../../hooks/useGameHook';
|
||||||
import {getSession, setSessionTeamsEnabled} from '../../hooks/SessionStore';
|
import {getSession, setSessionTeamsEnabled} from '../../hooks/SessionStore';
|
||||||
|
|
||||||
const GameSettingsScreen = () => {
|
const GameSettingsScreen = ({navigation}) => {
|
||||||
// Initialisation ------------
|
|
||||||
|
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
const {getLobby, updateGroup, getSubgroups} = useGameHook();
|
const {getLobby, updateGroup, getSubgroups, createSubgroup, deleteSubgroup, resetGame, getAdminWaitlist, approveAdmin, rejectAdmin, getUser, disbandTeams} = useGameHook();
|
||||||
const isBusiness = Boolean(session.isBusiness);
|
const gameApiRef = useRef({
|
||||||
|
getLobby,
|
||||||
// State ----------------------
|
getSubgroups,
|
||||||
|
getAdminWaitlist,
|
||||||
|
getUser,
|
||||||
|
});
|
||||||
|
gameApiRef.current = {
|
||||||
|
getLobby,
|
||||||
|
getSubgroups,
|
||||||
|
getAdminWaitlist,
|
||||||
|
getUser,
|
||||||
|
};
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
const isRefreshingRef = useRef(false);
|
||||||
|
const hasHydratedRef = useRef(false);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [groupName, setGroupName] = useState('');
|
const [groupName, setGroupName] = useState('');
|
||||||
|
const [businessName, setBusinessName] = useState('');
|
||||||
const [teamsEnabled, setTeamsEnabled] = useState(session.teamsEnabled);
|
const [teamsEnabled, setTeamsEnabled] = useState(session.teamsEnabled);
|
||||||
const [maxSubgroups, setMaxSubgroups] = useState('1');
|
const [serverTeamsEnabled, setServerTeamsEnabled] = useState(session.teamsEnabled);
|
||||||
const [cacheTriggerMeters, setCacheTriggerMeters] = useState('20');
|
const [cacheTriggerMeters, setCacheTriggerMeters] = useState('20');
|
||||||
const [adminJoinCode, setAdminJoinCode] = useState('');
|
const [adminJoinCode, setAdminJoinCode] = useState('');
|
||||||
|
const [orgJoinCode, setOrgJoinCode] = useState('');
|
||||||
const [memberJoinCode, setMemberJoinCode] = useState('');
|
const [memberJoinCode, setMemberJoinCode] = useState('');
|
||||||
|
const [waitlist, setWaitlist] = useState([]);
|
||||||
|
const [waitlistNames, setWaitlistNames] = useState({});
|
||||||
|
const [isBusinessGroup, setIsBusinessGroup] = useState(Boolean(session.isBusiness));
|
||||||
|
const [subgroupList, setSubgroupList] = useState([]);
|
||||||
|
const [newDeptName, setNewDeptName] = useState('');
|
||||||
|
|
||||||
// Handlers -------------------
|
// Handlers -------------------
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => () => {
|
||||||
const load = async () => {
|
isMountedRef.current = false;
|
||||||
if (!session.currentGid) {
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const group = await getLobby(session.currentGid);
|
|
||||||
if (group) {
|
|
||||||
setGroupName(group.GroupName || '');
|
|
||||||
const serverTeams = Boolean(group.TeamsEnabled);
|
|
||||||
setTeamsEnabled(serverTeams);
|
|
||||||
// Keep session in sync so the toggle survives app reloads
|
|
||||||
setSessionTeamsEnabled(serverTeams);
|
|
||||||
setMaxSubgroups(String(group.MaxMemberSubgroups || 1));
|
|
||||||
setCacheTriggerMeters(String(group.CacheTriggerMeters || 20));
|
|
||||||
setAdminJoinCode(group.AdminJoinCode || '');
|
|
||||||
}
|
|
||||||
const sgs = await getSubgroups(session.currentGid);
|
|
||||||
const memberSg = (sgs || []).find((sg) => !sg.IsAdminGroup);
|
|
||||||
if (memberSg) setMemberJoinCode(memberSg.JoinCode || '');
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
load();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const refreshSettings = useCallback(async ({showLoader = false} = {}) => {
|
||||||
if (!session.currentGid) return;
|
const activeSession = getSession();
|
||||||
|
if (!activeSession.currentGid) {
|
||||||
|
if (isMountedRef.current) setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard against re-entrant focus refresh calls.
|
||||||
|
if (isRefreshingRef.current) return;
|
||||||
|
isRefreshingRef.current = true;
|
||||||
|
|
||||||
|
if (showLoader && isMountedRef.current) setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [group, sgs, waitlistRows] = await Promise.all([
|
||||||
|
gameApiRef.current.getLobby(activeSession.currentGid),
|
||||||
|
gameApiRef.current.getSubgroups(activeSession.currentGid),
|
||||||
|
gameApiRef.current.getAdminWaitlist(activeSession.currentGid),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
|
if (group) {
|
||||||
|
setGroupName(group.GroupName || '');
|
||||||
|
setBusinessName(group.BusinessOrSchoolName || '');
|
||||||
|
// Organisation mode
|
||||||
|
setIsBusinessGroup(Boolean(group.BusinessOrSchoolName));
|
||||||
|
|
||||||
|
const serverTeams = Boolean(group.TeamsEnabled);
|
||||||
|
setTeamsEnabled(serverTeams);
|
||||||
|
setServerTeamsEnabled(serverTeams);
|
||||||
|
setSessionTeamsEnabled(serverTeams);
|
||||||
|
|
||||||
|
setCacheTriggerMeters(String(group.CacheTriggerMeters || 20));
|
||||||
|
setAdminJoinCode(group.AdminJoinCode || '');
|
||||||
|
setOrgJoinCode(group.OrgJoinCode || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubgroupList(sgs || []);
|
||||||
|
const memberSg = (sgs || []).find((sg) => !sg.IsAdminGroup);
|
||||||
|
setMemberJoinCode(memberSg?.JoinCode || '');
|
||||||
|
|
||||||
|
setWaitlist(waitlistRows || []);
|
||||||
|
const names = {};
|
||||||
|
for (const entry of (waitlistRows || [])) {
|
||||||
|
const user = await gameApiRef.current.getUser(entry.Uid);
|
||||||
|
if (user) names[entry.Uid] = user.username;
|
||||||
|
}
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
setWaitlistNames(names);
|
||||||
|
hasHydratedRef.current = true;
|
||||||
|
} finally {
|
||||||
|
if (isMountedRef.current && (showLoader || !hasHydratedRef.current)) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
isRefreshingRef.current = false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
refreshSettings({showLoader: !hasHydratedRef.current});
|
||||||
|
}, [refreshSettings]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Individual save ---
|
||||||
|
const saveSettings = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const result = await updateGroup(session.currentGid, {
|
const result = await updateGroup(session.currentGid, {
|
||||||
GroupName: groupName.trim(),
|
GroupName: groupName.trim(),
|
||||||
TeamsEnabled: teamsEnabled,
|
TeamsEnabled: teamsEnabled,
|
||||||
MaxMemberSubgroups: parseInt(maxSubgroups) || 1,
|
|
||||||
CacheTriggerMeters: parseInt(cacheTriggerMeters) || 20,
|
CacheTriggerMeters: parseInt(cacheTriggerMeters) || 20,
|
||||||
});
|
});
|
||||||
// Persist the new toggle value so the session reflects it after a reload
|
if (result) {
|
||||||
if (result) setSessionTeamsEnabled(teamsEnabled);
|
if (serverTeamsEnabled && !teamsEnabled) {
|
||||||
|
await disbandTeams(session.currentGid);
|
||||||
|
}
|
||||||
|
setSessionTeamsEnabled(teamsEnabled);
|
||||||
|
setServerTeamsEnabled(teamsEnabled);
|
||||||
|
}
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!session.currentGid) return;
|
||||||
|
// Business save — only save business name
|
||||||
|
if (isBusinessGroup) {
|
||||||
|
setSaving(true);
|
||||||
|
await updateGroup(session.currentGid, {
|
||||||
|
BusinessOrSchoolName: businessName.trim(),
|
||||||
|
GroupName: businessName.trim(),
|
||||||
|
});
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Individual save — check teams toggle
|
||||||
|
if (serverTeamsEnabled && !teamsEnabled) {
|
||||||
|
Alert.alert(
|
||||||
|
'Disable Teams',
|
||||||
|
'This will disband all existing teams and remove all players from their teams. Continue?',
|
||||||
|
[
|
||||||
|
{text: 'Cancel', style: 'cancel'},
|
||||||
|
{text: 'Disable', style: 'destructive', onPress: saveSettings},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await saveSettings();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetGame = () => {
|
||||||
|
Alert.alert('Reset Entire Game', 'This will remove ALL saved caches and reset progress for every player. Are you sure?', [
|
||||||
|
{text: 'Cancel', style: 'cancel'},
|
||||||
|
{
|
||||||
|
text: 'Reset',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
await resetGame(session.currentGid);
|
||||||
|
Alert.alert('Done', 'All caches and player progress have been reset.');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApproveAdmin = async (entry) => {
|
||||||
|
await approveAdmin(entry.id);
|
||||||
|
await refreshSettings();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRejectAdmin = async (entry) => {
|
||||||
|
await rejectAdmin(entry.id);
|
||||||
|
await refreshSettings();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Business — create a new department (subgroup)
|
||||||
|
const handleCreateDepartment = async () => {
|
||||||
|
const deptCount = subgroupList.filter((sg) => !sg.IsAdminGroup).length;
|
||||||
|
const name = newDeptName.trim() || `Department ${deptCount + 1}`;
|
||||||
|
if (!session.currentGid) return;
|
||||||
|
await createSubgroup({
|
||||||
|
SubGroupName: name,
|
||||||
|
Gid: session.currentGid,
|
||||||
|
IsAdminGroup: false,
|
||||||
|
CacheTriggerMeters: 20,
|
||||||
|
});
|
||||||
|
setNewDeptName('');
|
||||||
|
await refreshSettings();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Business — delete a department
|
||||||
|
const handleDeleteDepartment = (sg) => {
|
||||||
|
Alert.alert('Delete Department', `Delete "${sg.SubGroupName}" and all its data?`, [
|
||||||
|
{text: 'Cancel', style: 'cancel'},
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
await deleteSubgroup(sg.SGid);
|
||||||
|
await refreshSettings();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Business — navigate to department settings
|
||||||
|
const handleEnterDepartment = (sg) => {
|
||||||
|
navigation.navigate('DepartmentSettingsScreen', {
|
||||||
|
SGid: sg.SGid,
|
||||||
|
Gid: session.currentGid,
|
||||||
|
SubGroupName: sg.SubGroupName,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// View -----------------------
|
// View -----------------------
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -82,6 +239,147 @@ const GameSettingsScreen = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter subgroups for the department list (non-admin only)
|
||||||
|
const departments = subgroupList.filter((sg) => !sg.IsAdminGroup);
|
||||||
|
|
||||||
|
// ===== Business Admin View =====
|
||||||
|
if (isBusinessGroup) {
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<ScrollView contentContainerStyle={styles.formContainer} showsVerticalScrollIndicator={false}>
|
||||||
|
<Text style={styles.sectionTitle}>Game Configuration :</Text>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Organisation Name:</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={businessName}
|
||||||
|
onChangeText={setBusinessName}
|
||||||
|
placeholder="Enter organisation name"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.codeSection}>
|
||||||
|
<Text style={styles.codeLabel}>Admin join code:</Text>
|
||||||
|
<Text style={styles.codeValue}>{adminJoinCode || '—'}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.codeSection}>
|
||||||
|
<Text style={styles.codeLabel}>Org join code:</Text>
|
||||||
|
<Text style={styles.codeValue}>{orgJoinCode || '—'}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.saveWrap}>
|
||||||
|
<ButtonTray>
|
||||||
|
<Button
|
||||||
|
label={saving ? 'Saving...' : 'Save Settings'}
|
||||||
|
onClick={handleSave}
|
||||||
|
styleButton={styles.saveButton}
|
||||||
|
styleLabel={styles.saveLabel}
|
||||||
|
/>
|
||||||
|
</ButtonTray>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Departments List */}
|
||||||
|
<View style={styles.deptSection}>
|
||||||
|
<Text style={styles.sectionTitle}>Departments:</Text>
|
||||||
|
{departments.map((sg, index) => (
|
||||||
|
<Card key={sg.SGid}>
|
||||||
|
<Text style={styles.deptIndexLabel}>Department {index + 1}:</Text>
|
||||||
|
<View style={styles.deptCardRow}>
|
||||||
|
<View style={styles.deptCardInfo}>
|
||||||
|
<Text style={styles.deptInfoLabel}>Department Name</Text>
|
||||||
|
<Text style={styles.deptName}>{sg.SubGroupName}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.deptCardActions}>
|
||||||
|
<Button
|
||||||
|
label="Department Game"
|
||||||
|
onClick={() => handleEnterDepartment(sg)}
|
||||||
|
styleButton={styles.deptOpenButton}
|
||||||
|
styleLabel={styles.deptBtnLabel}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.deptCardRow}>
|
||||||
|
<View style={styles.deptCardInfo}>
|
||||||
|
<Text style={styles.deptInfoLabel}>Department Join code</Text>
|
||||||
|
<Text style={styles.deptCode}>{sg.JoinCode || '—'}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.deptCardActions}>
|
||||||
|
<Button
|
||||||
|
label="Delete Department"
|
||||||
|
onClick={() => handleDeleteDepartment(sg)}
|
||||||
|
styleButton={styles.deptDeleteButton}
|
||||||
|
styleLabel={styles.deptBtnLabel}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
{departments.length === 0 && (
|
||||||
|
<Text style={styles.emptyText}>No departments yet.</Text>
|
||||||
|
)}
|
||||||
|
<View style={styles.createDeptRow}>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, {flex: 1}]}
|
||||||
|
value={newDeptName}
|
||||||
|
onChangeText={setNewDeptName}
|
||||||
|
placeholder="New department name"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Add"
|
||||||
|
onClick={handleCreateDepartment}
|
||||||
|
styleButton={styles.createDeptButton}
|
||||||
|
styleLabel={styles.createDeptLabel}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Admin Waitlist */}
|
||||||
|
{waitlist.length > 0 && (
|
||||||
|
<View style={styles.waitlistSection}>
|
||||||
|
<Text style={styles.sectionTitle}>Admin Waitlist</Text>
|
||||||
|
{waitlist.map((entry) => (
|
||||||
|
<Card key={entry.id}>
|
||||||
|
<View style={styles.waitlistRow}>
|
||||||
|
<Text style={styles.waitlistName}>
|
||||||
|
{waitlistNames[entry.Uid] || `User ${entry.Uid}`}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.waitlistActions}>
|
||||||
|
<Button
|
||||||
|
label="Approve"
|
||||||
|
onClick={() => handleApproveAdmin(entry)}
|
||||||
|
styleButton={styles.approveButton}
|
||||||
|
styleLabel={styles.approveBtnLabel}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Reject"
|
||||||
|
onClick={() => handleRejectAdmin(entry)}
|
||||||
|
styleButton={styles.rejectButton}
|
||||||
|
styleLabel={styles.rejectBtnLabel}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reset Entire Game */}
|
||||||
|
<View style={styles.resetSection}>
|
||||||
|
<Button
|
||||||
|
label="Reset Entire Game"
|
||||||
|
onClick={handleResetGame}
|
||||||
|
styleButton={styles.resetButton}
|
||||||
|
styleLabel={styles.resetLabel}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Individual Admin View =====
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
<ScrollView contentContainerStyle={styles.formContainer} showsVerticalScrollIndicator={false}>
|
<ScrollView contentContainerStyle={styles.formContainer} showsVerticalScrollIndicator={false}>
|
||||||
@@ -106,20 +404,6 @@ const GameSettingsScreen = () => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isBusiness && (
|
|
||||||
<>
|
|
||||||
<Text style={styles.label}>Max Member Subgroups</Text>
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
value={maxSubgroups}
|
|
||||||
onChangeText={setMaxSubgroups}
|
|
||||||
keyboardType="numeric"
|
|
||||||
placeholder="1"
|
|
||||||
placeholderTextColor="#9ca3af"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Text style={styles.label}>Cache Claim Distance (metres)</Text>
|
<Text style={styles.label}>Cache Claim Distance (metres)</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
@@ -136,7 +420,7 @@ const GameSettingsScreen = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.codeSection}>
|
<View style={styles.codeSection}>
|
||||||
<Text style={styles.codeLabel}>Player Join Code</Text>
|
<Text style={styles.codeLabel}>Game Join Code</Text>
|
||||||
<Text style={styles.codeValue}>{memberJoinCode || '—'}</Text>
|
<Text style={styles.codeValue}>{memberJoinCode || '—'}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -150,6 +434,46 @@ const GameSettingsScreen = () => {
|
|||||||
/>
|
/>
|
||||||
</ButtonTray>
|
</ButtonTray>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Admin Waitlist */}
|
||||||
|
{waitlist.length > 0 && (
|
||||||
|
<View style={styles.waitlistSection}>
|
||||||
|
<Text style={styles.sectionTitle}>Admin Waitlist</Text>
|
||||||
|
{waitlist.map((entry) => (
|
||||||
|
<Card key={entry.id}>
|
||||||
|
<View style={styles.waitlistRow}>
|
||||||
|
<Text style={styles.waitlistName}>
|
||||||
|
{waitlistNames[entry.Uid] || `User ${entry.Uid}`}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.waitlistActions}>
|
||||||
|
<Button
|
||||||
|
label="Approve"
|
||||||
|
onClick={() => handleApproveAdmin(entry)}
|
||||||
|
styleButton={styles.approveButton}
|
||||||
|
styleLabel={styles.approveBtnLabel}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Reject"
|
||||||
|
onClick={() => handleRejectAdmin(entry)}
|
||||||
|
styleButton={styles.rejectButton}
|
||||||
|
styleLabel={styles.rejectBtnLabel}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reset Entire Game */}
|
||||||
|
<View style={styles.resetSection}>
|
||||||
|
<Button
|
||||||
|
label="Reset Entire Game"
|
||||||
|
onClick={handleResetGame}
|
||||||
|
styleButton={styles.resetButton}
|
||||||
|
styleLabel={styles.resetLabel}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
@@ -158,18 +482,18 @@ const GameSettingsScreen = () => {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
center: {justifyContent: 'center', alignItems: 'center'},
|
center: {justifyContent: 'center', alignItems: 'center'},
|
||||||
formContainer: {paddingBottom: 20},
|
formContainer: {paddingBottom: 20},
|
||||||
sectionTitle: {fontSize: 20, fontWeight: '700', color: '#1f2937', marginBottom: 16},
|
sectionTitle: {fontSize: 20, fontWeight: '700', color: '#cdd6f4', marginBottom: 16},
|
||||||
label: {fontSize: 14, fontWeight: '600', color: '#374151', marginBottom: 6, marginTop: 14},
|
label: {fontSize: 14, fontWeight: '600', color: '#bac2de', marginBottom: 6, marginTop: 14},
|
||||||
body: {color: '#4b5563', fontSize: 15},
|
body: {color: '#bac2de', fontSize: 15},
|
||||||
input: {
|
input: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#d1d5db',
|
borderColor: '#45475a',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: '#1f2937',
|
color: '#cdd6f4',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#313244',
|
||||||
},
|
},
|
||||||
toggleRow: {
|
toggleRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -178,9 +502,9 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: 16,
|
marginTop: 16,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: '#e5e7eb',
|
borderBottomColor: '#45475a',
|
||||||
},
|
},
|
||||||
toggleLabel: {fontSize: 14, fontWeight: '600', color: '#374151'},
|
toggleLabel: {fontSize: 14, fontWeight: '600', color: '#bac2de'},
|
||||||
codeSection: {
|
codeSection: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
@@ -188,14 +512,44 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: 16,
|
marginTop: 16,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
backgroundColor: '#f3f4f6',
|
backgroundColor: '#313244',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
},
|
},
|
||||||
codeLabel: {fontSize: 14, fontWeight: '600', color: '#374151'},
|
codeLabel: {fontSize: 14, fontWeight: '600', color: '#bac2de'},
|
||||||
codeValue: {fontSize: 16, fontWeight: '700', color: '#2563eb', letterSpacing: 2},
|
codeValue: {fontSize: 16, fontWeight: '700', color: '#bd93f9', letterSpacing: 2},
|
||||||
saveWrap: {marginTop: 24},
|
saveWrap: {marginTop: 24},
|
||||||
saveButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'},
|
saveButton: {backgroundColor: '#a6e3a1', borderColor: '#a6e3a1'},
|
||||||
saveLabel: {color: '#ffffff', fontWeight: '600'},
|
saveLabel: {color: '#1e1e2e', fontWeight: '600'},
|
||||||
|
deptSection: {marginTop: 30},
|
||||||
|
deptIndexLabel: {fontSize: 14, fontWeight: '700', color: '#bac2de', marginBottom: 8},
|
||||||
|
deptCardRow: {flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8},
|
||||||
|
deptCardInfo: {flex: 1, paddingRight: 10},
|
||||||
|
deptCardActions: {flexDirection: 'row', gap: 6, flex: 0},
|
||||||
|
deptInfoLabel: {fontSize: 12, fontWeight: '600', color: '#6c7086', marginBottom: 2},
|
||||||
|
deptName: {fontSize: 15, fontWeight: '700', color: '#cdd6f4'},
|
||||||
|
deptCode: {fontSize: 13, fontWeight: '600', color: '#bd93f9', marginTop: 2, letterSpacing: 1},
|
||||||
|
deptDeleteButton: {backgroundColor: '#D92800', borderColor: '#D92800', minHeight: 36, flex: 0, paddingHorizontal: 10},
|
||||||
|
deptOpenButton: {backgroundColor: '#bd93f9', borderColor: '#bd93f9', minHeight: 36, flex: 0, paddingHorizontal: 10},
|
||||||
|
deptBtnLabel: {color: '#1e1e2e', fontWeight: '600', fontSize: 13},
|
||||||
|
createDeptRow: {flexDirection: 'row', gap: 10, marginTop: 12, alignItems: 'center'},
|
||||||
|
createDeptButton: {backgroundColor: '#a6e3a1', borderColor: '#a6e3a1', flex: 0, paddingHorizontal: 16},
|
||||||
|
createDeptLabel: {color: '#1e1e2e', fontWeight: '600'},
|
||||||
|
emptyText: {color: '#6c7086', textAlign: 'center', marginTop: 10, fontSize: 14},
|
||||||
|
waitlistSection: {marginTop: 30},
|
||||||
|
waitlistRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
waitlistName: {fontSize: 15, fontWeight: '600', color: '#cdd6f4', flex: 1},
|
||||||
|
waitlistActions: {flexDirection: 'row', gap: 6},
|
||||||
|
approveButton: {backgroundColor: '#a6e3a1', borderColor: '#a6e3a1', minHeight: 36, flex: 0, paddingHorizontal: 10},
|
||||||
|
approveBtnLabel: {color: '#1e1e2e', fontWeight: '600', fontSize: 13},
|
||||||
|
rejectButton: {backgroundColor: '#D92800', borderColor: '#D92800', minHeight: 36, flex: 0, paddingHorizontal: 10},
|
||||||
|
rejectBtnLabel: {color: '#1e1e2e', fontWeight: '600', fontSize: 13},
|
||||||
|
resetSection: {marginTop: 30},
|
||||||
|
resetButton: {backgroundColor: '#D92800', borderColor: '#D92800'},
|
||||||
|
resetLabel: {color: '#ffffff', fontWeight: '600'},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default GameSettingsScreen;
|
export default GameSettingsScreen;
|
||||||
|
|||||||
@@ -1,24 +1,35 @@
|
|||||||
import React, {useCallback, useEffect, useState} from 'react';
|
import React, {useCallback, useEffect, useState} from 'react';
|
||||||
import {ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View} from 'react-native';
|
import {ActivityIndicator, Alert, Pressable, ScrollView, StyleSheet, Text, View} from 'react-native';
|
||||||
import Screen from '../layout/Screen';
|
import Screen from '../layout/Screen';
|
||||||
import Card from '../UI/Card';
|
import Card from '../UI/Card';
|
||||||
|
import {Button} from '../UI/Button';
|
||||||
|
import PendingApprovalView from '../UI/PendingApprovalView';
|
||||||
import useGameHook from '../../hooks/useGameHook';
|
import useGameHook from '../../hooks/useGameHook';
|
||||||
import {getSession} from '../../hooks/SessionStore';
|
import {getSession} from '../../hooks/SessionStore';
|
||||||
|
|
||||||
const LeaderboardScreen = () => {
|
const LeaderboardScreen = ({navigation, route}) => {
|
||||||
// Initialisation ------------
|
// Initialisation ------------
|
||||||
|
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
const {getLobby, getTeams, getTeamMembers, getGroupMembers, getCaches, getUser} = useGameHook();
|
const isAdmin = session.isAcceptedAdmin;
|
||||||
|
const isBusiness = Boolean(session.isBusiness);
|
||||||
|
const selectedDepartment = route?.params?.department || null;
|
||||||
|
const effectiveSGid = isBusiness
|
||||||
|
? ((isAdmin && selectedDepartment?.SGid) ? selectedDepartment.SGid : (isAdmin ? null : session.currentSGid))
|
||||||
|
: null;
|
||||||
|
const {getLobby, getSubgroup, getTeams, getTeamMembers, getGroupMembers, getCaches, getUser, deleteTeam, resetPlayerProgress, resetTeamProgress, getSubgroups} = useGameHook();
|
||||||
|
|
||||||
// State ----------------------
|
// State ----------------------
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [teamsEnabled, setTeamsEnabled] = useState(false);
|
const [teamsEnabled, setTeamsEnabled] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState('global');
|
const [activeTab, setActiveTab] = useState('game');
|
||||||
const [globalRanking, setGlobalRanking] = useState([]);
|
const [teamRankings, setTeamRankings] = useState([]);
|
||||||
const [teamRanking, setTeamRanking] = useState([]);
|
const [playerRankings, setPlayerRankings] = useState([]);
|
||||||
|
const [myTeamRanking, setMyTeamRanking] = useState([]);
|
||||||
const [expandedTeam, setExpandedTeam] = useState(null);
|
const [expandedTeam, setExpandedTeam] = useState(null);
|
||||||
|
const [totalCacheCount, setTotalCacheCount] = useState(0);
|
||||||
|
const [departmentList, setDepartmentList] = useState([]);
|
||||||
|
|
||||||
// Handlers -------------------
|
// Handlers -------------------
|
||||||
|
|
||||||
@@ -26,15 +37,28 @@ const LeaderboardScreen = () => {
|
|||||||
if (!session.currentGid) { setLoading(false); return; }
|
if (!session.currentGid) { setLoading(false); return; }
|
||||||
|
|
||||||
const group = await getLobby(session.currentGid);
|
const group = await getLobby(session.currentGid);
|
||||||
const isTeams = Boolean(group?.TeamsEnabled);
|
|
||||||
|
let isTeams = Boolean(group?.TeamsEnabled);
|
||||||
|
if (effectiveSGid !== null && effectiveSGid !== undefined) {
|
||||||
|
const subgroup = await getSubgroup(effectiveSGid);
|
||||||
|
if (subgroup) isTeams = Boolean(subgroup.TeamsEnabled);
|
||||||
|
}
|
||||||
setTeamsEnabled(isTeams);
|
setTeamsEnabled(isTeams);
|
||||||
|
|
||||||
const caches = await getCaches(session.currentGid, null);
|
if (isBusiness && isAdmin && !selectedDepartment) {
|
||||||
const claimedCaches = (caches || []).filter((c) => c.ClaimedByUid);
|
const sgs = await getSubgroups(session.currentGid);
|
||||||
|
setDepartmentList((sgs || []).filter((sg) => !sg.IsAdminGroup));
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const caches = await getCaches(session.currentGid, effectiveSGid ?? null);
|
||||||
|
const total = (caches || []).length;
|
||||||
|
setTotalCacheCount(total);
|
||||||
|
|
||||||
if (isTeams) {
|
if (isTeams) {
|
||||||
// Build team rankings
|
// Build team rankings
|
||||||
const teams = await getTeams(session.currentGid);
|
const teams = await getTeams(session.currentGid, effectiveSGid ?? null);
|
||||||
const teamData = [];
|
const teamData = [];
|
||||||
|
|
||||||
for (const team of (teams || [])) {
|
for (const team of (teams || [])) {
|
||||||
@@ -42,7 +66,9 @@ const LeaderboardScreen = () => {
|
|||||||
const memberData = [];
|
const memberData = [];
|
||||||
for (const m of (members || [])) {
|
for (const m of (members || [])) {
|
||||||
const user = await getUser(m.Uid);
|
const user = await getUser(m.Uid);
|
||||||
const count = claimedCaches.filter((c) => c.ClaimedByUid === m.Uid).length;
|
const count = (caches || []).filter((c) =>
|
||||||
|
(c.Claims || []).some((cl) => cl.Uid === m.Uid)
|
||||||
|
).length;
|
||||||
memberData.push({
|
memberData.push({
|
||||||
uid: m.Uid,
|
uid: m.Uid,
|
||||||
name: user?.username || `Player ${m.Uid}`,
|
name: user?.username || `Player ${m.Uid}`,
|
||||||
@@ -51,7 +77,9 @@ const LeaderboardScreen = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
memberData.sort((a, b) => b.caches - a.caches);
|
memberData.sort((a, b) => b.caches - a.caches);
|
||||||
const totalCaches = memberData.reduce((sum, m) => sum + m.caches, 0);
|
const totalCaches = (caches || []).filter((c) =>
|
||||||
|
(c.Claims || []).some((cl) => cl.Tid === team.Tid)
|
||||||
|
).length;
|
||||||
teamData.push({
|
teamData.push({
|
||||||
tid: team.Tid,
|
tid: team.Tid,
|
||||||
name: team.TeamName || 'Unnamed Team',
|
name: team.TeamName || 'Unnamed Team',
|
||||||
@@ -62,20 +90,22 @@ const LeaderboardScreen = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
teamData.sort((a, b) => b.totalCaches - a.totalCaches);
|
teamData.sort((a, b) => b.totalCaches - a.totalCaches);
|
||||||
setGlobalRanking(teamData);
|
setTeamRankings(teamData);
|
||||||
|
|
||||||
// Build current team ranking
|
// Build current user's team ranking (for TEAM tab)
|
||||||
if (session.currentTid) {
|
if (session.currentTid) {
|
||||||
const myTeam = teamData.find((t) => t.tid === session.currentTid);
|
const myTeam = teamData.find((t) => t.tid === session.currentTid);
|
||||||
setTeamRanking(myTeam ? myTeam.members : []);
|
setMyTeamRanking(myTeam ? myTeam.members : []);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Build player rankings (no teams)
|
// Build player rankings (no teams)
|
||||||
const allMembers = await getGroupMembers(session.currentGid);
|
const scopedMembers = await getGroupMembers(session.currentGid, effectiveSGid ?? null);
|
||||||
const playerData = [];
|
const playerData = [];
|
||||||
for (const m of (allMembers || []).filter((m) => !m.IsAcceptedAdmin)) {
|
for (const m of (scopedMembers || []).filter((mem) => !mem.IsAcceptedAdmin)) {
|
||||||
const user = await getUser(m.Uid);
|
const user = await getUser(m.Uid);
|
||||||
const count = claimedCaches.filter((c) => c.ClaimedByUid === m.Uid).length;
|
const count = (caches || []).filter((c) =>
|
||||||
|
(c.Claims || []).some((cl) => cl.Uid === m.Uid)
|
||||||
|
).length;
|
||||||
playerData.push({
|
playerData.push({
|
||||||
uid: m.Uid,
|
uid: m.Uid,
|
||||||
name: user?.username || `Player ${m.Uid}`,
|
name: user?.username || `Player ${m.Uid}`,
|
||||||
@@ -83,14 +113,60 @@ const LeaderboardScreen = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
playerData.sort((a, b) => b.caches - a.caches);
|
playerData.sort((a, b) => b.caches - a.caches);
|
||||||
setGlobalRanking(playerData);
|
setPlayerRankings(playerData);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [session.currentGid, session.currentTid]);
|
}, [session.currentGid, session.currentTid, session.currentSGid, isBusiness, isAdmin, selectedDepartment, selectedDepartment?.SGid, effectiveSGid]);
|
||||||
|
|
||||||
useEffect(() => { loadData(); }, [loadData]);
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
|
|
||||||
|
// Admin handler: delete a team and reload
|
||||||
|
const handleDeleteTeam = (team) => {
|
||||||
|
Alert.alert('Delete Team', `Delete "${team.name}" and kick all its members?`, [
|
||||||
|
{text: 'Cancel', style: 'cancel'},
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
await deleteTeam(team.tid);
|
||||||
|
await loadData();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admin handler: reset a single player's progress
|
||||||
|
const handleResetPlayer = (player) => {
|
||||||
|
Alert.alert('Reset Progress', `Reset all cache claims for ${player.name}?`, [
|
||||||
|
{text: 'Cancel', style: 'cancel'},
|
||||||
|
{
|
||||||
|
text: 'Reset',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
await resetPlayerProgress(session.currentGid, player.uid, effectiveSGid);
|
||||||
|
Alert.alert('Done', `Progress for ${player.name} has been reset.`);
|
||||||
|
await loadData();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetTeam = (team) => {
|
||||||
|
Alert.alert('Reset Team Progress', `Reset all cache claims for team "${team.name}"?`, [
|
||||||
|
{text: 'Cancel', style: 'cancel'},
|
||||||
|
{
|
||||||
|
text: 'Reset',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
await resetTeamProgress(session.currentGid, team.tid, effectiveSGid);
|
||||||
|
Alert.alert('Done', `Progress for team "${team.name}" has been reset.`);
|
||||||
|
await loadData();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
// View -----------------------
|
// View -----------------------
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -109,64 +185,212 @@ const LeaderboardScreen = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Teams enabled — tabbed view
|
if (session.isPendingAdmin && !session.isAcceptedAdmin) {
|
||||||
|
return <PendingApprovalView/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBusiness && isAdmin && !selectedDepartment) {
|
||||||
|
return (
|
||||||
|
<Screen style={styles.container}>
|
||||||
|
<Text style={styles.departmentTitle}>Departments</Text>
|
||||||
|
<ScrollView style={styles.listSection}>
|
||||||
|
{departmentList.map((department) => (
|
||||||
|
<Pressable
|
||||||
|
key={department.SGid}
|
||||||
|
onPress={() => navigation.navigate('LeaderboardScreen', {department})}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<View style={styles.rankRow}>
|
||||||
|
<Text style={styles.rankName}>{department.SubGroupName}</Text>
|
||||||
|
<Text style={styles.openLabel}>Open</Text>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
{departmentList.length === 0 && (
|
||||||
|
<Text style={styles.emptyText}>No departments yet.</Text>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: render an expandable team card (admin version with delete + reset)
|
||||||
|
const renderAdminTeamCard = (team, index) => (
|
||||||
|
<Card key={team.tid}>
|
||||||
|
<Pressable onPress={() => setExpandedTeam(expandedTeam === team.tid ? null : team.tid)}>
|
||||||
|
<View style={styles.rankRow}>
|
||||||
|
<Text style={styles.rank}>#{index + 1}</Text>
|
||||||
|
<Text style={styles.rankName}>{team.name} ({team.code})</Text>
|
||||||
|
<Text style={styles.score}>{team.totalCaches}/{totalCacheCount}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
{expandedTeam === team.tid && (
|
||||||
|
<View>
|
||||||
|
{team.members.map((m) => (
|
||||||
|
// Highlight the current user's row so they can identify themselves
|
||||||
|
<View key={m.uid} style={[styles.subRow, m.uid === session.currentUid && styles.selfSubRow]}>
|
||||||
|
<Text style={[styles.subName, {flex: 1}]}>
|
||||||
|
{m.name}{m.isLeader ? ' (Team Leader)' : ''}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.subScore}>{m.caches}/{totalCacheCount}</Text>
|
||||||
|
<Pressable
|
||||||
|
style={styles.resetMiniButton}
|
||||||
|
onPress={() => handleResetPlayer(m)}
|
||||||
|
>
|
||||||
|
<Text style={styles.resetMiniLabel}>Reset</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<View style={styles.deleteTeamWrap}>
|
||||||
|
<Button
|
||||||
|
label="Reset Team Progress"
|
||||||
|
onClick={() => handleResetTeam(team)}
|
||||||
|
styleButton={styles.resetTeamButton}
|
||||||
|
styleLabel={styles.deleteTeamLabel}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Delete Team"
|
||||||
|
onClick={() => handleDeleteTeam(team)}
|
||||||
|
styleButton={styles.deleteTeamButton}
|
||||||
|
styleLabel={styles.deleteTeamDeleteLabel}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper: render an expandable team card (player version)
|
||||||
|
const renderTeamCard = (team, index) => (
|
||||||
|
<Pressable key={team.tid} onPress={() => setExpandedTeam(expandedTeam === team.tid ? null : team.tid)}>
|
||||||
|
<Card>
|
||||||
|
<View style={styles.rankRow}>
|
||||||
|
<Text style={styles.rank}>#{index + 1}</Text>
|
||||||
|
<Text style={styles.rankName}>{team.name} ({team.code})</Text>
|
||||||
|
<Text style={styles.score}>{team.totalCaches}/{totalCacheCount}</Text>
|
||||||
|
</View>
|
||||||
|
{expandedTeam === team.tid && team.members.map((m) => (
|
||||||
|
// Highlight the current user's row so they can identify themselves
|
||||||
|
<View key={m.uid} style={[styles.subRow, m.uid === session.currentUid && styles.selfSubRow]}>
|
||||||
|
<Text style={styles.subName}>
|
||||||
|
{m.name}{m.isLeader ? ' (Team Leader)' : ''}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.subScore}>{m.caches}/{totalCacheCount}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper: render an admin player card (with reset button)
|
||||||
|
const renderAdminPlayerCard = (player, index) => (
|
||||||
|
// Highlight the current user's card so they can identify themselves
|
||||||
|
<Card key={player.uid} style={player.uid === session.currentUid ? styles.selfCard : undefined}>
|
||||||
|
<View style={styles.rankRow}>
|
||||||
|
<Text style={styles.rank}>#{index + 1}</Text>
|
||||||
|
<Text style={styles.rankName}>{player.name}</Text>
|
||||||
|
<Text style={styles.score}>{player.caches}/{totalCacheCount}</Text>
|
||||||
|
<Pressable
|
||||||
|
style={styles.resetMiniButton}
|
||||||
|
onPress={() => handleResetPlayer(player)}
|
||||||
|
>
|
||||||
|
<Text style={styles.resetMiniLabel}>Reset</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper: render a simple player card
|
||||||
|
const renderPlayerCard = (player, index) => (
|
||||||
|
// Highlight the current user's card so they can identify themselves
|
||||||
|
<Card key={player.uid} style={player.uid === session.currentUid ? styles.selfCard : undefined}>
|
||||||
|
<View style={styles.rankRow}>
|
||||||
|
<Text style={styles.rank}>#{index + 1}</Text>
|
||||||
|
<Text style={styles.rankName}>{player.name}</Text>
|
||||||
|
<Text style={styles.score}>{player.caches}/{totalCacheCount}</Text>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------- ADMIN LEADERBOARD ----------
|
||||||
|
if (isAdmin) {
|
||||||
|
// Teams enabled: show team list (no tabs for admin)
|
||||||
|
if (teamsEnabled) {
|
||||||
|
return (
|
||||||
|
<Screen style={styles.container} showBack={Boolean(selectedDepartment)}>
|
||||||
|
{selectedDepartment && (
|
||||||
|
<Text style={styles.departmentTitle}>{selectedDepartment.SubGroupName}</Text>
|
||||||
|
)}
|
||||||
|
<ScrollView style={styles.listSection}>
|
||||||
|
{teamRankings.map((team, index) => renderAdminTeamCard(team, index))}
|
||||||
|
{teamRankings.length === 0 && (
|
||||||
|
<Text style={styles.emptyText}>No teams yet.</Text>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teams disabled: show player list with reset buttons
|
||||||
|
return (
|
||||||
|
<Screen style={styles.container} showBack={Boolean(selectedDepartment)}>
|
||||||
|
{selectedDepartment && (
|
||||||
|
<Text style={styles.departmentTitle}>{selectedDepartment.SubGroupName}</Text>
|
||||||
|
)}
|
||||||
|
<ScrollView style={styles.listSection}>
|
||||||
|
{playerRankings.map((player, index) => renderAdminPlayerCard(player, index))}
|
||||||
|
{playerRankings.length === 0 && (
|
||||||
|
<Text style={styles.emptyText}>No players yet.</Text>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- PLAYER LEADERBOARD ----------
|
||||||
|
|
||||||
|
// Teams enabled — tabbed view (GAME / TEAM)
|
||||||
if (teamsEnabled) {
|
if (teamsEnabled) {
|
||||||
return (
|
return (
|
||||||
<Screen style={styles.container}>
|
<Screen style={styles.container}>
|
||||||
<View style={styles.tabRow}>
|
<View style={styles.tabRow}>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[styles.tabButton, activeTab === 'global' && styles.tabButtonActive]}
|
style={[styles.tabButton, activeTab === 'game' && styles.tabButtonActive]}
|
||||||
onPress={() => setActiveTab('global')}
|
onPress={() => setActiveTab('game')}
|
||||||
>
|
>
|
||||||
<Text style={[styles.tabLabel, activeTab === 'global' && styles.tabLabelActive]}>Global</Text>
|
<Text style={[styles.tabLabel, activeTab === 'game' && styles.tabLabelActive]}>GAME</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[styles.tabButton, activeTab === 'team' && styles.tabButtonActive]}
|
style={[styles.tabButton, activeTab === 'team' && styles.tabButtonActive]}
|
||||||
onPress={() => setActiveTab('team')}
|
onPress={() => setActiveTab('team')}
|
||||||
>
|
>
|
||||||
<Text style={[styles.tabLabel, activeTab === 'team' && styles.tabLabelActive]}>Team</Text>
|
<Text style={[styles.tabLabel, activeTab === 'team' && styles.tabLabelActive]}>TEAM</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{activeTab === 'global' ? (
|
{activeTab === 'game' ? (
|
||||||
<ScrollView style={styles.listSection}>
|
<ScrollView style={styles.listSection}>
|
||||||
{globalRanking.map((team, index) => (
|
{teamRankings.map((team, index) => renderTeamCard(team, index))}
|
||||||
<Pressable key={team.tid} onPress={() => setExpandedTeam(expandedTeam === team.tid ? null : team.tid)}>
|
{teamRankings.length === 0 && (
|
||||||
<Card>
|
|
||||||
<View style={styles.rankRow}>
|
|
||||||
<Text style={styles.rank}>#{index + 1}</Text>
|
|
||||||
<Text style={styles.rankName}>{team.name}</Text>
|
|
||||||
<Text style={styles.score}>{team.totalCaches} cache(s)</Text>
|
|
||||||
</View>
|
|
||||||
{expandedTeam === team.tid && team.members.map((m) => (
|
|
||||||
<View key={m.uid} style={styles.subRow}>
|
|
||||||
<Text style={styles.subName}>
|
|
||||||
{m.name}{m.isLeader ? ' (Leader)' : ''}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.subScore}>{m.caches}</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</Card>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
{globalRanking.length === 0 && (
|
|
||||||
<Text style={styles.emptyText}>No teams yet.</Text>
|
<Text style={styles.emptyText}>No teams yet.</Text>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
) : (
|
) : (
|
||||||
<ScrollView style={styles.listSection}>
|
<ScrollView style={styles.listSection}>
|
||||||
{teamRanking.map((m, index) => (
|
{myTeamRanking.map((m, index) => (
|
||||||
<Card key={m.uid}>
|
// Highlight the current user's card so they can identify themselves
|
||||||
|
<Card key={m.uid} style={m.uid === session.currentUid ? styles.selfCard : undefined}>
|
||||||
<View style={styles.rankRow}>
|
<View style={styles.rankRow}>
|
||||||
<Text style={styles.rank}>#{index + 1}</Text>
|
<Text style={styles.rank}>#{index + 1}</Text>
|
||||||
<Text style={styles.rankName}>
|
<Text style={styles.rankName}>
|
||||||
{m.name}{m.isLeader ? ' (Leader)' : ''}
|
{m.name}{m.isLeader ? ' (Team Leader)' : ''}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.score}>{m.caches} cache(s)</Text>
|
<Text style={styles.score}>{m.caches}/{totalCacheCount}</Text>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
{teamRanking.length === 0 && (
|
{myTeamRanking.length === 0 && (
|
||||||
<Text style={styles.emptyText}>Join a team to see your team leaderboard.</Text>
|
<Text style={styles.emptyText}>Join a team to see your team leaderboard.</Text>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -175,20 +399,12 @@ const LeaderboardScreen = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No teams — simple player ranking
|
// No teams — simple player ranking (GAME tab only, no TEAM tab)
|
||||||
return (
|
return (
|
||||||
<Screen style={styles.container}>
|
<Screen style={styles.container}>
|
||||||
<ScrollView style={styles.listSection}>
|
<ScrollView style={styles.listSection}>
|
||||||
{globalRanking.map((player, index) => (
|
{playerRankings.map((player, index) => renderPlayerCard(player, index))}
|
||||||
<Card key={player.uid}>
|
{playerRankings.length === 0 && (
|
||||||
<View style={styles.rankRow}>
|
|
||||||
<Text style={styles.rank}>#{index + 1}</Text>
|
|
||||||
<Text style={styles.rankName}>{player.name}</Text>
|
|
||||||
<Text style={styles.score}>{player.caches} cache(s)</Text>
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
{globalRanking.length === 0 && (
|
|
||||||
<Text style={styles.emptyText}>No players yet.</Text>
|
<Text style={styles.emptyText}>No players yet.</Text>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -199,11 +415,13 @@ const LeaderboardScreen = () => {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
center: {justifyContent: 'center', alignItems: 'center'},
|
center: {justifyContent: 'center', alignItems: 'center'},
|
||||||
container: {padding: 0},
|
container: {padding: 0},
|
||||||
body: {color: '#4b5563', fontSize: 15},
|
body: {color: '#bac2de', fontSize: 15},
|
||||||
|
departmentTitle: {fontSize: 20, fontWeight: '700', color: '#cdd6f4', paddingHorizontal: 15, paddingTop: 15, marginBottom: 6},
|
||||||
|
openLabel: {fontSize: 13, fontWeight: '700', color: '#bd93f9'},
|
||||||
tabRow: {
|
tabRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: '#e5e7eb',
|
borderBottomColor: '#45475a',
|
||||||
},
|
},
|
||||||
tabButton: {
|
tabButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -212,30 +430,45 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
tabButtonActive: {
|
tabButtonActive: {
|
||||||
borderBottomWidth: 2,
|
borderBottomWidth: 2,
|
||||||
borderBottomColor: '#2563eb',
|
borderBottomColor: '#bd93f9',
|
||||||
},
|
},
|
||||||
tabLabel: {fontSize: 15, fontWeight: '600', color: '#6b7280'},
|
tabLabel: {fontSize: 15, fontWeight: '600', color: '#6c7086'},
|
||||||
tabLabelActive: {color: '#2563eb'},
|
tabLabelActive: {color: '#bd93f9'},
|
||||||
listSection: {flex: 1, paddingHorizontal: 15, paddingTop: 10},
|
listSection: {flex: 1, paddingHorizontal: 15, paddingTop: 10},
|
||||||
rankRow: {
|
rankRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 10,
|
gap: 10,
|
||||||
},
|
},
|
||||||
rank: {fontSize: 16, fontWeight: '700', color: '#374151', width: 32},
|
rank: {fontSize: 16, fontWeight: '700', color: '#bac2de', width: 32},
|
||||||
rankName: {flex: 1, fontSize: 15, fontWeight: '600', color: '#1f2937'},
|
rankName: {flex: 1, fontSize: 15, fontWeight: '600', color: '#cdd6f4'},
|
||||||
score: {fontSize: 14, fontWeight: '600', color: '#16a34a'},
|
score: {fontSize: 14, fontWeight: '600', color: '#a6e3a1'},
|
||||||
subRow: {
|
subRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
alignItems: 'center',
|
||||||
paddingLeft: 42,
|
paddingLeft: 42,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: '#f3f4f6',
|
borderTopColor: '#45475a',
|
||||||
},
|
},
|
||||||
subName: {fontSize: 13, color: '#4b5563'},
|
subName: {fontSize: 13, color: '#bac2de'},
|
||||||
subScore: {fontSize: 13, fontWeight: '600', color: '#16a34a'},
|
subScore: {fontSize: 13, fontWeight: '600', color: '#a6e3a1', marginRight: 8},
|
||||||
emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 30, fontSize: 14},
|
emptyText: {color: '#6c7086', textAlign: 'center', marginTop: 30, fontSize: 14},
|
||||||
|
deleteTeamWrap: {marginTop: 8, paddingLeft: 42, flexDirection: 'row', gap: 8},
|
||||||
|
resetTeamButton: {backgroundColor: '#f4a460', borderColor: '#f4a460', minHeight: 36, flex: 1},
|
||||||
|
deleteTeamButton: {backgroundColor: '#D92800', borderColor: '#D92800', minHeight: 36, flex: 1},
|
||||||
|
deleteTeamLabel: {color: '#1e1e2e', fontWeight: '600', fontSize: 13},
|
||||||
|
deleteTeamDeleteLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
|
||||||
|
resetMiniButton: {
|
||||||
|
backgroundColor: '#86efac',
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
marginLeft: 6,
|
||||||
|
},
|
||||||
|
resetMiniLabel: {color: '#1e1e2e', fontWeight: '600', fontSize: 12},
|
||||||
|
selfCard: {backgroundColor: 'rgba(166, 227, 161, 0.15)', borderColor: '#a6e3a1'},
|
||||||
|
selfSubRow: {backgroundColor: 'rgba(166, 227, 161, 0.15)'},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default LeaderboardScreen;
|
export default LeaderboardScreen;
|
||||||
@@ -5,16 +5,9 @@ import Screen from '../layout/Screen';
|
|||||||
import {Button, ButtonTray} from '../UI/Button';
|
import {Button, ButtonTray} from '../UI/Button';
|
||||||
|
|
||||||
const LoginScreen = ({ navigation }) => {
|
const LoginScreen = ({ navigation }) => {
|
||||||
// Initialisation ------------->
|
|
||||||
|
|
||||||
const { email, password, error, isLoading, setEmail, setPassword, login } = useAuthHook();
|
const { email, password, error, isLoading, setEmail, setPassword, login } = useAuthHook();
|
||||||
|
|
||||||
// State ----------------------
|
|
||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
// Handlers -------------------
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
const user = await login();
|
const user = await login();
|
||||||
@@ -29,8 +22,6 @@ const LoginScreen = ({ navigation }) => {
|
|||||||
navigation.reset({ index: 0, routes: [{ name: 'Game' }] });
|
navigation.reset({ index: 0, routes: [{ name: 'Game' }] });
|
||||||
};
|
};
|
||||||
|
|
||||||
// View -----------------------
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen style={styles.container}>
|
<Screen style={styles.container}>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
@@ -39,7 +30,7 @@ const LoginScreen = ({ navigation }) => {
|
|||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
placeholderTextColor="#9ca3af"
|
placeholderTextColor="#6c7086"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
value={email}
|
value={email}
|
||||||
@@ -49,7 +40,7 @@ const LoginScreen = ({ navigation }) => {
|
|||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
placeholderTextColor="#9ca3af"
|
placeholderTextColor="#6c7086"
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
value={password}
|
value={password}
|
||||||
onChangeText={setPassword}
|
onChangeText={setPassword}
|
||||||
@@ -87,33 +78,34 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
|
color: '#cdd6f4',
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#d1d5db',
|
borderColor: '#45475a',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: '#1f2937',
|
color: '#cdd6f4',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#313244',
|
||||||
},
|
},
|
||||||
errorText: {
|
errorText: {
|
||||||
color: '#dc2626',
|
color: '#D92800',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
marginTop: 5,
|
marginTop: 5,
|
||||||
},
|
},
|
||||||
submitButton: {
|
submitButton: {
|
||||||
backgroundColor: '#2563eb',
|
backgroundColor: '#bd93f9',
|
||||||
borderColor: '#2563eb',
|
borderColor: '#bd93f9',
|
||||||
},
|
},
|
||||||
submitText: {
|
submitText: {
|
||||||
color: '#ffffff',
|
color: '#1e1e2e',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
linkText: {
|
linkText: {
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: '#2563eb',
|
color: '#bd93f9',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,27 +1,31 @@
|
|||||||
import React, {useCallback, useEffect, useState} from 'react';
|
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||||
import {ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native';
|
import {ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native';
|
||||||
import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
|
import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
|
||||||
import * as Location from 'expo-location';
|
import * as Location from 'expo-location';
|
||||||
|
import {useFocusEffect} from '@react-navigation/native';
|
||||||
import Screen from '../layout/Screen';
|
import Screen from '../layout/Screen';
|
||||||
import {Button, ButtonTray} from '../UI/Button';
|
import {Button, ButtonTray} from '../UI/Button';
|
||||||
|
import PendingApprovalView from '../UI/PendingApprovalView';
|
||||||
import CacheCardItem from '../gameplay/CacheCardItem';
|
import CacheCardItem from '../gameplay/CacheCardItem';
|
||||||
import ClaimTimerView from '../gameplay/ClaimTimerView';
|
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, setPendingAdmin, setSelectedCache, setSessionGroup, setSessionTeam, setSessionUser} from '../../hooks/SessionStore';
|
||||||
import {getFovCone} from '../../utils/geoMath';
|
import {getFovCone} 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 MapScreen = ({navigation}) => {
|
const MapScreen = ({navigation, route}) => {
|
||||||
// Initialisation ------------
|
|
||||||
|
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
const {getCaches, claimCache, upsertCache, deleteCache, joinPrivateGame, createPrivateGame, getLobby, getUser} = useGameHook();
|
const isBusiness = Boolean(session.isBusiness);
|
||||||
|
const {getCaches, claimCache, upsertCache, deleteCache, joinPrivateGame, joinTeamByCode, createPrivateGame, getLobby, getUser, getSubgroups, getGroupByOrgCode} = useGameHook();
|
||||||
|
|
||||||
// State ----------------------
|
// Stable ref to prevent infinite re-render loops
|
||||||
|
const getCachesRef = useRef(getCaches);
|
||||||
|
getCachesRef.current = getCaches;
|
||||||
|
const lastAppliedRouteDepartmentRef = useRef(null);
|
||||||
|
|
||||||
const [inGame, setInGame] = useState(Boolean(session.currentGid));
|
const [inGame, setInGame] = useState(Boolean(session.currentGid));
|
||||||
const [isAdmin, setIsAdmin] = useState(session.isAcceptedAdmin);
|
const [isAdmin, setIsAdmin] = useState(session.isAcceptedAdmin);
|
||||||
@@ -32,6 +36,16 @@ const MapScreen = ({navigation}) => {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [gameCode, setGameCode] = useState('');
|
const [gameCode, setGameCode] = useState('');
|
||||||
const [groupInfo, setGroupInfo] = useState(null);
|
const [groupInfo, setGroupInfo] = useState(null);
|
||||||
|
const [selectedCacheId, setSelectedCacheIdState] = useState(session.selectedCacheId);
|
||||||
|
const [claimedPopupVisible, setClaimedPopupVisible] = useState(false);
|
||||||
|
const [subgroups, setSubgroups] = useState([]);
|
||||||
|
|
||||||
|
// Business join flow state
|
||||||
|
const [joinStep, setJoinStep] = useState(null);
|
||||||
|
const [orgCode, setOrgCode] = useState('');
|
||||||
|
const [deptCode, setDeptCode] = useState('');
|
||||||
|
const [matchedGroup, setMatchedGroup] = useState(null);
|
||||||
|
const [joinError, setJoinError] = useState('');
|
||||||
|
|
||||||
// Admin cache creation / editing state
|
// Admin cache creation / editing state
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
@@ -39,40 +53,108 @@ const MapScreen = ({navigation}) => {
|
|||||||
const [newCoord, setNewCoord] = useState(null);
|
const [newCoord, setNewCoord] = useState(null);
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const [newClue, setNewClue] = useState('');
|
const [newClue, setNewClue] = useState('');
|
||||||
|
const [selectedCacheSubgroupId, setSelectedCacheSubgroupId] = useState(null);
|
||||||
|
const [departmentDropdownOpen, setDepartmentDropdownOpen] = useState(false);
|
||||||
|
|
||||||
// Global claim distance comes from the group's CacheTriggerMeters setting
|
|
||||||
const claimDistance = groupInfo?.CacheTriggerMeters || 20;
|
const claimDistance = groupInfo?.CacheTriggerMeters || 20;
|
||||||
|
|
||||||
const isPlayer = inGame && !isAdmin;
|
const isPlayer = inGame && !isAdmin;
|
||||||
const {visibleCache, isClaiming, setIsClaiming} = usePlayerGame(
|
const requiresTeam = Boolean(isPlayer && groupInfo?.TeamsEnabled && !session.currentTid);
|
||||||
isPlayer ? userLocation : null,
|
const EMPTY_CACHES = useMemo(() => [], []);
|
||||||
isPlayer ? heading : null,
|
|
||||||
isPlayer ? cacheRecords : [],
|
// Caches not yet claimed by the current user's team (or user if no team)
|
||||||
|
const activeCachesForPlayer = useMemo(() => {
|
||||||
|
if (!isPlayer) return EMPTY_CACHES;
|
||||||
|
return cacheRecords.filter((cache) => {
|
||||||
|
const claims = cache.Claims || [];
|
||||||
|
if (session.currentTid) {
|
||||||
|
return !claims.some((c) => c.Tid === session.currentTid);
|
||||||
|
}
|
||||||
|
return !claims.some((c) => c.Uid === session.currentUid);
|
||||||
|
});
|
||||||
|
}, [cacheRecords, isPlayer, session.currentTid, session.currentUid, EMPTY_CACHES]);
|
||||||
|
|
||||||
|
const {visibleCaches, isClaiming, setIsClaiming} = usePlayerGame(
|
||||||
|
(isPlayer && !requiresTeam) ? userLocation : null,
|
||||||
|
(isPlayer && !requiresTeam) ? heading : null,
|
||||||
|
requiresTeam ? EMPTY_CACHES : activeCachesForPlayer,
|
||||||
claimDistance,
|
claimDistance,
|
||||||
|
selectedCacheId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const mapRegion = userLocation
|
const mapRegion = userLocation
|
||||||
? {...userLocation, latitudeDelta: 0.01, longitudeDelta: 0.01}
|
? {...userLocation, latitudeDelta: 0.01, longitudeDelta: 0.01}
|
||||||
: DEFAULT_REGION;
|
: DEFAULT_REGION;
|
||||||
|
|
||||||
// Handlers -------------------
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session.currentGid) return;
|
if (!session.currentGid) return;
|
||||||
getLobby(session.currentGid).then(setGroupInfo);
|
getLobby(session.currentGid).then(setGroupInfo);
|
||||||
|
getSubgroups(session.currentGid).then(setSubgroups);
|
||||||
}, [inGame]);
|
}, [inGame]);
|
||||||
|
|
||||||
|
// Default member subgroup SGid for cache creation (first non-admin subgroup)
|
||||||
|
const departments = useMemo(
|
||||||
|
() => subgroups.filter((sg) => !sg.IsAdminGroup),
|
||||||
|
[subgroups],
|
||||||
|
);
|
||||||
|
const defaultMemberSGid = departments[0]?.SGid || null;
|
||||||
|
const departmentNamesById = useMemo(() => {
|
||||||
|
const map = {};
|
||||||
|
for (const subgroup of departments) {
|
||||||
|
map[String(subgroup.SGid)] = subgroup.SubGroupName || `Department ${subgroup.SGid}`;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [departments]);
|
||||||
|
const routeDepartmentSGid = route?.params?.selectedDepartmentSGid;
|
||||||
|
const isDepartmentScopedMap = routeDepartmentSGid !== null && routeDepartmentSGid !== undefined;
|
||||||
|
const effectiveSGid = isBusiness
|
||||||
|
? (isAdmin ? (isDepartmentScopedMap ? routeDepartmentSGid : null) : (session.currentSGid ?? null))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdmin || departments.length === 0) return;
|
||||||
|
const routeSelectedSgid = route?.params?.selectedDepartmentSGid;
|
||||||
|
const hasRouteDepartment = departments.some((sg) => String(sg.SGid) === String(routeSelectedSgid));
|
||||||
|
const shouldApplyRouteDepartment =
|
||||||
|
hasRouteDepartment && String(lastAppliedRouteDepartmentRef.current) !== String(routeSelectedSgid);
|
||||||
|
if (shouldApplyRouteDepartment) {
|
||||||
|
setSelectedCacheSubgroupId(routeSelectedSgid);
|
||||||
|
lastAppliedRouteDepartmentRef.current = routeSelectedSgid;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentStillValid = departments.some((sg) => String(sg.SGid) === String(selectedCacheSubgroupId));
|
||||||
|
if (!currentStillValid) {
|
||||||
|
setSelectedCacheSubgroupId(defaultMemberSGid);
|
||||||
|
}
|
||||||
|
}, [route?.params?.selectedDepartmentSGid, departments, isAdmin, selectedCacheSubgroupId, defaultMemberSGid]);
|
||||||
|
|
||||||
const loadCaches = useCallback(async () => {
|
const loadCaches = useCallback(async () => {
|
||||||
if (!session.currentGid) {
|
const currentGid = getSession().currentGid;
|
||||||
|
if (!currentGid) {
|
||||||
setCacheRecords([]);
|
setCacheRecords([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rows = await getCaches(session.currentGid, null);
|
const rows = await getCachesRef.current(currentGid, effectiveSGid ?? null);
|
||||||
setCacheRecords(rows || []);
|
setCacheRecords(rows || []);
|
||||||
}, [inGame, isAdmin]);
|
}, [effectiveSGid]);
|
||||||
|
|
||||||
|
// Run once on mount
|
||||||
useEffect(() => { loadCaches(); }, [loadCaches]);
|
useEffect(() => { loadCaches(); }, [loadCaches]);
|
||||||
|
|
||||||
|
// Re-fetch caches when screen gains focus
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => { loadCaches(); }, [loadCaches])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-select first unclaimed cache when list loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlayer || activeCachesForPlayer.length === 0) return;
|
||||||
|
if (selectedCacheId && activeCachesForPlayer.some((c) => c.id === selectedCacheId)) return;
|
||||||
|
const firstId = activeCachesForPlayer[0].id;
|
||||||
|
setSelectedCacheIdState(firstId);
|
||||||
|
setSelectedCache(firstId);
|
||||||
|
}, [activeCachesForPlayer, isPlayer]);
|
||||||
|
|
||||||
// Location tracking
|
// Location tracking
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!inGame) return;
|
if (!inGame) return;
|
||||||
@@ -87,7 +169,6 @@ const MapScreen = ({navigation}) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try cached position first for instant load, fall back to fresh fix
|
|
||||||
const last = await Location.getLastKnownPositionAsync();
|
const last = await Location.getLastKnownPositionAsync();
|
||||||
if (last) {
|
if (last) {
|
||||||
setUserLocation({latitude: last.coords.latitude, longitude: last.coords.longitude});
|
setUserLocation({latitude: last.coords.latitude, longitude: last.coords.longitude});
|
||||||
@@ -102,7 +183,6 @@ const MapScreen = ({navigation}) => {
|
|||||||
(next) => setUserLocation({latitude: next.coords.latitude, longitude: next.coords.longitude}),
|
(next) => setUserLocation({latitude: next.coords.latitude, longitude: next.coords.longitude}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use OS-fused heading from expo-location
|
|
||||||
headingSub = await Location.watchHeadingAsync((headingData) => {
|
headingSub = await Location.watchHeadingAsync((headingData) => {
|
||||||
const raw = headingData.trueHeading >= 0 ? headingData.trueHeading : headingData.magHeading;
|
const raw = headingData.trueHeading >= 0 ? headingData.trueHeading : headingData.magHeading;
|
||||||
setHeading(raw);
|
setHeading(raw);
|
||||||
@@ -121,6 +201,14 @@ const MapScreen = ({navigation}) => {
|
|||||||
const result = await joinPrivateGame({JoinCode: gameCode.trim().toUpperCase(), Uid: session.currentUid});
|
const result = await joinPrivateGame({JoinCode: gameCode.trim().toUpperCase(), Uid: session.currentUid});
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
setSessionGroup(result.Gid, result.SGid);
|
setSessionGroup(result.Gid, result.SGid);
|
||||||
|
setPendingAdmin(false);
|
||||||
|
|
||||||
|
const freshUser = await getUser(session.currentUid);
|
||||||
|
if (freshUser) {
|
||||||
|
setSessionUser(freshUser);
|
||||||
|
setSessionTeam(freshUser.TGid ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
setInGame(true);
|
setInGame(true);
|
||||||
setIsAdmin(false);
|
setIsAdmin(false);
|
||||||
navigation.navigate('TeamScreen');
|
navigation.navigate('TeamScreen');
|
||||||
@@ -134,77 +222,171 @@ const MapScreen = ({navigation}) => {
|
|||||||
MaxMemberSubgroups: 1,
|
MaxMemberSubgroups: 1,
|
||||||
});
|
});
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
|
setPendingAdmin(false);
|
||||||
const freshUser = await getUser(session.currentUid);
|
const freshUser = await getUser(session.currentUid);
|
||||||
if (freshUser) {
|
if (freshUser) {
|
||||||
setSessionUser(freshUser);
|
setSessionUser(freshUser);
|
||||||
setInGame(true);
|
setInGame(true);
|
||||||
setIsAdmin(true);
|
setIsAdmin(true);
|
||||||
navigation.navigate('GameSettingsScreen');
|
navigation.reset({index: 0, routes: [{name: 'Admin'}]});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClaim = async (cacheId) => {
|
// Business — create a new company
|
||||||
|
const handleCreateCompany = async () => {
|
||||||
|
const result = await createPrivateGame({
|
||||||
|
GroupName: 'My Company',
|
||||||
|
BusinessOrSchoolName: 'My Company',
|
||||||
|
CreatedByUid: session.currentUid,
|
||||||
|
TeamsEnabled: false,
|
||||||
|
MaxMemberSubgroups: 1,
|
||||||
|
isBusiness: true,
|
||||||
|
});
|
||||||
|
if (!result) return;
|
||||||
|
setPendingAdmin(false);
|
||||||
|
const freshUser = await getUser(session.currentUid);
|
||||||
|
if (freshUser) {
|
||||||
|
setSessionUser(freshUser);
|
||||||
|
setInGame(true);
|
||||||
|
setIsAdmin(true);
|
||||||
|
navigation.reset({index: 0, routes: [{name: 'Admin'}]});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Business — verify the org code
|
||||||
|
const handleVerifyOrgCode = async () => {
|
||||||
|
setJoinError('');
|
||||||
|
if (!orgCode.trim()) return;
|
||||||
|
const group = await getGroupByOrgCode(orgCode.trim().toUpperCase());
|
||||||
|
if (!group) {
|
||||||
|
setJoinError('Organisation not found. Check your code and try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMatchedGroup(group);
|
||||||
|
setJoinStep('deptCode');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Business — join a department within the matched org
|
||||||
|
const handleJoinDepartment = async () => {
|
||||||
|
setJoinError('');
|
||||||
|
if (!deptCode.trim()) return;
|
||||||
|
const enteredCode = deptCode.trim().toUpperCase();
|
||||||
|
const adminCode = String(matchedGroup?.AdminJoinCode || '').trim().toUpperCase();
|
||||||
|
|
||||||
|
// If the code matches the org admin code, submit an admin waitlist request
|
||||||
|
if (adminCode && enteredCode === adminCode) {
|
||||||
|
const waitlistResult = await joinTeamByCode({
|
||||||
|
JoinCode: enteredCode,
|
||||||
|
Uid: session.currentUid,
|
||||||
|
ExpectedGid: matchedGroup.Gid,
|
||||||
|
});
|
||||||
|
if (!waitlistResult || !waitlistResult.adminWaitlist) {
|
||||||
|
setJoinError('Could not submit admin request. Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSessionGroup(matchedGroup.Gid, null);
|
||||||
|
setSessionTeam(null);
|
||||||
|
setPendingAdmin(true);
|
||||||
|
setInGame(true);
|
||||||
|
setIsAdmin(false);
|
||||||
|
setJoinStep(null);
|
||||||
|
setDeptCode('');
|
||||||
|
navigation.navigate('TeamScreen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await joinPrivateGame({
|
||||||
|
JoinCode: enteredCode,
|
||||||
|
Uid: session.currentUid,
|
||||||
|
ExpectedGid: matchedGroup.Gid,
|
||||||
|
});
|
||||||
|
if (!result) {
|
||||||
|
setJoinError('Invalid department code. Check the code and try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSessionGroup(result.Gid, result.SGid);
|
||||||
|
setPendingAdmin(false);
|
||||||
|
const freshUser = await getUser(session.currentUid);
|
||||||
|
if (freshUser) {
|
||||||
|
setSessionUser(freshUser);
|
||||||
|
setSessionTeam(freshUser.TGid ?? null);
|
||||||
|
}
|
||||||
|
setInGame(true);
|
||||||
|
setIsAdmin(false);
|
||||||
|
setJoinStep(null);
|
||||||
|
navigation.navigate('TeamScreen');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClaim = useCallback(async (cacheId) => {
|
||||||
if (!session.currentGid) return;
|
if (!session.currentGid) return;
|
||||||
await claimCache({
|
const result = await claimCache({
|
||||||
gid: session.currentGid,
|
gid: session.currentGid,
|
||||||
cacheId,
|
cacheId,
|
||||||
uid: session.currentUid,
|
uid: session.currentUid,
|
||||||
tid: session.currentTid,
|
tid: session.currentTid,
|
||||||
|
sgid: effectiveSGid,
|
||||||
});
|
});
|
||||||
setIsClaiming(false);
|
setIsClaiming(false);
|
||||||
|
if (result) {
|
||||||
|
setClaimedPopupVisible(true);
|
||||||
|
setTimeout(() => setClaimedPopupVisible(false), 3000);
|
||||||
|
}
|
||||||
await loadCaches();
|
await loadCaches();
|
||||||
};
|
}, [session.currentGid, session.currentUid, session.currentTid, effectiveSGid, loadCaches]);
|
||||||
|
|
||||||
// Admin — open create form
|
|
||||||
const handleCreateCachePress = () => {
|
const handleCreateCachePress = () => {
|
||||||
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('');
|
setNewName('');
|
||||||
setNewClue('');
|
setNewClue('');
|
||||||
|
if (!selectedCacheSubgroupId && defaultMemberSGid) setSelectedCacheSubgroupId(defaultMemberSGid);
|
||||||
|
setDepartmentDropdownOpen(false);
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Admin — open edit form
|
|
||||||
const handleEditCache = (cache) => {
|
const handleEditCache = (cache) => {
|
||||||
setEditingCacheId(cache.id);
|
setEditingCacheId(cache.id);
|
||||||
setNewCoord(cache.coordinates);
|
setNewCoord(cache.coordinates);
|
||||||
setNewName(cache.name || '');
|
setNewName(cache.name || '');
|
||||||
setNewClue(cache.clue || '');
|
setNewClue(cache.clue || '');
|
||||||
|
setSelectedCacheSubgroupId(cache.subgroupId || defaultMemberSGid);
|
||||||
|
setDepartmentDropdownOpen(false);
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Admin — delete cache
|
|
||||||
const handleDeleteCache = async (cache) => {
|
const handleDeleteCache = async (cache) => {
|
||||||
if (!session.currentGid) return;
|
if (!session.currentGid) return;
|
||||||
await deleteCache(session.currentGid, cache.id);
|
await deleteCache(session.currentGid, cache.id);
|
||||||
await loadCaches();
|
await loadCaches();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Admin — save create or edit
|
|
||||||
const handleSaveCache = async () => {
|
const handleSaveCache = async () => {
|
||||||
if (!newCoord || !newClue.trim() || !session.currentGid) return;
|
if (!newCoord || !newClue.trim() || !session.currentGid) return;
|
||||||
|
const subgroupId = selectedCacheSubgroupId || defaultMemberSGid;
|
||||||
|
if (!subgroupId) return;
|
||||||
const payload = {
|
const payload = {
|
||||||
gid: session.currentGid,
|
gid: session.currentGid,
|
||||||
name: newName.trim(),
|
name: newName.trim(),
|
||||||
latitude: newCoord.latitude,
|
latitude: newCoord.latitude,
|
||||||
longitude: newCoord.longitude,
|
longitude: newCoord.longitude,
|
||||||
clue: newClue.trim(),
|
clue: newClue.trim(),
|
||||||
subgroupId: 1,
|
subgroupId,
|
||||||
};
|
};
|
||||||
if (editingCacheId) payload.cacheId = editingCacheId;
|
if (editingCacheId) payload.cacheId = editingCacheId;
|
||||||
await upsertCache(payload);
|
await upsertCache(payload);
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setEditingCacheId(null);
|
setEditingCacheId(null);
|
||||||
|
setDepartmentDropdownOpen(false);
|
||||||
await loadCaches();
|
await loadCaches();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelCreate = () => {
|
const handleCancelCreate = () => {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setEditingCacheId(null);
|
setEditingCacheId(null);
|
||||||
|
setDepartmentDropdownOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Navigate to expanded map view, passing the live location so it opens centred on the user
|
|
||||||
const handleExpandMap = () => {
|
const handleExpandMap = () => {
|
||||||
navigation.navigate('ExpandedMapScreen', {
|
navigation.navigate('ExpandedMapScreen', {
|
||||||
isAdmin,
|
isAdmin,
|
||||||
@@ -212,17 +394,101 @@ const MapScreen = ({navigation}) => {
|
|||||||
heading: heading || 0,
|
heading: heading || 0,
|
||||||
claimDistance,
|
claimDistance,
|
||||||
userLocation: userLocation || null,
|
userLocation: userLocation || null,
|
||||||
|
selectedCacheId,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Player — select cache
|
|
||||||
const handleSelectCache = (cache) => {
|
const handleSelectCache = (cache) => {
|
||||||
// Future: scroll map to cache location
|
setSelectedCacheIdState(cache.id);
|
||||||
|
setSelectedCache(cache.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isCacheClaimedByMe = (cache) => {
|
||||||
|
const claims = cache.Claims || [];
|
||||||
|
if (session.currentTid) return claims.some((c) => c.Tid === session.currentTid);
|
||||||
|
return claims.some((c) => c.Uid === session.currentUid);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showAdminDepartmentLabel = Boolean(isAdmin && groupInfo?.BusinessOrSchoolName);
|
||||||
|
|
||||||
// View -----------------------
|
// View -----------------------
|
||||||
|
|
||||||
// Not in a game
|
// Not in a game — Business flow
|
||||||
|
if (!inGame && session.isBusiness) {
|
||||||
|
// Step 2: Enter department code
|
||||||
|
if (joinStep === 'deptCode' && matchedGroup) {
|
||||||
|
return (
|
||||||
|
<Screen style={styles.center}>
|
||||||
|
<Text style={styles.bizTitle}>Join {matchedGroup.BusinessOrSchoolName || 'Company'}</Text>
|
||||||
|
<Text style={styles.bizSubtitle}>Enter your department code to join</Text>
|
||||||
|
<View style={styles.inputRow}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.codeInput}
|
||||||
|
placeholder="Department Code"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
value={deptCode}
|
||||||
|
onChangeText={(text) => setDeptCode(text.toUpperCase())}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Join"
|
||||||
|
onClick={handleJoinDepartment}
|
||||||
|
styleButton={styles.joinButton}
|
||||||
|
styleLabel={styles.joinLabel}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{joinError ? <Text style={styles.joinError}>{joinError}</Text> : null}
|
||||||
|
<View style={styles.fullRow}>
|
||||||
|
<ButtonTray>
|
||||||
|
<Button
|
||||||
|
label="Back"
|
||||||
|
onClick={() => { setJoinStep(null); setDeptCode(''); setJoinError(''); }}
|
||||||
|
styleButton={styles.backButton}
|
||||||
|
styleLabel={styles.backLabel}
|
||||||
|
/>
|
||||||
|
</ButtonTray>
|
||||||
|
</View>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: Join company or Create company choice
|
||||||
|
return (
|
||||||
|
<Screen style={styles.center}>
|
||||||
|
<Text style={styles.bizTitle}>Welcome</Text>
|
||||||
|
<Text style={styles.bizSubtitle}>Set up your organisation or join an existing one</Text>
|
||||||
|
<View style={styles.inputRow}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.codeInput}
|
||||||
|
placeholder="Company Code"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
value={orgCode}
|
||||||
|
onChangeText={(text) => setOrgCode(text.toUpperCase())}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Join Company"
|
||||||
|
onClick={handleVerifyOrgCode}
|
||||||
|
styleButton={styles.joinButton}
|
||||||
|
styleLabel={styles.joinLabel}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{joinError ? <Text style={styles.joinError}>{joinError}</Text> : null}
|
||||||
|
<View style={styles.fullRow}>
|
||||||
|
<ButtonTray>
|
||||||
|
<Button
|
||||||
|
label="Create Company"
|
||||||
|
onClick={handleCreateCompany}
|
||||||
|
styleButton={styles.createButton}
|
||||||
|
styleLabel={styles.createLabel}
|
||||||
|
/>
|
||||||
|
</ButtonTray>
|
||||||
|
</View>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not in a game — Individual flow
|
||||||
if (!inGame) {
|
if (!inGame) {
|
||||||
return (
|
return (
|
||||||
<Screen style={styles.center}>
|
<Screen style={styles.center}>
|
||||||
@@ -232,7 +498,7 @@ const MapScreen = ({navigation}) => {
|
|||||||
placeholder="Enter Game Code"
|
placeholder="Enter Game Code"
|
||||||
placeholderTextColor="#9ca3af"
|
placeholderTextColor="#9ca3af"
|
||||||
value={gameCode}
|
value={gameCode}
|
||||||
onChangeText={setGameCode}
|
onChangeText={(text) => setGameCode(text.toUpperCase())}
|
||||||
autoCapitalize="characters"
|
autoCapitalize="characters"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -263,6 +529,7 @@ const MapScreen = ({navigation}) => {
|
|||||||
<View style={styles.mapWrap}>
|
<View style={styles.mapWrap}>
|
||||||
<MapView
|
<MapView
|
||||||
style={{flex: 1}}
|
style={{flex: 1}}
|
||||||
|
provider="google"
|
||||||
initialRegion={newCoord ? {...newCoord, latitudeDelta: 0.01, longitudeDelta: 0.01} : mapRegion}
|
initialRegion={newCoord ? {...newCoord, latitudeDelta: 0.01, longitudeDelta: 0.01} : mapRegion}
|
||||||
scrollEnabled={true}
|
scrollEnabled={true}
|
||||||
zoomEnabled={true}
|
zoomEnabled={true}
|
||||||
@@ -301,6 +568,32 @@ const MapScreen = ({navigation}) => {
|
|||||||
</MapView>
|
</MapView>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView style={styles.formSection}>
|
<ScrollView style={styles.formSection}>
|
||||||
|
<Text style={styles.departmentLabel}>Department</Text>
|
||||||
|
<Pressable
|
||||||
|
style={styles.departmentSelector}
|
||||||
|
onPress={() => setDepartmentDropdownOpen((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<Text style={styles.departmentSelectorText}>
|
||||||
|
{departmentNamesById[String(selectedCacheSubgroupId)] || 'Select department'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.departmentSelectorChevron}>{departmentDropdownOpen ? '▲' : '▼'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
{departmentDropdownOpen && (
|
||||||
|
<View style={styles.departmentMenu}>
|
||||||
|
{departments.map((department) => (
|
||||||
|
<Pressable
|
||||||
|
key={department.SGid}
|
||||||
|
style={styles.departmentOption}
|
||||||
|
onPress={() => {
|
||||||
|
setSelectedCacheSubgroupId(department.SGid);
|
||||||
|
setDepartmentDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.departmentOptionText}>{department.SubGroupName}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.formInput}
|
style={styles.formInput}
|
||||||
placeholder="Cache Name"
|
placeholder="Cache Name"
|
||||||
@@ -356,14 +649,15 @@ const MapScreen = ({navigation}) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin — normal view (map + create button + cache list)
|
// Admin view
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
return (
|
return (
|
||||||
<Screen style={styles.containerMap}>
|
<Screen style={styles.containerMap}>
|
||||||
<View style={styles.mapContainer}>
|
<View style={styles.mapContainer}>
|
||||||
<MapView
|
<MapView
|
||||||
style={{flex: 1}}
|
style={{flex: 1}}
|
||||||
region={mapRegion}
|
provider="google"
|
||||||
|
initialRegion={mapRegion}
|
||||||
scrollEnabled={true}
|
scrollEnabled={true}
|
||||||
zoomEnabled={true}
|
zoomEnabled={true}
|
||||||
showsUserLocation
|
showsUserLocation
|
||||||
@@ -407,6 +701,7 @@ const MapScreen = ({navigation}) => {
|
|||||||
key={cache.id}
|
key={cache.id}
|
||||||
cache={cache}
|
cache={cache}
|
||||||
isAdmin={true}
|
isAdmin={true}
|
||||||
|
departmentName={showAdminDepartmentLabel ? (departmentNamesById[String(cache.subgroupId)] || 'Unassigned') : null}
|
||||||
onEdit={handleEditCache}
|
onEdit={handleEditCache}
|
||||||
onDelete={handleDeleteCache}
|
onDelete={handleDeleteCache}
|
||||||
/>
|
/>
|
||||||
@@ -420,6 +715,10 @@ const MapScreen = ({navigation}) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (session.isPendingAdmin && !session.isAcceptedAdmin) {
|
||||||
|
return <PendingApprovalView/>;
|
||||||
|
}
|
||||||
|
|
||||||
// Player — loading location
|
// Player — loading location
|
||||||
if (playerLoading) {
|
if (playerLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -439,40 +738,47 @@ const MapScreen = ({navigation}) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Player in game
|
const claimTarget = visibleCaches[0] || null;
|
||||||
const teamsWarning = groupInfo?.TeamsEnabled && !session.currentTid;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen style={styles.containerMap}>
|
<Screen style={styles.containerMap}>
|
||||||
{teamsWarning && (
|
{requiresTeam && (
|
||||||
<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. Join a team to start claiming caches!</Text>
|
||||||
)}
|
)}
|
||||||
<View style={styles.mapContainer}>
|
<View style={styles.mapContainer}>
|
||||||
<PlayerMapView
|
<PlayerMapView
|
||||||
userLocation={userLocation}
|
userLocation={userLocation}
|
||||||
visibleCache={visibleCache}
|
visibleCaches={visibleCaches}
|
||||||
heading={heading}
|
heading={heading}
|
||||||
claimDistance={claimDistance}
|
|
||||||
/>
|
/>
|
||||||
<Pressable style={styles.expandButton} onPress={handleExpandMap}>
|
<Pressable style={styles.expandButton} onPress={handleExpandMap}>
|
||||||
<Text style={styles.expandIcon}>⛶</Text>
|
<Text style={styles.expandIcon}>⛶</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
{!requiresTeam && (
|
||||||
<ClaimTimerView
|
<ClaimTimerView
|
||||||
cache={visibleCache}
|
cache={claimTarget}
|
||||||
isClaiming={isClaiming}
|
isClaiming={isClaiming}
|
||||||
onClaimSuccess={handleClaim}
|
onClaimSuccess={handleClaim}
|
||||||
|
showClaimedPopup={claimedPopupVisible}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<View style={styles.cacheSection}>
|
<View style={styles.cacheSection}>
|
||||||
<ScrollView>
|
<ScrollView>
|
||||||
{cacheRecords.map((cache) => (
|
{cacheRecords.map((cache) => {
|
||||||
|
const claimed = isCacheClaimedByMe(cache);
|
||||||
|
return (
|
||||||
<CacheCardItem
|
<CacheCardItem
|
||||||
key={cache.id}
|
key={cache.id}
|
||||||
cache={cache}
|
cache={cache}
|
||||||
isAdmin={false}
|
isAdmin={false}
|
||||||
onSelect={handleSelectCache}
|
isClaimed={claimed}
|
||||||
|
isSelected={!claimed && !requiresTeam && selectedCacheId === cache.id}
|
||||||
|
onSelect={(claimed || requiresTeam) ? undefined : handleSelectCache}
|
||||||
|
disabled={requiresTeam}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{cacheRecords.length === 0 && (
|
{cacheRecords.length === 0 && (
|
||||||
<Text style={styles.emptyText}>No caches available yet.</Text>
|
<Text style={styles.emptyText}>No caches available yet.</Text>
|
||||||
)}
|
)}
|
||||||
@@ -486,27 +792,31 @@ const styles = StyleSheet.create({
|
|||||||
center: {justifyContent: 'center', alignItems: 'center'},
|
center: {justifyContent: 'center', alignItems: 'center'},
|
||||||
containerMap: {padding: 0},
|
containerMap: {padding: 0},
|
||||||
mapContainer: {height: 200},
|
mapContainer: {height: 200},
|
||||||
error: {color: '#dc2626', fontSize: 15},
|
error: {color: '#D92800', fontSize: 15},
|
||||||
loadingText: {color: '#6b7280', fontSize: 14, marginTop: 10},
|
loadingText: {color: '#6c7086', 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},
|
||||||
fullRow: {width: '100%', paddingHorizontal: 20},
|
fullRow: {width: '100%', paddingHorizontal: 20},
|
||||||
codeInput: {
|
codeInput: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#d1d5db',
|
borderColor: '#45475a',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: '#1f2937',
|
color: '#cdd6f4',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#313244',
|
||||||
},
|
}, joinButton: {backgroundColor: '#bd93f9', borderColor: '#bd93f9', flex: 0, paddingHorizontal: 20},
|
||||||
joinButton: {backgroundColor: '#2563eb', borderColor: '#2563eb', flex: 0, paddingHorizontal: 20},
|
joinLabel: {color: '#1e1e2e', fontWeight: '600'},
|
||||||
joinLabel: {color: '#ffffff', fontWeight: '600'},
|
createButton: {backgroundColor: '#a6e3a1', borderColor: '#a6e3a1'},
|
||||||
createButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'},
|
createLabel: {color: '#1e1e2e', fontWeight: '600'},
|
||||||
createLabel: {color: '#ffffff', fontWeight: '600'},
|
bizTitle: {fontSize: 22, fontWeight: '700', color: '#cdd6f4', marginBottom: 6, textAlign: 'center'},
|
||||||
|
bizSubtitle: {fontSize: 14, color: '#bac2de', textAlign: 'center', marginBottom: 20, paddingHorizontal: 20},
|
||||||
|
joinError: {color: '#D92800', textAlign: 'center', marginTop: 8, marginBottom: 4, fontSize: 14, paddingHorizontal: 20},
|
||||||
|
backButton: {backgroundColor: '#6c7086', borderColor: '#6c7086'},
|
||||||
|
backLabel: {color: '#cdd6f4', fontWeight: '600'},
|
||||||
warning: {
|
warning: {
|
||||||
color: '#dc2626',
|
color: '#D92800',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
@@ -516,7 +826,7 @@ const styles = StyleSheet.create({
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 10,
|
top: 10,
|
||||||
left: 10,
|
left: 10,
|
||||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
@@ -524,42 +834,64 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
},
|
},
|
||||||
expandIcon: {color: '#ffffff', fontSize: 18, fontWeight: '700'},
|
expandIcon: {color: '#cdd6f4', fontSize: 18, fontWeight: '700'},
|
||||||
// Admin create button + scrollable cache list
|
|
||||||
createCacheWrap: {
|
createCacheWrap: {
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#313244',
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: '#e5e7eb',
|
borderBottomColor: '#45475a',
|
||||||
},
|
},
|
||||||
createCacheButton: {backgroundColor: '#2563eb', borderColor: '#2563eb', flex: 0},
|
createCacheButton: {backgroundColor: '#bd93f9', borderColor: '#bd93f9', flex: 0},
|
||||||
createCacheLabel: {color: '#ffffff', fontWeight: '600'},
|
createCacheLabel: {color: '#1e1e2e', fontWeight: '600'},
|
||||||
cacheListWrap: {flex: 1, backgroundColor: '#f3f4f6'},
|
cacheListWrap: {flex: 1, backgroundColor: '#1e1e2e'},
|
||||||
cacheListContent: {paddingHorizontal: 12, paddingTop: 10, paddingBottom: 12},
|
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,
|
||||||
borderColor: '#d1d5db',
|
borderColor: '#45475a',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: '#1f2937',
|
color: '#cdd6f4',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#313244',
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
},
|
},
|
||||||
|
departmentLabel: {fontSize: 13, fontWeight: '600', color: '#bac2de', marginBottom: 6},
|
||||||
|
departmentSelector: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#45475a',
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: '#313244',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
departmentSelectorText: {color: '#cdd6f4', fontSize: 15, fontWeight: '500'},
|
||||||
|
departmentSelectorChevron: {color: '#6c7086', fontSize: 12, fontWeight: '700'},
|
||||||
|
departmentMenu: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#45475a',
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: '#313244',
|
||||||
|
marginBottom: 10,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
departmentOption: {paddingHorizontal: 12, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#45475a'},
|
||||||
|
departmentOptionText: {color: '#cdd6f4', fontSize: 14},
|
||||||
coordRow: {flexDirection: 'row', gap: 10},
|
coordRow: {flexDirection: 'row', gap: 10},
|
||||||
coordField: {flex: 1},
|
coordField: {flex: 1},
|
||||||
coordLabel: {fontSize: 12, fontWeight: '600', color: '#6b7280', marginBottom: 4},
|
coordLabel: {fontSize: 12, fontWeight: '600', color: '#6c7086', marginBottom: 4},
|
||||||
saveButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'},
|
saveButton: {backgroundColor: '#a6e3a1', borderColor: '#a6e3a1'},
|
||||||
saveLabel: {color: '#ffffff', fontWeight: '600'},
|
saveLabel: {color: '#1e1e2e', fontWeight: '600'},
|
||||||
cancelButton: {backgroundColor: '#6b7280', borderColor: '#6b7280'},
|
cancelButton: {backgroundColor: '#6c7086', borderColor: '#6c7086'},
|
||||||
cancelLabel: {color: '#ffffff', fontWeight: '600'},
|
cancelLabel: {color: '#cdd6f4', fontWeight: '600'},
|
||||||
// Player cache section
|
cacheSection: {flex: 1, backgroundColor: '#1e1e2e', paddingHorizontal: 12, paddingTop: 10},
|
||||||
cacheSection: {flex: 1, backgroundColor: '#f3f4f6', paddingHorizontal: 12, paddingTop: 10},
|
emptyText: {color: '#6c7086', textAlign: 'center', marginTop: 20, fontSize: 14},
|
||||||
emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 20, fontSize: 14},
|
|
||||||
mapWrap: {flex: 1},
|
mapWrap: {flex: 1},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,97 @@
|
|||||||
import React, {useCallback, useEffect, useState} from 'react';
|
import React, {useCallback, useEffect, useState} from 'react';
|
||||||
import {ActivityIndicator, ScrollView, StyleSheet, Text, View} from 'react-native';
|
import {ActivityIndicator, Alert, Pressable, ScrollView, StyleSheet, Text, View} from 'react-native';
|
||||||
import Screen from '../layout/Screen';
|
import Screen from '../layout/Screen';
|
||||||
import Card from '../UI/Card';
|
import Card from '../UI/Card';
|
||||||
import {Button} from '../UI/Button';
|
import {Button} from '../UI/Button';
|
||||||
|
import PendingApprovalView from '../UI/PendingApprovalView';
|
||||||
import useGameHook from '../../hooks/useGameHook';
|
import useGameHook from '../../hooks/useGameHook';
|
||||||
import {getSession} from '../../hooks/SessionStore';
|
import {getSession} from '../../hooks/SessionStore';
|
||||||
|
|
||||||
const PlayersScreen = () => {
|
const PlayersScreen = ({navigation, route}) => {
|
||||||
// Initialisation ------------
|
// Initialisation ------------
|
||||||
|
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
const {getTeams, getTeamMembers, getGroupMembers, removeMember, getUser} = useGameHook();
|
const {getTeams, getTeamMembers, getGroupMembers, removeMember, resetPlayerProgress, getUser, getAdminWaitlist, approveAdmin, rejectAdmin, getSubgroups} = useGameHook();
|
||||||
|
const isBusiness = Boolean(session.isBusiness);
|
||||||
|
const isAdmin = Boolean(session.isAcceptedAdmin);
|
||||||
|
const selectedDepartment = route?.params?.department || null;
|
||||||
|
const effectiveSGid = isBusiness
|
||||||
|
? ((isAdmin && selectedDepartment?.SGid) ? selectedDepartment.SGid : (isAdmin ? null : session.currentSGid))
|
||||||
|
: null;
|
||||||
|
|
||||||
// State ----------------------
|
// State ----------------------
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [players, setPlayers] = useState([]);
|
const [players, setPlayers] = useState([]);
|
||||||
|
const [departmentList, setDepartmentList] = useState([]);
|
||||||
|
const [adminTeam, setAdminTeam] = useState([]);
|
||||||
|
const [isCurrentUserAdminLead, setIsCurrentUserAdminLead] = useState(false);
|
||||||
|
const [adminTeamExpanded, setAdminTeamExpanded] = useState(false);
|
||||||
|
|
||||||
// Handlers -------------------
|
// Handlers -------------------
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
if (!session.currentGid) { setLoading(false); return; }
|
if (!session.currentGid) { setLoading(false); return; }
|
||||||
|
|
||||||
// Get all non-admin players
|
if (isBusiness && isAdmin && !selectedDepartment) {
|
||||||
const allMembers = await getGroupMembers(session.currentGid);
|
const sgs = await getSubgroups(session.currentGid);
|
||||||
const nonAdminMembers = (allMembers || []).filter((m) => !m.IsAcceptedAdmin);
|
setDepartmentList((sgs || []).filter((sg) => !sg.IsAdminGroup));
|
||||||
|
|
||||||
|
const allOrgMembers = await getGroupMembers(session.currentGid, null);
|
||||||
|
const seenAdminUids = new Set();
|
||||||
|
const acceptedAdmins = (allOrgMembers || [])
|
||||||
|
.filter((m) => m.IsAcceptedAdmin)
|
||||||
|
.sort((a, b) => Number(a.id) - Number(b.id))
|
||||||
|
.filter((m) => {
|
||||||
|
if (seenAdminUids.has(m.Uid)) return false;
|
||||||
|
seenAdminUids.add(m.Uid);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminRows = [];
|
||||||
|
for (const member of acceptedAdmins) {
|
||||||
|
const user = await getUser(member.Uid);
|
||||||
|
if (!user) continue;
|
||||||
|
adminRows.push({
|
||||||
|
Uid: member.Uid,
|
||||||
|
username: user.username || `User ${member.Uid}`,
|
||||||
|
membershipId: member.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setAdminTeam(adminRows);
|
||||||
|
setIsCurrentUserAdminLead(adminRows.length > 0 && adminRows[0].Uid === session.currentUid);
|
||||||
|
setPlayers([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAdminTeam([]);
|
||||||
|
setIsCurrentUserAdminLead(false);
|
||||||
|
|
||||||
|
// Load subgroup names for department display (business accounts)
|
||||||
|
let subgroupNameMap = {};
|
||||||
|
if (isBusiness) {
|
||||||
|
const sgs = await getSubgroups(session.currentGid);
|
||||||
|
for (const sg of (sgs || [])) {
|
||||||
|
if (!sg.IsAdminGroup) subgroupNameMap[sg.SGid] = sg.SubGroupName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get ALL members (including admins)
|
||||||
|
const allMembers = await getGroupMembers(session.currentGid, effectiveSGid ?? null);
|
||||||
|
|
||||||
|
// Get admin waitlist entries
|
||||||
|
const waitlist = effectiveSGid ? [] : await getAdminWaitlist(session.currentGid);
|
||||||
|
|
||||||
|
// Deduplicate allMembers by Uid — a user may have two rows if they were a
|
||||||
|
// regular member before being approved as an admin. Prefer the admin row.
|
||||||
|
const seenUids = new Set();
|
||||||
|
const uniqueMembers = (allMembers || [])
|
||||||
|
.sort((a, b) => (b.IsAcceptedAdmin ? 1 : 0) - (a.IsAcceptedAdmin ? 1 : 0))
|
||||||
|
.filter((m) => { if (seenUids.has(m.Uid)) return false; seenUids.add(m.Uid); return true; });
|
||||||
|
|
||||||
// Build team lookup: { Uid -> { teamCode, teamName, isLeader } }
|
// Build team lookup: { Uid -> { teamCode, teamName, isLeader } }
|
||||||
const allTeams = await getTeams(session.currentGid);
|
const allTeams = await getTeams(session.currentGid, effectiveSGid ?? null);
|
||||||
const teamInfoByUid = {};
|
const teamInfoByUid = {};
|
||||||
for (const team of (allTeams || [])) {
|
for (const team of (allTeams || [])) {
|
||||||
const tms = await getTeamMembers(team.Tid);
|
const tms = await getTeamMembers(team.Tid);
|
||||||
@@ -40,21 +104,55 @@ const PlayersScreen = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build player list
|
// Build player list — include admins with tags
|
||||||
const playerList = [];
|
const playerList = [];
|
||||||
for (const m of nonAdminMembers) {
|
for (const m of uniqueMembers) {
|
||||||
|
// Skip the current admin viewing the list
|
||||||
|
if (m.Uid === session.currentUid) continue;
|
||||||
const user = await getUser(m.Uid);
|
const user = await getUser(m.Uid);
|
||||||
if (user) {
|
if (user) {
|
||||||
|
// A group member may also be on the admin waitlist (applied after joining as a player).
|
||||||
|
// Check the waitlist first so they are treated as pending, not a regular player.
|
||||||
|
const waitlistEntry = (waitlist || []).find((w) => w.Uid === m.Uid);
|
||||||
|
let adminTag = null;
|
||||||
|
let waitlistId = null;
|
||||||
|
if (waitlistEntry) {
|
||||||
|
adminTag = '[Admin Awaiting Response]';
|
||||||
|
waitlistId = waitlistEntry.id;
|
||||||
|
} else if (m.IsAcceptedAdmin) {
|
||||||
|
adminTag = '[ADMIN]';
|
||||||
|
}
|
||||||
playerList.push({
|
playerList.push({
|
||||||
...user,
|
...user,
|
||||||
membershipId: m.id,
|
membershipId: m.id,
|
||||||
team: teamInfoByUid[m.Uid] || null,
|
team: teamInfoByUid[m.Uid] || null,
|
||||||
|
adminTag,
|
||||||
|
waitlistId,
|
||||||
|
departmentName: subgroupNameMap[m.SGid] || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include waitlisted users who are not yet group members at all
|
||||||
|
for (const w of (waitlist || [])) {
|
||||||
|
if (playerList.some((p) => p.Uid === w.Uid)) continue;
|
||||||
|
if (w.Uid === session.currentUid) continue;
|
||||||
|
const user = await getUser(w.Uid);
|
||||||
|
if (user) {
|
||||||
|
playerList.push({
|
||||||
|
...user,
|
||||||
|
membershipId: null,
|
||||||
|
team: null,
|
||||||
|
adminTag: '[Admin Awaiting Response]',
|
||||||
|
waitlistId: w.id,
|
||||||
|
departmentName: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setPlayers(playerList);
|
setPlayers(playerList);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [session.currentGid]);
|
}, [session.currentGid, session.currentUid, session.currentSGid, isBusiness, isAdmin, selectedDepartment, selectedDepartment?.SGid, effectiveSGid]);
|
||||||
|
|
||||||
useEffect(() => { loadData(); }, [loadData]);
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
|
|
||||||
@@ -63,6 +161,55 @@ const PlayersScreen = () => {
|
|||||||
await loadData();
|
await loadData();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResetPlayerProgress = (player) => {
|
||||||
|
Alert.alert(
|
||||||
|
'Reset Progress',
|
||||||
|
`Reset all cache claims for ${player.username}?`,
|
||||||
|
[
|
||||||
|
{text: 'Cancel', style: 'cancel'},
|
||||||
|
{
|
||||||
|
text: 'Reset',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
await resetPlayerProgress(session.currentGid, player.Uid, effectiveSGid);
|
||||||
|
Alert.alert('Done', `Progress for ${player.username} has been reset.`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Approve a waitlisted admin and refresh the list
|
||||||
|
const handleApproveAdmin = async (player) => {
|
||||||
|
await approveAdmin(player.waitlistId);
|
||||||
|
await loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deny (remove from waitlist) a pending admin and refresh the list
|
||||||
|
const handleDenyAdmin = async (player) => {
|
||||||
|
await rejectAdmin(player.waitlistId);
|
||||||
|
await loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKickAdmin = (adminMember) => {
|
||||||
|
if (!isCurrentUserAdminLead || adminMember.Uid === session.currentUid) return;
|
||||||
|
Alert.alert(
|
||||||
|
'Kick Admin',
|
||||||
|
`Remove ${adminMember.username} from the admin team?`,
|
||||||
|
[
|
||||||
|
{text: 'Cancel', style: 'cancel'},
|
||||||
|
{
|
||||||
|
text: 'Kick',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
await removeMember(adminMember.membershipId);
|
||||||
|
await loadData();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// View -----------------------
|
// View -----------------------
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -81,14 +228,88 @@ const PlayersScreen = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (session.isPendingAdmin && !session.isAcceptedAdmin) {
|
||||||
|
return <PendingApprovalView/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBusiness && isAdmin && !selectedDepartment) {
|
||||||
return (
|
return (
|
||||||
<Screen style={styles.container}>
|
<Screen style={styles.container}>
|
||||||
|
<Text style={styles.sectionTitle}>Departments</Text>
|
||||||
|
<ScrollView style={styles.listSection}>
|
||||||
|
<Card>
|
||||||
|
<Pressable onPress={() => setAdminTeamExpanded((prev) => !prev)}>
|
||||||
|
<View style={styles.playerRow}>
|
||||||
|
<Text style={styles.playerName}>Admin Team</Text>
|
||||||
|
<Text style={styles.openLabel}>{adminTeamExpanded ? 'Close' : 'Open'}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
{adminTeamExpanded && (
|
||||||
|
<View style={styles.adminTeamWrap}>
|
||||||
|
{adminTeam.map((adminMember, index) => (
|
||||||
|
<View key={adminMember.Uid} style={styles.adminTeamRow}>
|
||||||
|
<Text style={styles.adminTeamName} numberOfLines={1}>
|
||||||
|
{adminMember.username}{index === 0 ? ' (Admin Lead)' : ''}
|
||||||
|
</Text>
|
||||||
|
{isCurrentUserAdminLead && adminMember.Uid !== session.currentUid && (
|
||||||
|
<Button
|
||||||
|
label="Kick Admin"
|
||||||
|
onClick={() => handleKickAdmin(adminMember)}
|
||||||
|
styleButton={styles.removeButton}
|
||||||
|
styleLabel={styles.kickLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{adminTeam.length === 0 && (
|
||||||
|
<Text style={styles.emptyText}>No approved admins yet.</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
{departmentList.map((department) => (
|
||||||
|
<Pressable
|
||||||
|
key={department.SGid}
|
||||||
|
onPress={() => navigation.navigate('PlayersScreen', {department})}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<View style={styles.playerRow}>
|
||||||
|
<Text style={styles.playerName}>{department.SubGroupName}</Text>
|
||||||
|
<Text style={styles.openLabel}>Open</Text>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
{departmentList.length === 0 && (
|
||||||
|
<Text style={styles.emptyText}>No departments yet.</Text>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen style={styles.container} showBack={Boolean(selectedDepartment)}>
|
||||||
<Text style={styles.sectionTitle}>Players</Text>
|
<Text style={styles.sectionTitle}>Players</Text>
|
||||||
<ScrollView style={styles.listSection}>
|
<ScrollView style={styles.listSection}>
|
||||||
{players.map((player) => (
|
{players.map((player) => (
|
||||||
<Card key={player.Uid}>
|
// Waitlisted admins get a light red tint so they stand out from regular players
|
||||||
|
<Card key={player.Uid} style={player.waitlistId ? styles.waitlistCard : undefined}>
|
||||||
<View style={styles.playerRow}>
|
<View style={styles.playerRow}>
|
||||||
<Text style={styles.playerName} numberOfLines={1}>{player.username}</Text>
|
<Text style={styles.playerName} numberOfLines={1}>
|
||||||
|
{player.username}
|
||||||
|
{player.adminTag && (
|
||||||
|
<Text style={player.adminTag === '[ADMIN]' ? styles.adminBadge : styles.waitlistBadge}>
|
||||||
|
{' '}{player.adminTag}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{/* Show department name in brackets for non-admin players (org accounts) */}
|
||||||
|
{!player.adminTag && player.departmentName && (
|
||||||
|
<Text style={styles.departmentName}>
|
||||||
|
{' '}({player.departmentName})
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
<View style={styles.playerMeta}>
|
<View style={styles.playerMeta}>
|
||||||
{player.team?.isLeader && (
|
{player.team?.isLeader && (
|
||||||
<Text style={styles.leaderBadge}>(Team Leader)</Text>
|
<Text style={styles.leaderBadge}>(Team Leader)</Text>
|
||||||
@@ -97,12 +318,44 @@ const PlayersScreen = () => {
|
|||||||
<Text style={styles.teamCodeBadge}>{player.team.teamCode}</Text>
|
<Text style={styles.teamCodeBadge}>{player.team.teamCode}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.actionRow}>
|
||||||
|
{/* Waitlisted admins get Approve / Deny instead of the normal player actions */}
|
||||||
|
{player.waitlistId ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
label="Approve"
|
||||||
|
onClick={() => handleApproveAdmin(player)}
|
||||||
|
styleButton={styles.approveButton}
|
||||||
|
styleLabel={styles.actionLabel}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Deny"
|
||||||
|
onClick={() => handleDenyAdmin(player)}
|
||||||
|
styleButton={styles.denyButton}
|
||||||
|
styleLabel={styles.actionLabel}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!player.adminTag && (
|
||||||
|
<Button
|
||||||
|
label="Reset Progress"
|
||||||
|
onClick={() => handleResetPlayerProgress(player)}
|
||||||
|
styleButton={styles.resetButton}
|
||||||
|
styleLabel={styles.actionLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{player.membershipId && (
|
||||||
<Button
|
<Button
|
||||||
label="Kick"
|
label="Kick"
|
||||||
onClick={() => handleRemovePlayer(player.membershipId)}
|
onClick={() => handleRemovePlayer(player.membershipId)}
|
||||||
styleButton={styles.removeButton}
|
styleButton={styles.removeButton}
|
||||||
styleLabel={styles.removeLabel}
|
styleLabel={styles.kickLabel}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -117,29 +370,42 @@ const PlayersScreen = () => {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
center: {justifyContent: 'center', alignItems: 'center', flex: 1},
|
center: {justifyContent: 'center', alignItems: 'center', flex: 1},
|
||||||
container: {padding: 0},
|
container: {padding: 0},
|
||||||
body: {color: '#4b5563', fontSize: 15},
|
body: {color: '#bac2de', fontSize: 15},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: '#1f2937',
|
color: '#cdd6f4',
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
paddingHorizontal: 15,
|
paddingHorizontal: 15,
|
||||||
paddingTop: 15,
|
paddingTop: 15,
|
||||||
},
|
},
|
||||||
|
openLabel: {fontSize: 13, fontWeight: '700', color: '#bd93f9'},
|
||||||
listSection: {flex: 1, paddingHorizontal: 15},
|
listSection: {flex: 1, paddingHorizontal: 15},
|
||||||
emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 30, fontSize: 14},
|
emptyText: {color: '#6c7086', textAlign: 'center', marginTop: 30, fontSize: 14},
|
||||||
playerRow: {
|
playerRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
playerName: {fontSize: 15, fontWeight: '600', color: '#1f2937', flex: 1},
|
playerName: {fontSize: 15, fontWeight: '600', color: '#cdd6f4', flex: 1},
|
||||||
playerMeta: {flexDirection: 'row', alignItems: 'center', gap: 6},
|
playerMeta: {flexDirection: 'row', alignItems: 'center', gap: 6},
|
||||||
leaderBadge: {fontSize: 11, color: '#2563eb', fontWeight: '700'},
|
leaderBadge: {fontSize: 11, color: '#bd93f9', fontWeight: '700'},
|
||||||
teamCodeBadge: {fontSize: 12, fontWeight: '700', color: '#16a34a', letterSpacing: 1},
|
teamCodeBadge: {fontSize: 12, fontWeight: '700', color: '#a6e3a1', letterSpacing: 1},
|
||||||
removeButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36, flex: 0, paddingHorizontal: 12},
|
adminBadge: {fontSize: 12, fontWeight: '700', color: '#D92800'},
|
||||||
removeLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
|
waitlistBadge: {fontSize: 11, fontWeight: '600', color: '#f4a460'},
|
||||||
|
actionRow: {flexDirection: 'row', gap: 8},
|
||||||
|
resetButton: {backgroundColor: '#86efac', borderColor: '#86efac', minHeight: 36, flex: 1, paddingHorizontal: 10},
|
||||||
|
removeButton: {backgroundColor: '#D92800', borderColor: '#D92800', minHeight: 36, flex: 1, paddingHorizontal: 10},
|
||||||
|
actionLabel: {color: '#1e1e2e', fontWeight: '600', fontSize: 13},
|
||||||
|
kickLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
|
||||||
|
adminTeamWrap: {gap: 8},
|
||||||
|
adminTeamRow: {flexDirection: 'row', alignItems: 'center', gap: 8},
|
||||||
|
adminTeamName: {flex: 1, fontSize: 14, color: '#cdd6f4', fontWeight: '600'},
|
||||||
|
departmentName: {fontSize: 14, color: '#6c7086', fontStyle: 'italic'},
|
||||||
|
waitlistCard: {backgroundColor: 'rgba(217, 40, 0, 0.15)', borderColor: '#D92800'},
|
||||||
|
approveButton: {backgroundColor: '#a6e3a1', borderColor: '#a6e3a1', minHeight: 36, flex: 1, paddingHorizontal: 10},
|
||||||
|
denyButton: {backgroundColor: '#D92800', borderColor: '#D92800', minHeight: 36, flex: 1, paddingHorizontal: 10},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default PlayersScreen;
|
export default PlayersScreen;
|
||||||
|
|
||||||
@@ -5,8 +5,6 @@ import Screen from '../layout/Screen';
|
|||||||
import {Button, ButtonTray} from '../UI/Button';
|
import {Button, ButtonTray} from '../UI/Button';
|
||||||
|
|
||||||
const RegisterScreen = ({navigation}) => {
|
const RegisterScreen = ({navigation}) => {
|
||||||
// Initialisation ------------
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
username, email, password, confirmPassword, accountType,
|
username, email, password, confirmPassword, accountType,
|
||||||
error, isLoading,
|
error, isLoading,
|
||||||
@@ -14,12 +12,8 @@ const RegisterScreen = ({navigation}) => {
|
|||||||
register,
|
register,
|
||||||
} = useAuthHook();
|
} = useAuthHook();
|
||||||
|
|
||||||
// State ----------------------
|
|
||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
// Handlers -------------------
|
|
||||||
|
|
||||||
const handleRegister = async () => {
|
const handleRegister = async () => {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
const user = await register();
|
const user = await register();
|
||||||
@@ -32,8 +26,6 @@ const RegisterScreen = ({navigation}) => {
|
|||||||
navigation.reset({index: 0, routes: [{name: 'Game'}]});
|
navigation.reset({index: 0, routes: [{name: 'Game'}]});
|
||||||
};
|
};
|
||||||
|
|
||||||
// View -----------------------
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen style={styles.container}>
|
<Screen style={styles.container}>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
@@ -42,7 +34,7 @@ const RegisterScreen = ({navigation}) => {
|
|||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
placeholderTextColor="#9ca3af"
|
placeholderTextColor="#6c7086"
|
||||||
value={username}
|
value={username}
|
||||||
onChangeText={setUsername}
|
onChangeText={setUsername}
|
||||||
editable={!isLoading}
|
editable={!isLoading}
|
||||||
@@ -50,7 +42,7 @@ const RegisterScreen = ({navigation}) => {
|
|||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
placeholderTextColor="#9ca3af"
|
placeholderTextColor="#6c7086"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
value={email}
|
value={email}
|
||||||
@@ -60,7 +52,7 @@ const RegisterScreen = ({navigation}) => {
|
|||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
placeholderTextColor="#9ca3af"
|
placeholderTextColor="#6c7086"
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
value={password}
|
value={password}
|
||||||
onChangeText={setPassword}
|
onChangeText={setPassword}
|
||||||
@@ -69,7 +61,7 @@ const RegisterScreen = ({navigation}) => {
|
|||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="Confirm Password"
|
placeholder="Confirm Password"
|
||||||
placeholderTextColor="#9ca3af"
|
placeholderTextColor="#6c7086"
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChangeText={setConfirmPassword}
|
onChangeText={setConfirmPassword}
|
||||||
@@ -128,16 +120,17 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
|
color: '#cdd6f4',
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#d1d5db',
|
borderColor: '#45475a',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: '#1f2937',
|
color: '#cdd6f4',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#313244',
|
||||||
},
|
},
|
||||||
typeRow: {
|
typeRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -146,38 +139,38 @@ const styles = StyleSheet.create({
|
|||||||
typeButton: {
|
typeButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#cbd5e1',
|
borderColor: '#45475a',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
},
|
},
|
||||||
typeButtonActive: {
|
typeButtonActive: {
|
||||||
backgroundColor: '#2563eb',
|
backgroundColor: '#bd93f9',
|
||||||
borderColor: '#2563eb',
|
borderColor: '#bd93f9',
|
||||||
},
|
},
|
||||||
typeText: {
|
typeText: {
|
||||||
color: '#1f2937',
|
color: '#bac2de',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
typeTextActive: {
|
typeTextActive: {
|
||||||
color: '#ffffff',
|
color: '#1e1e2e',
|
||||||
},
|
},
|
||||||
errorText: {
|
errorText: {
|
||||||
color: '#dc2626',
|
color: '#D92800',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
marginTop: 5,
|
marginTop: 5,
|
||||||
},
|
},
|
||||||
submitButton: {
|
submitButton: {
|
||||||
backgroundColor: '#2563eb',
|
backgroundColor: '#bd93f9',
|
||||||
borderColor: '#2563eb',
|
borderColor: '#bd93f9',
|
||||||
},
|
},
|
||||||
submitText: {
|
submitText: {
|
||||||
color: '#ffffff',
|
color: '#1e1e2e',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
linkText: {
|
linkText: {
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: '#2563eb',
|
color: '#bd93f9',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,17 +3,15 @@ import {ActivityIndicator, ScrollView, StyleSheet, Text, TextInput, View} from '
|
|||||||
import Screen from '../layout/Screen';
|
import Screen from '../layout/Screen';
|
||||||
import Card from '../UI/Card';
|
import Card from '../UI/Card';
|
||||||
import {Button, ButtonTray} from '../UI/Button';
|
import {Button, ButtonTray} from '../UI/Button';
|
||||||
|
import PendingApprovalView from '../UI/PendingApprovalView';
|
||||||
import useGameHook from '../../hooks/useGameHook';
|
import useGameHook from '../../hooks/useGameHook';
|
||||||
import {getSession, setSessionTeam} from '../../hooks/SessionStore';
|
import {getSession, setPendingAdmin, setSessionTeam, setSessionUser} from '../../hooks/SessionStore';
|
||||||
|
|
||||||
const TeamScreen = () => {
|
|
||||||
// Initialisation ------------
|
|
||||||
|
|
||||||
|
const TeamScreen = ({navigation}) => {
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
const isAdmin = session.isAcceptedAdmin;
|
const isAdmin = session.isAcceptedAdmin;
|
||||||
const {getTeam, createTeam, joinTeamByCode, getTeamMembers, leaveTeam, getUser, updateUser} = useGameHook();
|
const isBusiness = Boolean(session.isBusiness);
|
||||||
|
const {getLobby, getSubgroup, getTeam, createTeam, joinTeamByCode, getTeamMembers, leaveTeam, getUser, updateUser, updateTeam, getAdminWaitlist} = useGameHook();
|
||||||
// State ----------------------
|
|
||||||
|
|
||||||
const [activeTid, setActiveTid] = useState(session.currentTid);
|
const [activeTid, setActiveTid] = useState(session.currentTid);
|
||||||
const [teamCode, setTeamCode] = useState('');
|
const [teamCode, setTeamCode] = useState('');
|
||||||
@@ -21,8 +19,10 @@ const TeamScreen = () => {
|
|||||||
const [members, setMembers] = useState([]);
|
const [members, setMembers] = useState([]);
|
||||||
const [memberNames, setMemberNames] = useState({});
|
const [memberNames, setMemberNames] = useState({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [teamsDisabled, setTeamsDisabled] = useState(false);
|
||||||
// Handlers -------------------
|
const [onAdminWaitlist, setOnAdminWaitlist] = useState(false);
|
||||||
|
const [editingName, setEditingName] = useState(false);
|
||||||
|
const [teamNameDraft, setTeamNameDraft] = useState('');
|
||||||
|
|
||||||
const loadTeamData = async (tid) => {
|
const loadTeamData = async (tid) => {
|
||||||
const t = await getTeam(tid);
|
const t = await getTeam(tid);
|
||||||
@@ -39,23 +39,118 @@ const TeamScreen = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
|
if (!session.currentGid) { setLoading(false); return; }
|
||||||
|
|
||||||
|
const group = await getLobby(session.currentGid);
|
||||||
|
let teamsAreEnabled = Boolean(group?.TeamsEnabled);
|
||||||
|
|
||||||
|
if (isBusiness && session.currentSGid) {
|
||||||
|
const subgroup = await getSubgroup(session.currentSGid);
|
||||||
|
if (subgroup) teamsAreEnabled = Boolean(subgroup.TeamsEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.isPendingAdmin && !session.isAcceptedAdmin) {
|
||||||
|
const freshUser = await getUser(session.currentUid);
|
||||||
|
if (freshUser?.IsAcceptedAdmin) {
|
||||||
|
setPendingAdmin(false);
|
||||||
|
setSessionUser(freshUser);
|
||||||
|
setSessionTeam(freshUser.TGid ?? null);
|
||||||
|
navigation.reset({index: 0, routes: [{name: 'Admin'}]});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const waitlist = await getAdminWaitlist(session.currentGid, session.currentUid);
|
||||||
|
if (waitlist.length > 0) {
|
||||||
|
setOnAdminWaitlist(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingAdmin(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!teamsAreEnabled) {
|
||||||
|
setTeamsDisabled(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTeamsDisabled(false);
|
||||||
|
|
||||||
|
if (!activeTid) {
|
||||||
|
const waitlist = await getAdminWaitlist(session.currentGid, session.currentUid);
|
||||||
|
if (waitlist.length > 0) {
|
||||||
|
setOnAdminWaitlist(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (activeTid) await loadTeamData(activeTid);
|
if (activeTid) await loadTeamData(activeTid);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
}, [activeTid]);
|
}, [activeTid]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onAdminWaitlist && !session.isPendingAdmin) return;
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
const refreshWaitlistStatus = async () => {
|
||||||
|
const currentSession = getSession();
|
||||||
|
if (!currentSession.currentUid || !currentSession.currentGid) return;
|
||||||
|
|
||||||
|
const freshUser = await getUser(currentSession.currentUid);
|
||||||
|
if (stopped) return;
|
||||||
|
|
||||||
|
if (freshUser?.IsAcceptedAdmin) {
|
||||||
|
setPendingAdmin(false);
|
||||||
|
setSessionUser(freshUser);
|
||||||
|
setSessionTeam(freshUser.TGid ?? null);
|
||||||
|
navigation.reset({index: 0, routes: [{name: 'Admin'}]});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitlist = await getAdminWaitlist(currentSession.currentGid, currentSession.currentUid);
|
||||||
|
if (stopped) return;
|
||||||
|
|
||||||
|
const isStillWaiting = waitlist.length > 0;
|
||||||
|
setOnAdminWaitlist(isStillWaiting);
|
||||||
|
if (!isStillWaiting) {
|
||||||
|
setPendingAdmin(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
refreshWaitlistStatus();
|
||||||
|
const intervalId = setInterval(refreshWaitlistStatus, 5000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [onAdminWaitlist, session.isPendingAdmin]);
|
||||||
|
|
||||||
const handleJoinTeam = async () => {
|
const handleJoinTeam = async () => {
|
||||||
if (!teamCode.trim()) return;
|
if (!teamCode.trim()) return;
|
||||||
const result = await joinTeamByCode({JoinCode: teamCode.trim().toUpperCase(), Uid: session.currentUid});
|
// Force uppercase just in case a lowercase code is pasted
|
||||||
|
const code = teamCode.trim().toUpperCase();
|
||||||
|
const result = await joinTeamByCode({
|
||||||
|
JoinCode: code,
|
||||||
|
Uid: session.currentUid,
|
||||||
|
ExpectedSGid: session.currentSGid,
|
||||||
|
});
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
|
|
||||||
|
// If the server indicates this code matched the admin join code
|
||||||
|
if (result.adminWaitlist) {
|
||||||
|
setPendingAdmin(true);
|
||||||
|
setOnAdminWaitlist(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSessionTeam(result.Tid);
|
setSessionTeam(result.Tid);
|
||||||
setActiveTid(result.Tid);
|
setActiveTid(result.Tid);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateTeam = async () => {
|
const handleCreateTeam = async () => {
|
||||||
if (!session.currentGid) return;
|
if (!session.currentGid) return;
|
||||||
const result = await createTeam({Gid: session.currentGid, TeamName: 'My Team'});
|
const result = await createTeam({Gid: session.currentGid, SGid: session.currentSGid, TeamName: 'My Team'});
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
const membership = await joinTeamByCode({Tid: result.Tid, Uid: session.currentUid});
|
const membership = await joinTeamByCode({Tid: result.Tid, Uid: session.currentUid});
|
||||||
if (membership) {
|
if (membership) {
|
||||||
@@ -81,6 +176,16 @@ const TeamScreen = () => {
|
|||||||
await loadTeamData(activeTid);
|
await loadTeamData(activeTid);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRenameTeam = async () => {
|
||||||
|
const trimmed = teamNameDraft.trim();
|
||||||
|
if (!trimmed || !activeTid) return;
|
||||||
|
const result = await updateTeam(activeTid, {TeamName: trimmed});
|
||||||
|
if (result) {
|
||||||
|
setTeam(result);
|
||||||
|
setEditingName(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Check if current user is the team leader
|
// Check if current user is the team leader
|
||||||
const myMembership = members.find((m) => m.Uid === session.currentUid);
|
const myMembership = members.find((m) => m.Uid === session.currentUid);
|
||||||
const isCurrentUserLeader = Boolean(myMembership?.IsLeader);
|
const isCurrentUserLeader = Boolean(myMembership?.IsLeader);
|
||||||
@@ -95,24 +200,48 @@ const TeamScreen = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not in a team
|
if (teamsDisabled) {
|
||||||
if (!activeTid || !team) {
|
|
||||||
return (
|
return (
|
||||||
<Screen style={styles.center}>
|
<Screen style={styles.center}>
|
||||||
|
<View style={styles.disabledWrap}>
|
||||||
|
<Text style={styles.disabledTitle}>Teams Disabled</Text>
|
||||||
|
<Text style={styles.disabledBody}>The admin has not enabled teams for this game.</Text>
|
||||||
|
</View>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waiting for admin approval
|
||||||
|
if (onAdminWaitlist) {
|
||||||
|
return <PendingApprovalView message="Waiting to be approved for Admin Team"/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not in a team
|
||||||
|
if (!activeTid || !team) {
|
||||||
|
// Disable team actions until the user has joined a game
|
||||||
|
const notInGame = !session.currentGid;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen style={styles.center}>
|
||||||
|
{notInGame && (
|
||||||
|
<Text style={styles.notInGameText}>Join a game first to manage teams.</Text>
|
||||||
|
)}
|
||||||
<View style={styles.inputRow}>
|
<View style={styles.inputRow}>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.codeInput}
|
style={[styles.codeInput, notInGame && styles.inputDisabled]}
|
||||||
placeholder="Enter Team Code"
|
placeholder="Enter Team Code"
|
||||||
placeholderTextColor="#9ca3af"
|
placeholderTextColor="#9ca3af"
|
||||||
value={teamCode}
|
value={teamCode}
|
||||||
onChangeText={setTeamCode}
|
onChangeText={(text) => setTeamCode(text.toUpperCase())}
|
||||||
autoCapitalize="characters"
|
autoCapitalize="characters"
|
||||||
|
editable={!notInGame}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="Join"
|
label="Join"
|
||||||
onClick={handleJoinTeam}
|
onClick={handleJoinTeam}
|
||||||
styleButton={styles.joinButton}
|
styleButton={styles.joinButton}
|
||||||
styleLabel={styles.joinLabel}
|
styleLabel={styles.joinLabel}
|
||||||
|
disabled={notInGame}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{!isAdmin && (
|
{!isAdmin && (
|
||||||
@@ -123,6 +252,7 @@ const TeamScreen = () => {
|
|||||||
onClick={handleCreateTeam}
|
onClick={handleCreateTeam}
|
||||||
styleButton={styles.createButton}
|
styleButton={styles.createButton}
|
||||||
styleLabel={styles.createLabel}
|
styleLabel={styles.createLabel}
|
||||||
|
disabled={notInGame}
|
||||||
/>
|
/>
|
||||||
</ButtonTray>
|
</ButtonTray>
|
||||||
</View>
|
</View>
|
||||||
@@ -138,9 +268,51 @@ const TeamScreen = () => {
|
|||||||
<Text style={styles.teamCodeLabel}>Team Code:</Text>
|
<Text style={styles.teamCodeLabel}>Team Code:</Text>
|
||||||
<Text style={styles.teamCodeValue}>{team.JoinCode}</Text>
|
<Text style={styles.teamCodeValue}>{team.JoinCode}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Team name display / edit */}
|
||||||
|
<View style={styles.teamNameSection}>
|
||||||
|
{editingName ? (
|
||||||
|
<View style={styles.renameRow}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.renameInput}
|
||||||
|
value={teamNameDraft}
|
||||||
|
onChangeText={setTeamNameDraft}
|
||||||
|
placeholder="Team name"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Save"
|
||||||
|
onClick={handleRenameTeam}
|
||||||
|
styleButton={styles.renameSaveButton}
|
||||||
|
styleLabel={styles.renameSaveLabel}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Cancel"
|
||||||
|
onClick={() => setEditingName(false)}
|
||||||
|
styleButton={styles.renameCancelButton}
|
||||||
|
styleLabel={styles.renameCancelLabel}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.renameRow}>
|
||||||
|
<Text style={styles.teamNameText}>{team.TeamName || 'Unnamed Team'}</Text>
|
||||||
|
{isCurrentUserLeader && (
|
||||||
|
<Button
|
||||||
|
label="Rename"
|
||||||
|
onClick={() => { setTeamNameDraft(team.TeamName || ''); setEditingName(true); }}
|
||||||
|
styleButton={styles.renameButton}
|
||||||
|
styleLabel={styles.renameLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
<ScrollView style={styles.memberList}>
|
<ScrollView style={styles.memberList}>
|
||||||
{members.map((m) => (
|
{members.map((m) => (
|
||||||
<Card key={m.id}>
|
// Highlight the current user's card so they can identify themselves
|
||||||
|
<Card key={m.id} style={m.Uid === session.currentUid ? styles.selfCard : undefined}>
|
||||||
<View style={styles.memberRow}>
|
<View style={styles.memberRow}>
|
||||||
<View style={styles.memberInfo}>
|
<View style={styles.memberInfo}>
|
||||||
<Text style={styles.memberName}>
|
<Text style={styles.memberName}>
|
||||||
@@ -162,7 +334,7 @@ const TeamScreen = () => {
|
|||||||
{members.length === 0 && (
|
{members.length === 0 && (
|
||||||
<Text style={styles.emptyText}>No members yet.</Text>
|
<Text style={styles.emptyText}>No members yet.</Text>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
{/* Leave button lives inside the scroll so it is never obscured by the bottom nav bar */}
|
||||||
<View style={styles.leaveWrap}>
|
<View style={styles.leaveWrap}>
|
||||||
<Button
|
<Button
|
||||||
label="Leave Team"
|
label="Leave Team"
|
||||||
@@ -171,6 +343,7 @@ const TeamScreen = () => {
|
|||||||
styleLabel={styles.leaveLabel}
|
styleLabel={styles.leaveLabel}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
</ScrollView>
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -178,33 +351,65 @@ const TeamScreen = () => {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
center: {justifyContent: 'center', alignItems: 'center'},
|
center: {justifyContent: 'center', alignItems: 'center'},
|
||||||
container: {padding: 0},
|
container: {padding: 0},
|
||||||
|
disabledWrap: {paddingHorizontal: 30, alignItems: 'center', opacity: 0.45},
|
||||||
|
disabledTitle: {fontSize: 20, fontWeight: '700', color: '#ffffff', marginBottom: 10, textAlign: 'center'},
|
||||||
|
disabledBody: {fontSize: 15, color: '#ffffff', textAlign: 'center'},
|
||||||
|
notInGameText: {color: '#ffffff', fontSize: 15, fontWeight: '600', marginBottom: 16, textAlign: 'center', paddingHorizontal: 20},
|
||||||
|
inputDisabled: {backgroundColor: '#313244', opacity: 0.5},
|
||||||
inputRow: {flexDirection: 'row', gap: 10, marginBottom: 15, width: '100%', paddingHorizontal: 20},
|
inputRow: {flexDirection: 'row', gap: 10, marginBottom: 15, width: '100%', paddingHorizontal: 20},
|
||||||
fullRow: {width: '100%', paddingHorizontal: 20},
|
fullRow: {width: '100%', paddingHorizontal: 20},
|
||||||
codeInput: {
|
codeInput: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#d1d5db',
|
borderColor: '#45475a',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: '#1f2937',
|
color: '#ffffff',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#313244',
|
||||||
|
},
|
||||||
|
joinButton: {backgroundColor: '#bd93f9', borderColor: '#bd93f9', flex: 0, paddingHorizontal: 20},
|
||||||
|
joinLabel: {color: '#1e1e2e', fontWeight: '600'},
|
||||||
|
createButton: {backgroundColor: '#a6e3a1', borderColor: '#a6e3a1'},
|
||||||
|
createLabel: {color: '#1e1e2e', fontWeight: '600'},
|
||||||
|
waitlistText: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#ffffff',
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingHorizontal: 30,
|
||||||
},
|
},
|
||||||
joinButton: {backgroundColor: '#2563eb', borderColor: '#2563eb', flex: 0, paddingHorizontal: 20},
|
|
||||||
joinLabel: {color: '#ffffff', fontWeight: '600'},
|
|
||||||
createButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'},
|
|
||||||
createLabel: {color: '#ffffff', fontWeight: '600'},
|
|
||||||
teamHeader: {
|
teamHeader: {
|
||||||
backgroundColor: '#374151',
|
backgroundColor: '#45475a',
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
},
|
},
|
||||||
teamCodeLabel: {color: '#d1d5db', fontSize: 15, fontWeight: '600'},
|
teamCodeLabel: {color: '#ffffff', fontSize: 15, fontWeight: '600'},
|
||||||
teamCodeValue: {color: '#ffffff', fontSize: 17, fontWeight: '700', letterSpacing: 2},
|
teamCodeValue: {color: '#ffffff', fontSize: 17, fontWeight: '700', letterSpacing: 2},
|
||||||
|
teamNameSection: {paddingHorizontal: 15, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#45475a'},
|
||||||
|
teamNameText: {fontSize: 18, fontWeight: '700', color: '#ffffff', flex: 1},
|
||||||
|
renameRow: {flexDirection: 'row', alignItems: 'center', gap: 8},
|
||||||
|
renameInput: {
|
||||||
|
flex: 1,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#45475a',
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#ffffff',
|
||||||
|
backgroundColor: '#313244',
|
||||||
|
},
|
||||||
|
renameButton: {backgroundColor: '#bd93f9', borderColor: '#bd93f9', flex: 0, minHeight: 36, paddingHorizontal: 14},
|
||||||
|
renameLabel: {color: '#1e1e2e', fontWeight: '600', fontSize: 13},
|
||||||
|
renameSaveButton: {backgroundColor: '#a6e3a1', borderColor: '#a6e3a1', flex: 0, minHeight: 36, paddingHorizontal: 12},
|
||||||
|
renameSaveLabel: {color: '#1e1e2e', fontWeight: '600', fontSize: 13},
|
||||||
|
renameCancelButton: {backgroundColor: '#6c7086', borderColor: '#6c7086', flex: 0, minHeight: 36, paddingHorizontal: 12},
|
||||||
|
renameCancelLabel: {color: '#cdd6f4', fontWeight: '600', fontSize: 13},
|
||||||
memberList: {flex: 1, paddingHorizontal: 15, paddingTop: 10},
|
memberList: {flex: 1, paddingHorizontal: 15, paddingTop: 10},
|
||||||
memberRow: {
|
memberRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -212,14 +417,15 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
memberInfo: {flex: 1, flexDirection: 'row', alignItems: 'center', gap: 6},
|
memberInfo: {flex: 1, flexDirection: 'row', alignItems: 'center', gap: 6},
|
||||||
memberName: {fontSize: 16, color: '#1f2937', fontWeight: '600'},
|
memberName: {fontSize: 16, color: '#ffffff', fontWeight: '600'},
|
||||||
leaderBadge: {fontSize: 12, color: '#2563eb', fontWeight: '700'},
|
leaderBadge: {fontSize: 12, color: '#bd93f9', fontWeight: '700'},
|
||||||
kickButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36, flex: 0, paddingHorizontal: 14},
|
kickButton: {backgroundColor: '#D92800', borderColor: '#D92800', minHeight: 36, flex: 0, paddingHorizontal: 14},
|
||||||
kickLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
|
kickLabel: {color: '#1e1e2e', fontWeight: '600', fontSize: 13},
|
||||||
emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 30, fontSize: 14},
|
emptyText: {color: '#ffffff', textAlign: 'center', marginTop: 30, fontSize: 14},
|
||||||
leaveWrap: {paddingHorizontal: 15, paddingVertical: 10},
|
leaveWrap: {paddingHorizontal: 0, paddingTop: 10, paddingBottom: 16},
|
||||||
leaveButton: {backgroundColor: '#dc2626', borderColor: '#dc2626'},
|
leaveButton: {backgroundColor: '#D92800', borderColor: '#D92800'},
|
||||||
leaveLabel: {color: '#ffffff', fontWeight: '600'},
|
leaveLabel: {color: '#1e1e2e', fontWeight: '600'},
|
||||||
|
selfCard: {backgroundColor: 'rgba(166, 227, 161, 0.15)', borderColor: '#a6e3a1'},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default TeamScreen;
|
export default TeamScreen;
|
||||||
@@ -11,10 +11,12 @@ const session = {
|
|||||||
currentTid: null,
|
currentTid: null,
|
||||||
isBusiness: null,
|
isBusiness: null,
|
||||||
isAcceptedAdmin: false,
|
isAcceptedAdmin: false,
|
||||||
|
isPendingAdmin: false,
|
||||||
teamsEnabled: false,
|
teamsEnabled: false,
|
||||||
// Global gameplay
|
// Global gameplay
|
||||||
currentGlobalEventId: null,
|
currentGlobalEventId: null,
|
||||||
currentGlobalPlayerId: null,
|
currentGlobalPlayerId: null,
|
||||||
|
selectedCacheId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Handlers ---
|
// --- Handlers ---
|
||||||
@@ -26,13 +28,16 @@ const setSessionUser = (user) => {
|
|||||||
session.currentTid = user?.TGid ?? null;
|
session.currentTid = user?.TGid ?? null;
|
||||||
session.isBusiness = user?.isBusiness ?? null;
|
session.isBusiness = user?.isBusiness ?? null;
|
||||||
session.isAcceptedAdmin = Boolean(user?.IsAcceptedAdmin);
|
session.isAcceptedAdmin = Boolean(user?.IsAcceptedAdmin);
|
||||||
|
session.isPendingAdmin = Boolean(
|
||||||
|
user?.isBusiness && user?.Gid && !user?.SGid && !user?.IsAcceptedAdmin,
|
||||||
|
);
|
||||||
persistSession();
|
persistSession();
|
||||||
};
|
};
|
||||||
|
|
||||||
const setSessionGroup = (gid, sgid = null) => {
|
const setSessionGroup = (gid, sgid = null) => {
|
||||||
session.currentGid = gid ?? null;
|
session.currentGid = gid ?? null;
|
||||||
if (sgid !== null && sgid !== undefined) {
|
if (sgid !== undefined) {
|
||||||
session.currentSGid = sgid;
|
session.currentSGid = sgid ?? null;
|
||||||
}
|
}
|
||||||
persistSession();
|
persistSession();
|
||||||
};
|
};
|
||||||
@@ -47,6 +52,16 @@ const setSessionTeamsEnabled = (val) => {
|
|||||||
persistSession();
|
persistSession();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setPendingAdmin = (val) => {
|
||||||
|
session.isPendingAdmin = Boolean(val);
|
||||||
|
persistSession();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSelectedCache = (cacheId) => {
|
||||||
|
session.selectedCacheId = cacheId ?? null;
|
||||||
|
persistSession();
|
||||||
|
};
|
||||||
|
|
||||||
const getSession = () => ({ ...session });
|
const getSession = () => ({ ...session });
|
||||||
|
|
||||||
const setGlobalSession = (eventId, playerId) => {
|
const setGlobalSession = (eventId, playerId) => {
|
||||||
@@ -70,6 +85,7 @@ const clearSession = () => {
|
|||||||
session.isAcceptedAdmin = false;
|
session.isAcceptedAdmin = false;
|
||||||
session.currentGlobalEventId = null;
|
session.currentGlobalEventId = null;
|
||||||
session.currentGlobalPlayerId = null;
|
session.currentGlobalPlayerId = null;
|
||||||
|
session.isPendingAdmin = false;
|
||||||
AsyncStorage.removeItem(STORAGE_KEY).catch(() => {});
|
AsyncStorage.removeItem(STORAGE_KEY).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,7 +95,9 @@ const clearGameSession = () => {
|
|||||||
session.currentSGid = null;
|
session.currentSGid = null;
|
||||||
session.currentTid = null;
|
session.currentTid = null;
|
||||||
session.isAcceptedAdmin = false;
|
session.isAcceptedAdmin = false;
|
||||||
|
session.isPendingAdmin = false;
|
||||||
session.teamsEnabled = false;
|
session.teamsEnabled = false;
|
||||||
|
session.selectedCacheId = null;
|
||||||
persistSession();
|
persistSession();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -114,3 +132,4 @@ export {
|
|||||||
setGlobalSession,
|
setGlobalSession,
|
||||||
clearGlobalSession,
|
clearGlobalSession,
|
||||||
};
|
};
|
||||||
|
export { setSessionUser, setSessionGroup, setSessionTeam, setSessionTeamsEnabled, setPendingAdmin, setSelectedCache, getSession, clearSession, clearGameSession, loadSession };
|
||||||
+155
-26
@@ -6,10 +6,11 @@ const useGameHook = () => {
|
|||||||
const usersEndpoint = `${API_BASE_URL}/users`;
|
const usersEndpoint = `${API_BASE_URL}/users`;
|
||||||
const groupsEndpoint = `${API_BASE_URL}/groups`;
|
const groupsEndpoint = `${API_BASE_URL}/groups`;
|
||||||
const subgroupsEndpoint = `${API_BASE_URL}/subgroups`;
|
const subgroupsEndpoint = `${API_BASE_URL}/subgroups`;
|
||||||
const subgroupMembershipsEndpoint = `${API_BASE_URL}/subgroup-memberships`;
|
const subgroupMembersEndpoint = `${API_BASE_URL}/subgroup-members`;
|
||||||
const teamsEndpoint = `${API_BASE_URL}/teams`;
|
const teamsEndpoint = `${API_BASE_URL}/teams`;
|
||||||
const teamMembersEndpoint = `${API_BASE_URL}/team-members`;
|
const teamMembersEndpoint = `${API_BASE_URL}/team-members`;
|
||||||
const gameDataEndpoint = `${API_BASE_URL}/game-data`;
|
const cachesEndpoint = `${API_BASE_URL}/caches`;
|
||||||
|
const adminWaitlistEndpoint = `${API_BASE_URL}/admin-waitlist`;
|
||||||
|
|
||||||
// Handlers -------------------
|
// Handlers -------------------
|
||||||
|
|
||||||
@@ -40,30 +41,71 @@ const useGameHook = () => {
|
|||||||
return response.isSuccess ? response.result : null;
|
return response.isSuccess ? response.result : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Look up a group by its Organisation Join Code
|
||||||
|
const getGroupByOrgCode = async (code) => {
|
||||||
|
const normalizedCode = String(code || '').trim().toUpperCase();
|
||||||
|
if (!normalizedCode) return null;
|
||||||
|
const response = await API.get(`${groupsEndpoint}?OrgJoinCode=${encodeURIComponent(normalizedCode)}`);
|
||||||
|
if (response.isSuccess && response.result.length > 0) return response.result[0];
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Subgroups
|
||||||
const getSubgroups = async (gid) => {
|
const getSubgroups = async (gid) => {
|
||||||
const response = await API.get(`${subgroupsEndpoint}?Gid=${gid}`);
|
const response = await API.get(`${subgroupsEndpoint}?Gid=${gid}`);
|
||||||
return response.isSuccess ? response.result : [];
|
return response.isSuccess ? response.result : [];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Subgroup memberships
|
const getSubgroup = async (sgid) => {
|
||||||
const joinPrivateGame = async (payload) => {
|
const response = await API.get(`${subgroupsEndpoint}/${sgid}`);
|
||||||
const response = await API.post(subgroupMembershipsEndpoint, payload);
|
|
||||||
return response.isSuccess ? response.result : null;
|
return response.isSuccess ? response.result : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getGroupMembers = async (gid) => {
|
const createSubgroup = async (payload) => {
|
||||||
const response = await API.get(`${subgroupMembershipsEndpoint}?Gid=${gid}`);
|
const response = await API.post(subgroupsEndpoint, payload);
|
||||||
|
return response.isSuccess ? response.result : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSubgroup = async (sgid, data) => {
|
||||||
|
const response = await API.put(`${subgroupsEndpoint}/${sgid}`, data);
|
||||||
|
return response.isSuccess ? response.result : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSubgroup = async (sgid) => {
|
||||||
|
const response = await API.delete(`${subgroupsEndpoint}/${sgid}`);
|
||||||
|
return response.isSuccess;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Subgroup members
|
||||||
|
const joinPrivateGame = async (payload) => {
|
||||||
|
const data = {
|
||||||
|
JoinCode: payload.JoinCode,
|
||||||
|
Uid: payload.Uid,
|
||||||
|
};
|
||||||
|
if (payload.ExpectedGid !== undefined && payload.ExpectedGid !== null) {
|
||||||
|
data.ExpectedGid = payload.ExpectedGid;
|
||||||
|
}
|
||||||
|
const response = await API.post(subgroupMembersEndpoint, data);
|
||||||
|
return response.isSuccess ? response.result : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGroupMembers = async (gid, sgid = null) => {
|
||||||
|
let url = `${subgroupMembersEndpoint}?Gid=${gid}`;
|
||||||
|
if (sgid !== null && sgid !== undefined) url += `&SGid=${sgid}`;
|
||||||
|
const response = await API.get(url);
|
||||||
return response.isSuccess ? response.result : [];
|
return response.isSuccess ? response.result : [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeMember = async (membershipId) => {
|
const removeMember = async (membershipId) => {
|
||||||
const response = await API.delete(`${subgroupMembershipsEndpoint}/${membershipId}`);
|
const response = await API.delete(`${subgroupMembersEndpoint}/${membershipId}`);
|
||||||
return response.isSuccess;
|
return response.isSuccess;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Teams
|
// Teams
|
||||||
const getTeams = async (gid) => {
|
const getTeams = async (gid, sgid = null) => {
|
||||||
const response = await API.get(`${teamsEndpoint}?Gid=${gid}`);
|
let url = `${teamsEndpoint}?Gid=${gid}`;
|
||||||
|
if (sgid !== null && sgid !== undefined) url += `&SGid=${sgid}`;
|
||||||
|
const response = await API.get(url);
|
||||||
return response.isSuccess ? response.result : [];
|
return response.isSuccess ? response.result : [];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,8 +119,34 @@ const useGameHook = () => {
|
|||||||
return response.isSuccess ? response.result : null;
|
return response.isSuccess ? response.result : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateTeam = async (tid, data) => {
|
||||||
|
const response = await API.put(`${teamsEndpoint}/${tid}`, data);
|
||||||
|
return response.isSuccess ? response.result : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteTeam = async (tid) => {
|
||||||
|
const response = await API.delete(`${teamsEndpoint}/${tid}`);
|
||||||
|
return response.isSuccess;
|
||||||
|
};
|
||||||
|
|
||||||
|
const disbandTeams = async (gid) => {
|
||||||
|
const response = await API.post(`${teamsEndpoint}/disband?Gid=${gid}`, {});
|
||||||
|
return response.isSuccess;
|
||||||
|
};
|
||||||
|
|
||||||
const joinTeamByCode = async (payload) => {
|
const joinTeamByCode = async (payload) => {
|
||||||
const response = await API.post(teamMembersEndpoint, payload);
|
const data = {
|
||||||
|
Uid: payload.Uid,
|
||||||
|
};
|
||||||
|
if (payload.JoinCode !== undefined) data.JoinCode = payload.JoinCode;
|
||||||
|
if (payload.Tid !== undefined) data.Tid = payload.Tid;
|
||||||
|
if (payload.ExpectedGid !== undefined && payload.ExpectedGid !== null) {
|
||||||
|
data.ExpectedGid = payload.ExpectedGid;
|
||||||
|
}
|
||||||
|
if (payload.ExpectedSGid !== undefined && payload.ExpectedSGid !== null) {
|
||||||
|
data.ExpectedSGid = payload.ExpectedSGid;
|
||||||
|
}
|
||||||
|
const response = await API.post(teamMembersEndpoint, data);
|
||||||
return response.isSuccess ? response.result : null;
|
return response.isSuccess ? response.result : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,39 +161,86 @@ const useGameHook = () => {
|
|||||||
return response.isSuccess;
|
return response.isSuccess;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Caches
|
|
||||||
const getCaches = async (gid, sgid = null) => {
|
const getCaches = async (gid, sgid = null) => {
|
||||||
let url = `${gameDataEndpoint}/${gid}/caches`;
|
let url = `${cachesEndpoint}?Gid=${gid}`;
|
||||||
if (sgid !== null) url += `?SGid=${sgid}`;
|
if (sgid !== null) url += `&SGid=${sgid}`;
|
||||||
const response = await API.get(url);
|
const response = await API.get(url);
|
||||||
return response.isSuccess ? response.result : [];
|
return response.isSuccess ? response.result : [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const upsertCache = async (payload) => {
|
const upsertCache = async (payload) => {
|
||||||
|
const data = {
|
||||||
|
Gid: payload.gid,
|
||||||
|
SGid: payload.subgroupId,
|
||||||
|
Title: payload.name,
|
||||||
|
Clue: payload.clue,
|
||||||
|
Latitude: payload.latitude,
|
||||||
|
Longitude: payload.longitude,
|
||||||
|
TriggerMeters: payload.radius,
|
||||||
|
};
|
||||||
if (payload.cacheId) {
|
if (payload.cacheId) {
|
||||||
const response = await API.put(
|
const response = await API.put(`${cachesEndpoint}/${payload.cacheId}`, data);
|
||||||
`${gameDataEndpoint}/${payload.gid}/caches/${payload.cacheId}`,
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
return response.isSuccess ? response.result : null;
|
return response.isSuccess ? response.result : null;
|
||||||
}
|
}
|
||||||
const response = await API.post(
|
const response = await API.post(cachesEndpoint, data);
|
||||||
`${gameDataEndpoint}/${payload.gid}/caches`,
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
return response.isSuccess ? response.result : null;
|
return response.isSuccess ? response.result : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const claimCache = async (payload) => {
|
const claimCache = async (payload) => {
|
||||||
const response = await API.put(
|
let url = `${cachesEndpoint}/${payload.cacheId}/claim`;
|
||||||
`${gameDataEndpoint}/${payload.gid}/caches/${payload.cacheId}`,
|
if (payload.sgid !== null && payload.sgid !== undefined) {
|
||||||
{ClaimedByUid: payload.uid, ClaimedByTid: payload.tid},
|
url += `?SGid=${payload.sgid}`;
|
||||||
|
}
|
||||||
|
const response = await API.post(
|
||||||
|
url,
|
||||||
|
{Uid: payload.uid, Tid: payload.tid, SGid: payload.sgid},
|
||||||
);
|
);
|
||||||
return response.isSuccess ? response.result : null;
|
return response.isSuccess ? response.result : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteCache = async (gid, cacheId) => {
|
const deleteCache = async (gid, cacheId) => {
|
||||||
const response = await API.delete(`${gameDataEndpoint}/${gid}/caches/${cacheId}`);
|
const response = await API.delete(`${cachesEndpoint}/${cacheId}`);
|
||||||
|
return response.isSuccess;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Game reset — remove all caches for a game (optionally scoped to a subgroup)
|
||||||
|
const resetGame = async (gid, sgid = null) => {
|
||||||
|
let url = `${cachesEndpoint}/reset?Gid=${gid}`;
|
||||||
|
if (sgid !== null) url += `&SGid=${sgid}`;
|
||||||
|
const response = await API.post(url, {});
|
||||||
|
return response.isSuccess;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Player progress reset — clear all claims by a specific user
|
||||||
|
const resetPlayerProgress = async (gid, uid, sgid = null) => {
|
||||||
|
let url = `${cachesEndpoint}/reset-player/${uid}?Gid=${gid}`;
|
||||||
|
if (sgid !== null && sgid !== undefined) url += `&SGid=${sgid}`;
|
||||||
|
const response = await API.post(url, {});
|
||||||
|
return response.isSuccess;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetTeamProgress = async (gid, tid, sgid = null) => {
|
||||||
|
let url = `${cachesEndpoint}/reset-team/${tid}?Gid=${gid}`;
|
||||||
|
if (sgid !== null && sgid !== undefined) url += `&SGid=${sgid}`;
|
||||||
|
const response = await API.post(url, {});
|
||||||
|
return response.isSuccess;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admin waitlist
|
||||||
|
const getAdminWaitlist = async (gid, uid = null) => {
|
||||||
|
let url = `${adminWaitlistEndpoint}?Gid=${gid}`;
|
||||||
|
if (uid !== null) url += `&Uid=${uid}`;
|
||||||
|
const response = await API.get(url);
|
||||||
|
return response.isSuccess ? response.result : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const approveAdmin = async (waitlistId) => {
|
||||||
|
const response = await API.post(`${adminWaitlistEndpoint}/${waitlistId}/approve`, {});
|
||||||
|
return response.isSuccess ? response.result : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectAdmin = async (waitlistId) => {
|
||||||
|
const response = await API.delete(`${adminWaitlistEndpoint}/${waitlistId}`);
|
||||||
return response.isSuccess;
|
return response.isSuccess;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -137,13 +252,21 @@ const useGameHook = () => {
|
|||||||
createPrivateGame,
|
createPrivateGame,
|
||||||
getLobby,
|
getLobby,
|
||||||
updateGroup,
|
updateGroup,
|
||||||
|
getGroupByOrgCode,
|
||||||
getSubgroups,
|
getSubgroups,
|
||||||
|
getSubgroup,
|
||||||
|
createSubgroup,
|
||||||
|
updateSubgroup,
|
||||||
|
deleteSubgroup,
|
||||||
joinPrivateGame,
|
joinPrivateGame,
|
||||||
getGroupMembers,
|
getGroupMembers,
|
||||||
removeMember,
|
removeMember,
|
||||||
getTeams,
|
getTeams,
|
||||||
getTeam,
|
getTeam,
|
||||||
createTeam,
|
createTeam,
|
||||||
|
updateTeam,
|
||||||
|
deleteTeam,
|
||||||
|
disbandTeams,
|
||||||
joinTeamByCode,
|
joinTeamByCode,
|
||||||
getTeamMembers,
|
getTeamMembers,
|
||||||
leaveTeam,
|
leaveTeam,
|
||||||
@@ -151,6 +274,12 @@ const useGameHook = () => {
|
|||||||
upsertCache,
|
upsertCache,
|
||||||
claimCache,
|
claimCache,
|
||||||
deleteCache,
|
deleteCache,
|
||||||
|
resetGame,
|
||||||
|
resetPlayerProgress,
|
||||||
|
resetTeamProgress,
|
||||||
|
getAdminWaitlist,
|
||||||
|
approveAdmin,
|
||||||
|
rejectAdmin,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+21
-20
@@ -1,35 +1,36 @@
|
|||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useRef, useState} from 'react';
|
||||||
import {isInClaimCone} from '../utils/geoMath';
|
import {isInClaimCone} from '../utils/geoMath';
|
||||||
|
|
||||||
const usePlayerGame = (playerLocation, playerHeading, activeCaches, claimDistance) => {
|
const usePlayerGame = (playerLocation, playerHeading, activeCaches, claimDistance, selectedCacheId) => {
|
||||||
// State ----------------------
|
const [visibleCaches, setVisibleCaches] = useState([]);
|
||||||
|
|
||||||
const [visibleCache, setVisibleCache] = useState(null);
|
|
||||||
const [isClaiming, setIsClaiming] = useState(false);
|
const [isClaiming, setIsClaiming] = useState(false);
|
||||||
|
const prevIdRef = useRef('');
|
||||||
// Handlers -------------------
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!playerLocation || playerHeading === null || playerHeading === undefined) {
|
if (!playerLocation || playerHeading === null || playerHeading === undefined || !selectedCacheId) {
|
||||||
setVisibleCache(null);
|
if (prevIdRef.current !== '') {
|
||||||
|
prevIdRef.current = '';
|
||||||
|
setVisibleCaches([]);
|
||||||
setIsClaiming(false);
|
setIsClaiming(false);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the first cache that falls inside the invisible claim cone
|
const selected = (activeCaches || []).find((c) => c.id === selectedCacheId);
|
||||||
// (same FOV angle as the visible cone, but radius = admin-set claimDistance)
|
const visible = selected && isInClaimCone(playerHeading, playerLocation, selected.coordinates, claimDistance);
|
||||||
const cacheInCone = (activeCaches || []).find((cache) =>
|
const newId = visible ? String(selected.id) : '';
|
||||||
isInClaimCone(playerHeading, playerLocation, cache.coordinates, claimDistance)
|
|
||||||
);
|
|
||||||
|
|
||||||
setVisibleCache(cacheInCone || null);
|
if (newId !== prevIdRef.current) {
|
||||||
setIsClaiming(Boolean(cacheInCone));
|
prevIdRef.current = newId;
|
||||||
}, [playerLocation, playerHeading, activeCaches, claimDistance]);
|
setVisibleCaches(visible ? [selected] : []);
|
||||||
|
setIsClaiming(Boolean(visible));
|
||||||
|
}
|
||||||
|
}, [playerLocation, playerHeading, activeCaches, claimDistance, selectedCacheId]);
|
||||||
|
|
||||||
// Return ---------------------
|
return {visibleCaches, isClaiming, setIsClaiming};
|
||||||
|
|
||||||
return {visibleCache, isClaiming, setIsClaiming};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default usePlayerGame;
|
export default usePlayerGame;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in new issue
Block a user