Add new screens for game management, team management, and cache handling; implement caching logic and session management

This commit is contained in:
bobbert committed 2026-04-02 22:08:07 +01:00
1 parent 8686f0e1ff
commit 17ed120ce8
70 files changed
+2172 -3892

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
import {NavigationContainer} from "@react-navigation/native";
import {createNativeStackNavigator} from "@react-navigation/native-stack";
import LoginScreen from "./src/components/screens/LoginScreen";
import RegisterScreen from "./src/components/screens/RegisterScreen";
import GameTypeScreen from "./src/components/screens/GameTypeScreen";
import JoinPrivateGameScreen from "./src/components/screens/JoinPrivateGameScreen";
import ManagePrivateGameScreen from "./src/components/screens/ManagePrivateGameScreen";
import CreateGameScreen from "./src/components/screens/CreateGameScreen";
import ManageGameScreen from "./src/components/screens/ManageGameScreen";
import GameLobbyScreen from "./src/components/screens/GameLobbyScreen";
import TeamScreen from "./src/components/screens/TeamScreen";
import MapScreen from "./src/components/screens/MapScreen";
import LeaderboardScreen from "./src/components/screens/LeaderboardScreen";
import AdminHubScreen from "./src/components/screens/AdminHubScreen";
import ManageSubgroupsScreen from "./src/components/screens/ManageSubgroupsScreen";
const Stack = createNativeStackNavigator();
const AuthStack = () => (
<Stack.Navigator
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="LoginScreen" component={LoginScreen} />
<Stack.Screen name="RegisterScreen" component={RegisterScreen} />
</Stack.Navigator>
);
const GameStack = () => (
<Stack.Navigator
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="GameTypeScreen" component={GameTypeScreen} />
<Stack.Screen name="JoinPrivateGameScreen" component={JoinPrivateGameScreen} />
<Stack.Screen name="ManagePrivateGameScreen" component={ManagePrivateGameScreen} />
<Stack.Screen name="CreateGameScreen" component={CreateGameScreen} />
<Stack.Screen name="ManageGameScreen" component={ManageGameScreen} />
<Stack.Screen name="GameLobbyScreen" component={GameLobbyScreen} />
<Stack.Screen name="LeaderboardScreen" component={LeaderboardScreen} />
<Stack.Screen name="TeamScreen" component={TeamScreen} />
<Stack.Screen name="MapScreen" component={MapScreen} />
</Stack.Navigator>
);
const AdminStack = () => (
<Stack.Navigator
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="AdminHubScreen" component={AdminHubScreen} />
<Stack.Screen name="ManageSubgroupsScreen" component={ManageSubgroupsScreen} />
<Stack.Screen name="LeaderboardScreen" component={LeaderboardScreen} />
<Stack.Screen name="MapScreen" component={MapScreen} />
<Stack.Screen name="TeamScreen" component={TeamScreen} />
<Stack.Screen name="CreateGameScreen" component={CreateGameScreen} />
<Stack.Screen name="ManageGameScreen" component={ManageGameScreen} />
</Stack.Navigator>
);
export const App = () => {
return (
<NavigationContainer>
<Stack.Navigator
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="Auth" component={AuthStack} />
<Stack.Screen name="Game" component={GameStack} />
<Stack.Screen name="Admin" component={AdminStack} />
</Stack.Navigator>
</NavigationContainer>
);
};
export default App;
-134
View File
@@ -1,134 +0,0 @@
# Database Structure & Game Rules
This project uses split JSON files in `DB/` so each collection behaves like its own REST endpoint.
## Purpose
- Keep data modular and readable.
- Mirror server-side endpoint structure before moving to a hosted REST API.
- Allow the app to load all collections into one in-memory state via `app/Model/DbController.js`.
## Files
- `users.json`: user accounts and role state (`Uid`, `Gid`, `SGid`, `TGid`, `isBusiness`, `IsAcceptedAdmin`).
- `groups.json`: top-level game groups (`Gid`), admin approvals, group settings, creator metadata (`CreatedByUid`, `IsBusinessGroup`), and subgroup limits (`MaxMemberSubgroups`).
- `subgroups.json`: subgroup definitions within each group (`SGid` scoped by `Gid`), join codes, cache trigger defaults, and game status (`IsGameStarted`).
- `subgroup-memberships.json`: membership bridge from `Uid` to `Gid`/`SGid`.
- `admin-waitlist.json`: pending admin requests for groups when active admins already exist.
- `teams.json`: team entities for non-admin subgroup players.
- `team-members.json`: membership bridge from `Uid` to `Tid`.
- `game-data.json`: per-group game payloads (geocaches, map/game objects).
- `game-types.json`: available game type options for UI flow.
- `db.json`: index file listing split JSON resources.
## ID Rules
- `Uid`: unique user ID.
- `Gid`: unique group/game ID.
- `SGid`: subgroup ID within a group (`0` is admin subgroup; `1+` are member subgroups).
- `Tid`: unique team ID.
- New IDs are generated from the max existing value in each endpoint collection (`users`, `groups`, `teams`) so no separate counter file is required.
## User Types: Individual vs Business
### Individual Users (`isBusiness: false`)
**Private Game Creation Rules:**
- Individuals can create **one private game per account**.
- Auto-creates:
- **Subgroup 0**: Admin subgroup (empty on creation).
- **Subgroup 1**: Members subgroup (individual joins as member, not admin).
- `MaxMemberSubgroups` is set to `1` (no additional subgroups can be created).
- Individual has `SGid = 1` and `IsAcceptedAdmin = false`.
- If an individual tries to create a game again, returns the existing game.
**Team Participation:**
- Individuals can create and join teams **only in subgroup 1**.
- Cannot access admin functions or view Admin Hub.
- Game management is simplified: "Manage Game" screen instead of "Admin Hub".
### Business/School Users (`isBusiness: true`)
**Private Game Creation Rules:**
- Businesses can create **unlimited private games** (tracked per creator `CreatedByUid`).
- Auto-creates:
- **Subgroup 0**: Admin subgroup (business becomes member).
- **No automatic subgroups 1+** (must be created manually).
- `MaxMemberSubgroups` is set to `999` (supports up to 999 subgroups).
- Business creator has `SGid = 0` and `IsAcceptedAdmin = true` (immediately accepted).
**Admin Approval Flow:**
- When a new admin joins via "Join as Admin for Private Game":
- If an active admin already exists (`hasActiveAdmin` check for SGid 0, IsAcceptedAdmin true):
- Request added to `admin-waitlist.json` with status `pending`.
- User is notified: "Admin access request submitted. You will see admin tools after approval."
- If **no active admin exists**:
- User is auto-promoted to accepted admin immediately.
- `IsAcceptedAdmin` is set to `true`.
**Team Participation & Admin Restrictions:**
- **Admins cannot create or join teams** (checked via `canJoinTeamForSubgroup` logic).
- Admins in subgroup 0 see a warning in Game Lobby: "Admins cannot participate in teams. Manage your game from the Admin Hub instead."
- Team controls are hidden for admin users.
## Subgroup Visibility & Management
### Manage Subgroups Page
- **Only business users can access** (individuals see an informational message).
- **Only displays subgroups with `SGid > 0`** (member subgroups).
- Subgroup 0 (admin) is hidden to avoid confusion.
- **Features:**
- Search bar to filter by subgroup name or ID.
- Card-based display showing:
- Bold subgroup name
- `ID: [SGid]`
- `Status: [Active|Inactive]` (based on `IsGameStarted` flag)
- `Teams: [true|false]`
- Create form at bottom for adding new subgroups (up to `MaxMemberSubgroups` limit).
### Visibility API
- `getVisibleSubgroups(gid)` returns only subgroups where `SGid > 0`.
- Used by ManageSubgroupsPage for filtering.
- Maintains admin-only (subgroup 0) separation in UI.
## Admin Logic
- Group creator is auto-set as admin (`SGid = 0`) only if **business account**.
- Individual creators are set to subgroup 1 (members) and are never admins.
- Admins cannot create/join teams (blocked by `canJoinTeamForSubgroup` check).
- New admin join requests go into `admin-waitlist.json` if active admins exist.
- If no active admin exists, the requester is auto-promoted to accepted admin.
- Group-level `ApprovedAdmins` stores accepted admin `Uid` values.
## Timestamp Format
- `CreatedAt` values are stored at minute precision:
- format: `YYYY-MM-DDTHH:mmZ`
- example: `2026-03-17T14:42Z`
## Summary: Game Flow Decision Tree
```
User Registration
├─ Individual (isBusiness = false)
│ └─ Login → "Manage Private Game"
│ ├─ Has created game before?
│ │ ├─ Yes → "Manage Game" screen (team manager)
│ │ └─ No → "Create Game" screen → creates game with subgroups 0 & 1
│ └─ Never sees Admin Hub
│
└─ Business (isBusiness = true)
└─ Login → "Manage Private Game"
├─ "Create New Private Game" → creates game with subgroup 0 only
└─ "Join as Admin for Private Game" → requests admin access
├─ Active admin exists?
│ ├─ Yes → queued in admin-waitlist (pending)
│ └─ No → auto-promoted (accepted)
└─ Once accepted → Admin Hub access
├─ Create a Game
├─ Manage Existing Game (blocked from teams)
└─ Manage Subgroups (create/search member subgroups)
```
+18 -124
View File
@@ -1,132 +1,26 @@
# Welcome to the mobile application development repository for our Treasure Hunt Game! This project is built using React Native and Expo.
# MAD Treasure Hunt
## Get started
Treasure Hunt mobile app built with React Native and Expo.
1. Install dependencies
## Run
```bash
npm i
```
```bash
npm install
npm run lint
expo start --android
```
2. Start the app
## Structure
```bash
expo start --android
```
- `App.js`: app navigation entry.
- `src/components/layout`: shared screen wrapper.
- `src/components/UI`: shared UI components.
- `src/components/screens`: app screens.
- `src/hooks`: app state, local DB link, and feature hooks.
- `DB`: local JSON data files.
## Notes
# Database Structure
This project uses split JSON files in `DB/` so each collection behaves like its own REST endpoint.
## Purpose
- Keep data modular and readable.
- Mirror server-side endpoint structure before moving to a hosted REST API.
- Allow the app to load all collections into one in-memory state via `app/Model/DbController.js`.
## Files
- `users.json`: user accounts and role state (`Uid`, `Gid`, `SGid`, `TGid`, `isBusiness`, `IsAcceptedAdmin`).
- `groups.json`: top-level game groups (`Gid`), admin approvals, group settings, creator metadata (`CreatedByUid`, `IsBusinessGroup`), and subgroup limits (`MaxMemberSubgroups`).
- `subgroups.json`: subgroup definitions within each group (`SGid` scoped by `Gid`), join codes, cache trigger defaults, and game status (`IsGameStarted`).
- `subgroup-memberships.json`: membership bridge from `Uid` to `Gid`/`SGid`.
- `admin-waitlist.json`: pending admin requests for groups when active admins already exist.
- `teams.json`: team entities for non-admin subgroup players.
- `team-members.json`: membership bridge from `Uid` to `Tid`.
- `game-data.json`: per-group game payloads (geocaches, map/game objects).
- `game-types.json`: available game type options for UI flow.
- `db.json`: index file listing split JSON resources.
## ID Rules
- `Uid`: unique user ID.
- `Gid`: unique group/game ID.
- `SGid`: subgroup ID within a group (`0` is admin subgroup; `1+` are member subgroups).
- `Tid`: unique team ID.
- New IDs are generated from the max existing value in each endpoint collection (`users`, `groups`, `teams`) so no separate counter file is required.
## User Types and Private Game Rules
### Individual Users (`isBusiness: false`)
- **Private Game Creation**: Individuals can create **one** private game per account.
- Auto-creates **subgroup 0** (admins) and **subgroup 1** (members/self).
- `MaxMemberSubgroups` is set to `1` (cannot create additional subgroups).
- Individual becomes a **member** of subgroup 1, not an admin.
- Cannot view "Admin Hub" options; instead see "Manage Game" UI for active team management.
- **Team Participation**: Individuals can create and join teams within their subgroup 1.
- Admins (those in subgroup 0) are blocked from team participation.
### Business/School Users (`isBusiness: true`)
- **Private Game Creation**: Businesses can create private games with flexible subgroup management.
- Auto-creates **subgroup 0** (admins) only; no auto-member subgroup.
- `MaxMemberSubgroups` is set to `999` (can create up to 999 subgroups).
- Business creator becomes an **accepted admin** of subgroup 0 immediately.
- **Admin Participation**: Business admins (SGid 0, IsAcceptedAdmin true) **cannot participate in teams**.
- Game Lobby shows warning message and hides team creation/join controls.
- Admins must manage subgroups and games from Admin Hub and Manage Subgroups pages.
- **Admin Approval Flow**: New admins joining a business group go into `admin-waitlist.json` if an active admin already exists.
- Auto-promoted if no active admin exists.
- Promoted admins gain accepted admin status.
## Subgroup Visibility
- **Manage Subgroups Page**: Only displays subgroups with `SGid > 0` (member subgroups).
- Subgroup 0 (admins) is hidden in the UI to avoid confusion.
- Features search by name/ID and card-based display with status and team info.
- Only visible to business users; individual users see an informational message.
## Admin Logic
- Group creator is auto-set as admin (`SGid = 0`) if business account.
- New admin join requests go into `admin-waitlist.json` if active admins exist.
- If no active admin exists, the requester is auto-promoted to accepted admin.
- Group-level `ApprovedAdmins` stores accepted admin `Uid` values.
- Admins cannot create or join teams; they manage the game and subgroups.
## Game Flow
### Login / Registration
1. User registers as Individual or Business/School.
2. Session stores `isBusiness` and `IsAcceptedAdmin` flags.
### Individual Private Game Flow
1. Individual selects "Manage Private Game" option.
2. Check if they have an existing private game:
- **If existing**: Navigate to "Manage Game" screen → "Open Team Manager" → join/create team.
- **If new**: Navigate to "Create Game" screen → create game → then to "Manage Game".
3. Individual can only manage their single team in subgroup 1.
4. Cannot access Admin Hub or Subgroup management.
### Business Private Game Flow
1. Business user selects "Manage Private Game" option.
2. Presented with:
- **Create New Private Game**: Creates a game with admin subgroup 0 only.
- **Join as Admin for Private Game**: Submit request to join an existing business group.
3. Once admin (accepted or auto-promoted), access Admin Hub:
- Create a Game
- Manage Existing Game (team/lobby controls)
- Manage Subgroups (search, filter, create member subgroups)
4. Cannot create/join teams in Game Lobby (shows warning).
### Team Management
- Teams can only be created/joined in **non-admin subgroups** (`SGid > 0`).
- Admin users (SGid 0 + IsAcceptedAdmin) are blocked from team participation.
- Teams are scoped per subgroup.
## Timestamp Format
- `CreatedAt` values are stored at minute precision:
- format: `YYYY-MM-DDTHH:mmZ`
- example: `2026-03-17T14:42Z`
- The app uses local JSON data from `DB` through `src/hooks/DbController.js`.
- Navigation is handled with React Navigation native stack.
+11 -31
View File
@@ -2,48 +2,28 @@
"expo": {
"name": "MAD_Treasure_Hunt_App",
"slug": "MAD_Treasure_Hunt_App",
"version": "1.0.0",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "madtreasurehuntapp",
"userInterfaceStyle": "automatic",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"splash": {
"image": "./assets/images/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
"foregroundImage": "./assets/images/android-icon-monochrome.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"package": "com.bobthebob.MAD_Treasure_Hunt_App"
"edgeToEdgeEnabled": true
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#000000"
}
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
"favicon": "./assets/favicon.png"
}
}
}
-18
View File
@@ -1,18 +0,0 @@
# ReadMe for the API endpoint
### This page is for my project partner to understand the API endpoint, how to use it and also when to use it.
The database will first be made locally with regular SQL.
Once the database is initialised and fully created locally, we can move the logic [refactor] it to use the API endpoint that will be created
api endpoint will be used here to fetch the data from the database and then display it on the frontend.
Same logic applies so minimal change in the code, for now we'll have the api endpoint point to the local database.
Therefore, still a need for the API.js file to be created and used in the frontend to fetch the data from the database and display it on the frontend.
To refactor its just going to be a simple replace.
### API Endpoint
``` (Endpoint Link/URL once it's made.) ```
### How to use it
[Will be populated once there is data for me to provide here.]
-249
View File
@@ -1,249 +0,0 @@
import dbController from '../Model/DbController';
const apiConfig = {
useLocalDb: true,
baseUrl: 'http://localhost:3000',
};
const setApiConfig = (nextConfig = {}) => {
Object.assign(apiConfig, nextConfig);
};
const isSameId = (item, id) =>
item.id?.toString() === id
|| item.Uid?.toString() === id
|| item.Gid?.toString() === id
|| item.SGid?.toString() === id
|| item.Tid?.toString() === id;
const normalize = (endpoint) => endpoint.replace(/^\/+/, '').split('/');
const localGet = (endpoint) => {
const [resource, id] = normalize(endpoint);
const state = dbController.getState();
if (!resource) {
return state;
}
if (!state[resource]) {
throw new Error(`Resource not found: ${resource}`);
}
if (!id) {
return state[resource];
}
return state[resource].find((item) => isSameId(item, id)) || null;
};
const localPost = (endpoint, payload) => {
const [resource] = normalize(endpoint);
if (resource === 'auth' && payload.action === 'login') {
return dbController.login(payload.data);
}
if (resource === 'auth' && payload.action === 'register') {
return dbController.register(payload.data);
}
if (resource === 'groups' && payload.action === 'joinPrivate') {
return dbController.joinPrivateGame(payload.data);
}
if (resource === 'groups' && payload.action === 'joinAsAdmin') {
return dbController.joinAsAdmin(payload.data);
}
if (resource === 'groups' && payload.action === 'createPrivate') {
return dbController.createPrivateGame(payload.data);
}
if (resource === 'groups' && payload.action === 'requestAdmin') {
return dbController.requestAdminApproval(payload.data);
}
if (resource === 'groups' && payload.action === 'approveAdmin') {
return dbController.approveAdmin(payload.data);
}
if (resource === 'groups' && payload.action === 'createdByUser') {
return dbController.getCreatedPrivateGameByUser(payload.data.userId);
}
if (resource === 'subgroups' && payload.action === 'create') {
return dbController.createSubgroup(payload.data);
}
if (resource === 'teams' && payload.action === 'create') {
return dbController.createTeam(payload.data);
}
if (resource === 'teams' && payload.action === 'join') {
return dbController.joinTeamByCode(payload.data);
}
const state = dbController.getState();
if (!state[resource]) {
throw new Error(`Resource not found: ${resource}`);
}
state[resource].push(payload);
return payload;
};
const localPut = (endpoint, payload) => {
const [resource, id] = normalize(endpoint);
const state = dbController.getState();
if (!state[resource]) {
throw new Error(`Resource not found: ${resource}`);
}
const index = state[resource].findIndex((item) =>
isSameId(item, id),
);
if (index < 0) {
throw new Error('Record not found');
}
state[resource][index] = { ...state[resource][index], ...payload };
return state[resource][index];
};
const localDelete = (endpoint) => {
const [resource, id] = normalize(endpoint);
const state = dbController.getState();
if (!state[resource]) {
throw new Error(`Resource not found: ${resource}`);
}
const index = state[resource].findIndex((item) =>
isSameId(item, id),
);
if (index < 0) {
return { deleted: false };
}
state[resource].splice(index, 1);
return { deleted: true };
};
const remoteRequest = async (endpoint, options = {}) => {
const response = await fetch(`${apiConfig.baseUrl}/${endpoint.replace(/^\/+/, '')}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
};
const APIFetch = async (endpoint) => {
if (apiConfig.useLocalDb) {
return localGet(endpoint);
}
return remoteRequest(endpoint, { method: 'GET' });
};
const APIPost = async (endpoint, data) => {
if (apiConfig.useLocalDb) {
return localPost(endpoint, data);
}
return remoteRequest(endpoint, { method: 'POST', body: JSON.stringify(data) });
};
const APIPut = async (endpoint, data) => {
if (apiConfig.useLocalDb) {
if (endpoint === 'groups/settings') {
return dbController.updateGroupSettings(data);
}
if (endpoint === 'subgroups/cache') {
return dbController.updateSubgroupCacheSettings(data);
}
return localPut(endpoint, data);
}
return remoteRequest(endpoint, { method: 'PUT', body: JSON.stringify(data) });
};
const APIDelete = async (endpoint) => {
if (apiConfig.useLocalDb) {
return localDelete(endpoint);
}
return remoteRequest(endpoint, { method: 'DELETE' });
};
const loginUser = async (email, password) => APIPost('auth', { action: 'login', data: { email, password } });
const registerUser = async (payload) => APIPost('auth', { action: 'register', data: payload });
const getGameTypes = async () => {
if (apiConfig.useLocalDb) {
return dbController.getGameTypes();
}
return APIFetch('game_types');
};
const joinPrivateGame = async (payload) => APIPost('groups', { action: 'joinPrivate', data: payload });
const joinAsAdmin = async (payload) => APIPost('groups', { action: 'joinAsAdmin', data: payload });
const createPrivateGame = async (payload) => APIPost('groups', { action: 'createPrivate', data: payload });
const getCreatedPrivateGame = async (userId) => APIPost('groups', { action: 'createdByUser', data: { userId } });
const requestAdminAccess = async (payload) => APIPost('groups', { action: 'requestAdmin', data: payload });
const approveAdminAccess = async (payload) => APIPost('groups', { action: 'approveAdmin', data: payload });
const createSubgroup = async (payload) => APIPost('subgroups', { action: 'create', data: payload });
const getSubgroups = async (gid) => {
if (apiConfig.useLocalDb) {
return dbController.getSubgroups(gid);
}
return APIFetch(`groups/${gid}/subgroups`);
};
const getVisibleSubgroups = async (gid) => {
if (apiConfig.useLocalDb) {
return dbController.getVisibleSubgroups(gid);
}
return APIFetch(`groups/${gid}/subgroups?visibleOnly=true`);
};
const updateSubgroupCache = async (payload) => APIPut('subgroups/cache', payload);
const updateGroupSettings = async (payload) => APIPut('groups/settings', payload);
const createTeam = async (payload) => APIPost('teams', { action: 'create', data: payload });
const joinTeam = async (payload) => APIPost('teams', { action: 'join', data: payload });
const getLobby = async (gid) => {
if (apiConfig.useLocalDb) {
return dbController.getLobby(gid);
}
return APIFetch(`groups/${gid}/lobby`);
};
const getTeam = async (tid) => {
if (apiConfig.useLocalDb) {
return dbController.getTeam(tid);
}
return APIFetch(`teams/${tid}`);
};
const getMapPoints = async (gid, sgid = null) => {
if (apiConfig.useLocalDb) {
return dbController.getMapPoints(gid, sgid);
}
if (sgid === null || sgid === undefined) {
return APIFetch(`groups/${gid}/map`);
}
return APIFetch(`groups/${gid}/map/${sgid}`);
};
export {
apiConfig,
setApiConfig,
APIFetch,
APIPost,
APIPut,
APIDelete,
loginUser,
registerUser,
getGameTypes,
joinPrivateGame,
joinAsAdmin,
createPrivateGame,
getCreatedPrivateGame,
requestAdminAccess,
approveAdminAccess,
createSubgroup,
getSubgroups,
getVisibleSubgroups,
updateSubgroupCache,
updateGroupSettings,
createTeam,
joinTeam,
getLobby,
getTeam,
getMapPoints,
};
// Default export to satisfy Expo Router route file requirement when the folder is scanned.
// This module primarily provides named exports; returning null as a component is safe
// and prevents the router warning about missing default export.
export default function APIPlaceholder() {
return null;
}
-32
View File
@@ -1,32 +0,0 @@
import { useState } from 'react';
import { useNavigation } from '@react-navigation/native';
const useLoginLogic = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const navigation = useNavigation();
const handleLogin = async () => {
if (!email || !password) {
return false;
}
if (email.trim().toLowerCase().includes('admin')) {
navigation.navigate('(routes)/admin-hub');
return true;
}
navigation.navigate('(routes)/game-type');
return true;
};
return {
email,
password,
setEmail,
setPassword,
handleLogin,
};
};
export default useLoginLogic;
-526
View File
@@ -1,526 +0,0 @@
import usersSeed from '../../DB/users.json';
import groupsSeed from '../../DB/groups.json';
import subgroupsSeed from '../../DB/subgroups.json';
import subgroupMembershipsSeed from '../../DB/subgroup-memberships.json';
import adminWaitlistSeed from '../../DB/admin-waitlist.json';
import teamsSeed from '../../DB/teams.json';
import teamMembersSeed from '../../DB/team-members.json';
import gameDataSeed from '../../DB/game-data.json';
import gameTypesSeed from '../../DB/game-types.json';
import UserModel from './UserModel';
import GroupModel from './GroupModel';
import TeamModel from './TeamModel';
const buildSeedState = () => ({
users: usersSeed,
groups: groupsSeed,
subgroups: subgroupsSeed,
subgroup_memberships: subgroupMembershipsSeed,
admin_waitlist: adminWaitlistSeed,
teams: teamsSeed,
team_members: teamMembersSeed,
game_data: gameDataSeed,
game_types: gameTypesSeed,
});
class DbController {
constructor(seed = buildSeedState()) {
this.seed = JSON.parse(JSON.stringify(seed));
this.reset();
}
reset() {
this.db = JSON.parse(JSON.stringify(this.seed));
}
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)}`;
}
toMinuteIso(date = new Date()) {
return `${date.toISOString().slice(0, 16)}Z`;
}
generateJoinCode(length = 5) {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let code = '';
for (let i = 0; i < length; i += 1) {
code += charset[Math.floor(Math.random() * charset.length)];
}
return code;
}
generateUniqueJoinCode(collection, key) {
let code = this.generateJoinCode(Math.random() > 0.5 ? 5 : 6);
while (collection.some((item) => item[key] === code)) {
code = this.generateJoinCode(Math.random() > 0.5 ? 5 : 6);
}
return code;
}
nextId(counterKey) {
const keyToCollection = {
user: { collection: 'users', field: 'Uid' },
group: { collection: 'groups', field: 'Gid' },
team: { collection: 'teams', field: 'Tid' },
};
const config = keyToCollection[counterKey];
if (!config) {
throw new Error(`Unsupported counter key: ${counterKey}`);
}
const rows = this.db[config.collection] || [];
const maxId = rows.reduce((max, row) => {
const value = Number(row[config.field] || 0);
return value > max ? value : max;
}, 0);
return maxId + 1;
}
getUserByEmail(email) {
return this.db.users.find((u) => u.email.toLowerCase() === email.toLowerCase()) || null;
}
getUserById(uid) {
return this.db.users.find((u) => u.Uid === Number(uid)) || null;
}
getGroupById(gid) {
return this.db.groups.find((group) => group.Gid === Number(gid)) || null;
}
getCreatedPrivateGameByUser(uid) {
return this.db.groups.find(
(group) => group.CreatedByUid === Number(uid) && group.GameType === 'private',
) || null;
}
register({ username, email, password, confirmPassword, accountType }) {
if (!username || !email || !password || !confirmPassword) {
throw new Error('Missing required registration fields');
}
if (password !== confirmPassword) {
throw new Error('Passwords do not match');
}
if (this.getUserByEmail(email)) {
throw new Error('Email already in use');
}
const uid = this.nextId('user');
const user = new UserModel({
Uid: uid,
username,
email,
passwordHash: this.hashPassword(password),
isBusiness: accountType === 'Business/School',
IsAcceptedAdmin: false,
Gid: null,
SGid: null,
TGid: null,
});
this.db.users.push(user.toJSON());
return user.toJSON();
}
login({ email, password }) {
const user = this.getUserByEmail(email);
if (!user) {
throw new Error('Email not found');
}
const incomingHash = this.hashPassword(password);
if (incomingHash !== user.passwordHash) {
throw new Error('Invalid password');
}
return user;
}
getGameTypes() {
return this.db.game_types;
}
getSubgroupByJoinCode(joinCode) {
return this.db.subgroups.find(
(subgroup) => subgroup.JoinCode && subgroup.JoinCode === joinCode.toUpperCase(),
) || null;
}
ensureMembership(uid, gid, sgid, isAcceptedAdmin) {
const existing = this.db.subgroup_memberships.find(
(membership) => membership.Uid === uid && membership.Gid === gid,
);
if (existing) {
existing.SGid = sgid;
existing.IsAcceptedAdmin = isAcceptedAdmin;
return existing;
}
const membership = {
Uid: uid,
Gid: gid,
SGid: sgid,
IsAcceptedAdmin: isAcceptedAdmin,
};
this.db.subgroup_memberships.push(membership);
return membership;
}
ensureGroupGameData(gid) {
const existing = this.db.game_data.find((entry) => entry.Gid === gid);
if (existing) {
return existing;
}
const payload = { Gid: gid, GeoCaches: [] };
this.db.game_data.push(payload);
return payload;
}
hasActiveAdmin(gid) {
return this.db.subgroup_memberships.some(
(membership) => membership.Gid === gid && membership.SGid === 0 && membership.IsAcceptedAdmin,
);
}
canJoinTeamForSubgroup(group, sgid) {
return group.TeamsEnabled && sgid > 0;
}
joinPrivateGame({ userId, joinCode }) {
const user = this.getUserById(Number(userId));
const subgroup = this.getSubgroupByJoinCode(joinCode);
if (!user || !subgroup || subgroup.IsAdminGroup) {
throw new Error('Invalid subgroup join code');
}
const group = this.getGroupById(subgroup.Gid);
if (!group) {
throw new Error('Group not found');
}
this.ensureMembership(user.Uid, subgroup.Gid, subgroup.SGid, false);
user.Gid = group.Gid;
user.SGid = subgroup.SGid;
user.IsAcceptedAdmin = false;
return group;
}
createPrivateGame({ userId, groupName, businessOrSchoolName, gameType = 'private', isBusiness = false }) {
const user = this.getUserById(Number(userId));
if (!user) {
throw new Error('User not found');
}
const existing = this.getCreatedPrivateGameByUser(user.Uid);
if (existing) {
return existing;
}
const gid = this.nextId('group');
const isBusinessGroup = Boolean(isBusiness);
const group = new GroupModel({
Gid: gid,
GroupName: groupName,
BusinessOrSchoolName: businessOrSchoolName || groupName,
GameType: gameType,
AdminJoinCode: this.generateUniqueJoinCode(this.db.groups, 'AdminJoinCode'),
CreatedByUid: user.Uid,
MaxMemberSubgroups: isBusinessGroup ? 999 : 1,
TeamsEnabled: true,
IsBusinessGroup: isBusinessGroup,
ApprovedAdmins: isBusinessGroup ? [user.Uid] : [],
CreatedAt: this.toMinuteIso(),
});
this.db.groups.push(group.toJSON());
this.ensureGroupGameData(gid);
this.db.subgroups.push({
SGid: 0,
SubGroupName: 'Admins',
Gid: group.Gid,
JoinCode: null,
IsAdminGroup: true,
IsGameStarted: false,
CacheTriggerMeters: 20,
});
if (!isBusinessGroup) {
this.createSubgroup({ gid: group.Gid, subgroupName: 'Members', cacheTriggerMeters: 20 });
}
const ownerSgid = isBusinessGroup ? 0 : 1;
const ownerIsAcceptedAdmin = isBusinessGroup;
this.ensureMembership(user.Uid, group.Gid, ownerSgid, ownerIsAcceptedAdmin);
user.Gid = group.Gid;
user.SGid = ownerSgid;
user.IsAcceptedAdmin = ownerIsAcceptedAdmin;
return group.toJSON();
}
joinAsAdmin({ userId, businessOrSchoolName }) {
const user = this.getUserById(Number(userId));
const group = this.db.groups.find(
(entry) => entry.BusinessOrSchoolName.toLowerCase() === businessOrSchoolName.trim().toLowerCase(),
);
if (!user || !group) {
throw new Error('User or business/school not found');
}
this.ensureMembership(user.Uid, group.Gid, 0, false);
user.Gid = group.Gid;
user.SGid = 0;
user.IsAcceptedAdmin = false;
const activeAdminExists = this.hasActiveAdmin(group.Gid);
if (!activeAdminExists) {
return this.promoteAdmin({ uid: user.Uid, gid: group.Gid, approvedByUid: user.Uid });
}
const alreadyQueued = this.db.admin_waitlist.some(
(entry) => entry.Uid === user.Uid && entry.Gid === group.Gid && entry.Status === 'pending',
);
if (!alreadyQueued) {
this.db.admin_waitlist.push({
RequestId: `${group.Gid}_${user.Uid}`,
Uid: user.Uid,
Gid: group.Gid,
RequestedAt: this.toMinuteIso(),
Status: 'pending',
});
}
return { queued: true, Gid: group.Gid, Uid: user.Uid };
}
promoteAdmin({ uid, gid, approvedByUid }) {
const user = this.getUserById(Number(uid));
const group = this.getGroupById(Number(gid));
if (!user || !group) {
throw new Error('User or group not found');
}
this.ensureMembership(user.Uid, group.Gid, 0, true);
user.Gid = group.Gid;
user.SGid = 0;
user.IsAcceptedAdmin = true;
if (!group.ApprovedAdmins.includes(user.Uid)) {
group.ApprovedAdmins.push(user.Uid);
}
if (!group.ApprovedAdmins.includes(Number(approvedByUid))) {
group.ApprovedAdmins.push(Number(approvedByUid));
}
this.db.admin_waitlist = this.db.admin_waitlist.map((entry) => {
if (entry.Uid === user.Uid && entry.Gid === group.Gid && entry.Status === 'pending') {
return { ...entry, Status: 'approved' };
}
return entry;
});
return user;
}
requestAdminApproval({ userId, groupId }) {
const group = this.getGroupById(Number(groupId));
if (!group) {
throw new Error('Group not found');
}
return this.joinAsAdmin({ userId, businessOrSchoolName: group.BusinessOrSchoolName });
}
approveAdmin({ approverUserId, userId, groupId }) {
const approverMembership = this.db.subgroup_memberships.find(
(membership) => membership.Uid === Number(approverUserId)
&& membership.Gid === Number(groupId)
&& membership.SGid === 0
&& membership.IsAcceptedAdmin,
);
if (!approverMembership) {
throw new Error('Approver is not an admin for this group');
}
return this.promoteAdmin({ uid: Number(userId), gid: Number(groupId), approvedByUid: Number(approverUserId) });
}
createSubgroup({ gid, subgroupName, cacheTriggerMeters = 20 }) {
const group = this.getGroupById(Number(gid));
if (!group) {
throw new Error('Group not found');
}
const nonAdminCount = this.db.subgroups.filter((entry) => entry.Gid === group.Gid && !entry.IsAdminGroup).length;
if (nonAdminCount >= group.MaxMemberSubgroups) {
throw new Error('Maximum subgroup count reached');
}
const nextSgid = this.db.subgroups
.filter((entry) => entry.Gid === group.Gid && !entry.IsAdminGroup)
.reduce((max, entry) => Math.max(max, entry.SGid), 0) + 1;
const subgroup = {
SGid: nextSgid,
SubGroupName: subgroupName,
Gid: group.Gid,
JoinCode: this.generateUniqueJoinCode(this.db.subgroups.filter((entry) => !entry.IsAdminGroup), 'JoinCode'),
IsAdminGroup: false,
IsGameStarted: false,
CacheTriggerMeters: cacheTriggerMeters,
};
this.db.subgroups.push(subgroup);
return subgroup;
}
getSubgroups(gid) {
return this.db.subgroups.filter((subgroup) => subgroup.Gid === Number(gid));
}
getVisibleSubgroups(gid) {
return this.getSubgroups(gid).filter((subgroup) => Number(subgroup.SGid) > 0);
}
updateSubgroupCacheSettings({ gid, sgid, triggerMeters }) {
const subgroup = this.db.subgroups.find(
(entry) => entry.Gid === Number(gid) && entry.SGid === Number(sgid),
);
if (!subgroup) {
throw new Error('Subgroup not found');
}
subgroup.CacheTriggerMeters = Number(triggerMeters);
return subgroup;
}
createTeam({ userId, gid, teamName }) {
const user = this.getUserById(Number(userId));
if (!user) {
throw new Error('User not found');
}
const group = this.getGroupById(Number(gid));
if (!group) {
throw new Error('Group not found');
}
if (!this.canJoinTeamForSubgroup(group, user.SGid || 0)) {
throw new Error('Teams are disabled or user is in admin subgroup');
}
const tid = this.nextId('team');
const team = new TeamModel({
Tid: tid,
TeamName: teamName,
Gid: group.Gid,
JoinCode: this.generateUniqueJoinCode(this.db.teams, 'JoinCode'),
CreatorUid: user.Uid,
SGid: user.SGid,
CreatedAt: this.toMinuteIso(),
});
this.db.teams.push(team.toJSON());
this.db.team_members.push({
Tid: team.Tid,
Uid: user.Uid,
IsTeamCreator: true,
});
user.TGid = team.Tid;
return team.toJSON();
}
joinTeamByCode({ userId, teamCode }) {
const user = this.getUserById(Number(userId));
const team = this.db.teams.find((t) => t.JoinCode === teamCode.toUpperCase());
if (!user || !team) {
throw new Error('Invalid user or team code');
}
const group = this.getGroupById(team.Gid);
if (!group || !this.canJoinTeamForSubgroup(group, user.SGid || 0)) {
throw new Error('Teams are disabled or user is in admin subgroup');
}
if (user.Gid !== team.Gid) {
throw new Error('User is not in this game group');
}
const alreadyInTeam = this.db.team_members.some((tm) => tm.Tid === team.Tid && tm.Uid === user.Uid);
if (!alreadyInTeam) {
this.db.team_members.push({ Tid: team.Tid, Uid: user.Uid, IsTeamCreator: false });
}
user.TGid = team.Tid;
return team;
}
getLobby(gid) {
const group = this.getGroupById(Number(gid));
if (!group) {
return null;
}
const members = this.db.subgroup_memberships
.filter((membership) => membership.Gid === group.Gid)
.map((membership) => this.getUserById(membership.Uid))
.filter(Boolean);
return {
group,
members,
subgroups: this.getSubgroups(group.Gid),
teams: this.db.teams.filter((team) => team.Gid === group.Gid),
};
}
getTeam(tid) {
const team = this.db.teams.find((entry) => entry.Tid === Number(tid));
if (!team) {
return null;
}
const members = this.db.team_members
.filter((member) => member.Tid === team.Tid)
.map((member) => this.getUserById(member.Uid))
.filter(Boolean);
return { team, members };
}
getMapPoints(gid, sgid = null) {
const gameData = this.db.game_data.find((entry) => entry.Gid === Number(gid));
if (!gameData) {
return [];
}
if (sgid === null || sgid === undefined) {
return gameData.GeoCaches;
}
return gameData.GeoCaches.filter((cache) => cache.SGid === Number(sgid));
}
updateGroupSettings({ gid, teamsEnabled }) {
const group = this.getGroupById(Number(gid));
if (!group) {
throw new Error('Group not found');
}
group.TeamsEnabled = Boolean(teamsEnabled);
return group;
}
getState() {
return this.db;
}
}
const dbController = new DbController();
export { DbController };
export default dbController;
-34
View File
@@ -1,34 +0,0 @@
class GroupModel {
constructor(payload) {
this.Gid = payload.Gid;
this.GroupName = payload.GroupName;
this.BusinessOrSchoolName = payload.BusinessOrSchoolName;
this.GameType = payload.GameType;
this.AdminJoinCode = payload.AdminJoinCode;
this.CreatedByUid = payload.CreatedByUid;
this.MaxMemberSubgroups = payload.MaxMemberSubgroups;
this.TeamsEnabled = Boolean(payload.TeamsEnabled);
this.IsBusinessGroup = payload.IsBusinessGroup !== undefined ? Boolean(payload.IsBusinessGroup) : true;
this.ApprovedAdmins = payload.ApprovedAdmins || [];
this.CreatedAt = payload.CreatedAt;
}
toJSON() {
return {
Gid: this.Gid,
GroupName: this.GroupName,
BusinessOrSchoolName: this.BusinessOrSchoolName,
GameType: this.GameType,
AdminJoinCode: this.AdminJoinCode,
CreatedByUid: this.CreatedByUid,
MaxMemberSubgroups: this.MaxMemberSubgroups,
TeamsEnabled: this.TeamsEnabled,
IsBusinessGroup: this.IsBusinessGroup,
ApprovedAdmins: this.ApprovedAdmins,
CreatedAt: this.CreatedAt,
};
}
}
export default GroupModel;
-45
View File
@@ -1,45 +0,0 @@
const session = {
currentUid: null,
currentGid: null,
currentSGid: null,
currentTGid: null,
currentIsBusiness: null,
currentIsAcceptedAdmin: false,
};
const setSessionUser = (user) => {
session.currentUid = user?.Uid ?? null;
session.currentGid = user?.Gid ?? null;
session.currentSGid = user?.SGid ?? null;
session.currentTGid = user?.TGid ?? null;
session.currentIsBusiness = user?.isBusiness ?? null;
session.currentIsAcceptedAdmin = Boolean(user?.IsAcceptedAdmin);
};
const setSessionGroup = (gid, sgid = null) => {
session.currentGid = gid ?? null;
if (sgid !== null && sgid !== undefined) {
session.currentSGid = sgid;
}
};
const setSessionMembership = ({ gid = null, sgid = null, isAcceptedAdmin = false } = {}) => {
session.currentGid = gid;
session.currentSGid = sgid;
session.currentIsAcceptedAdmin = Boolean(isAcceptedAdmin);
};
const setSessionTeam = (tgid) => {
session.currentTGid = tgid ?? null;
};
const getSession = () => ({ ...session });
export { setSessionUser, setSessionGroup, setSessionMembership, setSessionTeam, getSession };
// Default placeholder export to avoid Expo Router treating this non-component file as a route without a default.
export default function SessionStorePlaceholder() {
return null;
}
-26
View File
@@ -1,26 +0,0 @@
class TeamModel {
constructor(payload) {
this.Tid = payload.Tid;
this.TeamName = payload.TeamName;
this.Gid = payload.Gid;
this.JoinCode = payload.JoinCode;
this.CreatorUid = payload.CreatorUid;
this.SGid = payload.SGid;
this.CreatedAt = payload.CreatedAt;
}
toJSON() {
return {
Tid: this.Tid,
TeamName: this.TeamName,
Gid: this.Gid,
JoinCode: this.JoinCode,
CreatorUid: this.CreatorUid,
SGid: this.SGid,
CreatedAt: this.CreatedAt,
};
}
}
export default TeamModel;
-30
View File
@@ -1,30 +0,0 @@
class UserModel {
constructor(payload) {
this.Uid = payload.Uid;
this.username = payload.username;
this.email = payload.email;
this.passwordHash = payload.passwordHash;
this.isBusiness = Boolean(payload.isBusiness);
this.IsAcceptedAdmin = Boolean(payload.IsAcceptedAdmin);
this.Gid = payload.Gid ?? null;
this.SGid = payload.SGid ?? null;
this.TGid = payload.TGid ?? null;
}
toJSON() {
return {
Uid: this.Uid,
username: this.username,
email: this.email,
passwordHash: this.passwordHash,
isBusiness: this.isBusiness,
IsAcceptedAdmin: this.IsAcceptedAdmin,
Gid: this.Gid,
SGid: this.SGid,
TGid: this.TGid,
};
}
}
export default UserModel;
-96
View File
@@ -1,96 +0,0 @@
import React from 'react';
import {Pressable, ScrollView, StyleSheet, Text, View} from 'react-native';
import {useNavigation} from '@react-navigation/native';
import AdminFooterNavbar from '../components/AdminFooterNavbar';
import useAdminViewModel from '../ViewModel/useAdminViewModel';
import ScreenHeader from '../components/ScreenHeader';
import {getSession} from '../Model/SessionStore';
const AdminHubPage = () => {
const navigation = useNavigation();
const { currentIsBusiness } = getSession();
const { createGame } = useAdminViewModel();
const handleCreateGame = async () => {
await createGame({
groupName: `Admin Game ${Date.now().toString().slice(-4)}`,
businessOrSchoolName: 'New Business',
gameType: 'private',
isBusiness: true,
});
navigation.navigate('(routes)/game-lobby');
};
if (!currentIsBusiness) {
return (
<View style={styles.wrapper}>
<ScreenHeader title="Admin Hub" />
<View style={styles.container}>
<Text style={styles.message}>
As an individual, your game is managed automatically. You can view and manage your active team from the Game Lobby.
</Text>
<Pressable style={styles.button} onPress={() => navigation.navigate('(routes)/game-lobby')}>
<Text style={styles.buttonText}>Go to Game Lobby</Text>
</Pressable>
</View>
<AdminFooterNavbar />
</View>
);
}
return (
<View style={styles.wrapper}>
<ScreenHeader title="Admin Hub" />
<View style={styles.container}>
<ScrollView style={styles.buttonList}>
<Pressable style={styles.button} onPress={handleCreateGame}>
<Text style={styles.buttonText}>Create a Game</Text>
</Pressable>
<Pressable style={styles.button} onPress={() => navigation.navigate('(routes)/game-lobby')}>
<Text style={styles.buttonText}>Manage Existing Game</Text>
</Pressable>
<Pressable style={styles.button} onPress={() => navigation.navigate('(routes)/manage-subgroups')}>
<Text style={styles.buttonText}>Manage Subgroups</Text>
</Pressable>
</ScrollView>
</View>
<AdminFooterNavbar />
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
flex: 1,
backgroundColor: '#ffffff',
},
container: {
flex: 1,
justifyContent: 'center',
padding: 20,
gap: 12,
},
message: {
color: '#4b5563',
fontSize: 15,
lineHeight: 22,
marginBottom: 20,
},
buttonList: {
flex: 1,
},
button: {
backgroundColor: '#2563eb',
borderRadius: 8,
paddingVertical: 12,
alignItems: 'center',
marginBottom: 10,
},
buttonText: {
color: '#ffffff',
fontWeight: '600',
},
});
export default AdminHubPage;
-34
View File
@@ -1,34 +0,0 @@
import React from 'react';
import {StyleSheet, Text, View} from 'react-native';
import ScreenHeader from '../components/ScreenHeader';
const CreateGamePage = () => {
return (
<View style={styles.wrapper}>
<ScreenHeader title="Create Game" />
<View style={styles.container}>
<Text style={styles.body}>Create game setup goes here.</Text>
</View>
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
flex: 1,
backgroundColor: '#ffffff',
},
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
body: {
color: '#4b5563',
fontSize: 15,
},
});
export default CreateGamePage;
-128
View File
@@ -1,128 +0,0 @@
import React, {useState} from 'react';
import {Pressable, StyleSheet, Text, TextInput, View} from 'react-native';
import {useNavigation} from '@react-navigation/native';
import MemberFooterNavbar from '../components/MemberFooterNavbar';
import useLobbyViewModel from '../ViewModel/useLobbyViewModel';
import ScreenHeader from '../components/ScreenHeader';
import {getSession} from '../Model/SessionStore';
const GameLobbyPage = () => {
const navigation = useNavigation();
const { currentSGid, currentIsAcceptedAdmin } = getSession();
const { teamCode, error, setTeamCode, createLobbyTeam, joinLobbyTeam } = useLobbyViewModel();
const [hasTeam, setHasTeam] = useState(false);
const isAdminUser = currentSGid === 0 && currentIsAcceptedAdmin;
const handleCreateTeam = async () => {
const team = await createLobbyTeam({
teamName: `Team-${Date.now().toString().slice(-4)}`,
});
if (!team) {
return;
}
setHasTeam(true);
navigation.navigate('(routes)/team');
};
const handleJoinTeam = async () => {
const team = await joinLobbyTeam();
if (!team) {
return;
}
setHasTeam(true);
navigation.navigate('(routes)/team');
};
return (
<View style={styles.wrapper}>
<ScreenHeader title="Game Lobby" />
<View style={styles.container}>
{isAdminUser && (
<View style={styles.adminMessage}>
<Text style={styles.adminMessageText}>
Admins cannot participate in teams. Manage your game from the Admin Hub instead.
</Text>
</View>
)}
{!isAdminUser && (
<>
<TextInput
style={styles.input}
placeholder="Join Team Code"
autoCapitalize="characters"
maxLength={6}
value={teamCode}
onChangeText={setTeamCode}
/>
<Pressable style={styles.secondaryButton} onPress={handleJoinTeam}>
<Text style={styles.buttonText}>Join Team</Text>
</Pressable>
<Pressable style={styles.primaryButton} onPress={handleCreateTeam}>
<Text style={styles.buttonText}>Create Team</Text>
</Pressable>
</>
)}
<Pressable style={styles.secondaryButton} onPress={() => navigation.navigate('(routes)/map')}>
<Text style={styles.buttonText}>Open Map</Text>
</Pressable>
{!!error && <Text style={styles.errorText}>{error}</Text>}
</View>
<MemberFooterNavbar visible={hasTeam} />
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
flex: 1,
backgroundColor: '#ffffff',
},
container: {
flex: 1,
justifyContent: 'center',
padding: 20,
gap: 12,
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
},
primaryButton: {
backgroundColor: '#2563eb',
borderRadius: 8,
paddingVertical: 12,
alignItems: 'center',
},
secondaryButton: {
backgroundColor: '#4b5563',
borderRadius: 8,
paddingVertical: 12,
alignItems: 'center',
},
buttonText: {
color: '#ffffff',
fontWeight: '600',
},
errorText: {
color: '#dc2626',
},
adminMessage: {
backgroundColor: '#fef3c7',
borderWidth: 1,
borderColor: '#fcd34d',
borderRadius: 8,
padding: 12,
marginBottom: 12,
},
adminMessageText: {
color: '#92400e',
fontSize: 13,
fontWeight: '500',
},
});
export default GameLobbyPage;
-60
View File
@@ -1,60 +0,0 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import useGameTypeViewModel from '../ViewModel/useGameTypeViewModel';
import ScreenHeader from '../components/ScreenHeader';
const GameTypePage = () => {
const navigation = useNavigation();
const { gameTypes } = useGameTypeViewModel();
const navigateByType = (label) => {
if (label === 'Join Global Game') {
navigation.navigate('(routes)/game-lobby');
return;
}
if (label === 'Join Private Game') {
navigation.navigate('(routes)/join-private-game');
return;
}
navigation.navigate('(routes)/manage-private-subtask');
};
return (
<View style={styles.wrapper}>
<ScreenHeader title="Select Game Type" />
<View style={styles.container}>
{gameTypes.map((label) => (
<Pressable key={label} style={styles.button} onPress={() => navigateByType(label)}>
<Text style={styles.buttonText}>{label}</Text>
</Pressable>
))}
</View>
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
flex: 1,
backgroundColor: '#ffffff',
},
container: {
flex: 1,
justifyContent: 'center',
padding: 20,
gap: 12,
},
button: {
backgroundColor: '#2563eb',
borderRadius: 8,
paddingVertical: 12,
alignItems: 'center',
},
buttonText: {
color: '#ffffff',
fontWeight: '600',
},
});
export default GameTypePage;
-76
View File
@@ -1,76 +0,0 @@
import React, { useState } from 'react';
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import usePrivateGameViewModel from '../ViewModel/usePrivateGameViewModel';
import ScreenHeader from '../components/ScreenHeader';
const JoinPrivateGamePage = () => {
const navigation = useNavigation();
const { joinCode, error, setJoinCode, joinWithCode } = usePrivateGameViewModel();
const [submitting, setSubmitting] = useState(false);
const handleJoinGame = async () => {
setSubmitting(true);
const group = await joinWithCode();
setSubmitting(false);
if (!group) {
return;
}
navigation.navigate('(routes)/game-lobby');
};
return (
<View style={styles.wrapper}>
<ScreenHeader title="Join Private Game" />
<View style={styles.container}>
<TextInput
style={styles.input}
placeholder="Enter Join Code"
autoCapitalize="characters"
maxLength={6}
value={joinCode}
onChangeText={setJoinCode}
/>
<Pressable style={styles.button} onPress={handleJoinGame}>
<Text style={styles.buttonText}>{submitting ? 'Joining...' : 'Join Game'}</Text>
</Pressable>
{!!error && <Text style={styles.errorText}>{error}</Text>}
</View>
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
flex: 1,
backgroundColor: '#ffffff',
},
container: {
flex: 1,
justifyContent: 'center',
padding: 20,
gap: 12,
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
},
button: {
backgroundColor: '#2563eb',
borderRadius: 8,
paddingVertical: 12,
alignItems: 'center',
},
buttonText: {
color: '#ffffff',
fontWeight: '600',
},
errorText: {
color: '#dc2626',
},
});
export default JoinPrivateGamePage;
-114
View File
@@ -1,114 +0,0 @@
import React, { useState } from 'react';
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import useAuthViewModel from '../ViewModel/useAuthViewModel';
import ScreenHeader from '../components/ScreenHeader';
const LoginPage = () => {
const navigation = useNavigation();
const {
email,
password,
error,
setEmail,
setPassword,
login,
} = useAuthViewModel();
const [submitting, setSubmitting] = useState(false);
const handleLogin = async () => {
setSubmitting(true);
const user = await login();
setSubmitting(false);
if (!user) {
return;
}
if (user.SGid === 0) {
navigation.navigate('(routes)/admin-hub');
return;
}
navigation.navigate('(routes)/game-type');
};
return (
<View style={styles.wrapper}>
<ScreenHeader title="Login" />
<View style={styles.container}>
<TextInput
style={styles.input}
placeholder="Email"
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
/>
<TextInput
style={styles.input}
placeholder="Password"
secureTextEntry
value={password}
onChangeText={setPassword}
/>
<View style={styles.row}>
<Pressable style={styles.leftButton} onPress={() => navigation.navigate('(routes)/register')}>
<Text style={styles.buttonText}>Register</Text>
</Pressable>
<Pressable style={styles.rightButton} onPress={handleLogin}>
<Text style={styles.buttonText}>{submitting ? 'Logging in...' : 'Login'}</Text>
</Pressable>
</View>
{!!error && <Text style={styles.errorText}>{error}</Text>}
</View>
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
flex: 1,
backgroundColor: '#ffffff',
},
container: {
flex: 1,
padding: 20,
justifyContent: 'center',
gap: 12,
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
},
row: {
flexDirection: 'row',
gap: 10,
marginTop: 4,
},
leftButton: {
flex: 1,
backgroundColor: '#4b5563',
borderRadius: 8,
alignItems: 'center',
paddingVertical: 12,
},
rightButton: {
flex: 1,
backgroundColor: '#2563eb',
borderRadius: 8,
alignItems: 'center',
paddingVertical: 12,
},
buttonText: {
color: '#ffffff',
fontWeight: '600',
},
errorText: {
color: '#dc2626',
marginTop: 4,
},
});
export default LoginPage;
-51
View File
@@ -1,51 +0,0 @@
import React from 'react';
import {Pressable, StyleSheet, Text, View} from 'react-native';
import {useNavigation} from '@react-navigation/native';
import ScreenHeader from '../components/ScreenHeader';
const ManageGamePage = () => {
const navigation = useNavigation();
return (
<View style={styles.wrapper}>
<ScreenHeader title="Manage Game" />
<View style={styles.container}>
<Text style={styles.body}>Manage your active team from here.</Text>
<Pressable style={styles.button} onPress={() => navigation.navigate('(routes)/team')}>
<Text style={styles.buttonText}>Open Team Manager</Text>
</Pressable>
</View>
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
flex: 1,
backgroundColor: '#ffffff',
},
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
gap: 12,
padding: 20,
},
body: {
color: '#4b5563',
fontSize: 15,
},
button: {
backgroundColor: '#2563eb',
borderRadius: 8,
paddingHorizontal: 16,
paddingVertical: 12,
},
buttonText: {
color: '#ffffff',
fontWeight: '600',
},
});
export default ManageGamePage;
-124
View File
@@ -1,124 +0,0 @@
import React, {useState} from 'react';
import {Pressable, StyleSheet, Text, TextInput, View} from 'react-native';
import {useNavigation} from '@react-navigation/native';
import useManagePrivateSubtaskViewModel from '../ViewModel/useManagePrivateSubtaskViewModel';
import ScreenHeader from '../components/ScreenHeader';
const ManagePrivateSubtaskPage = () => {
const navigation = useNavigation();
const {
businessOrSchoolName,
error,
isBusinessUser,
setBusinessOrSchoolName,
createNewPrivateGame,
resolveIndividualPrivateRoute,
joinAdminForPrivate,
} = useManagePrivateSubtaskViewModel();
const [submitting, setSubmitting] = useState(false);
const handleCreate = async () => {
setSubmitting(true);
if (!isBusinessUser) {
const nextRoute = await resolveIndividualPrivateRoute();
setSubmitting(false);
if (nextRoute) {
navigation.navigate(nextRoute);
}
return;
}
const group = await createNewPrivateGame();
setSubmitting(false);
if (!group) {
return;
}
navigation.navigate('(routes)/admin-hub');
};
const handleJoinAdmin = async () => {
setSubmitting(true);
const response = await joinAdminForPrivate();
setSubmitting(false);
if (!response) {
return;
}
navigation.navigate('(routes)/admin-hub');
};
return (
<View style={styles.wrapper}>
<ScreenHeader title="Manage Private Game" />
<View style={styles.container}>
<Pressable style={styles.primaryButton} onPress={handleCreate}>
<Text style={styles.buttonText}>{submitting ? 'Please wait...' : isBusinessUser ? 'Create New Private Game' : 'Create or Manage My Game'}</Text>
</Pressable>
{isBusinessUser && (
<View style={styles.adminJoinSection}>
<Text style={styles.helperText}>Business/School Name is only required for admin join.</Text>
<TextInput
style={styles.input}
placeholder="Business/School Name"
value={businessOrSchoolName}
onChangeText={setBusinessOrSchoolName}
/>
<Pressable style={styles.secondaryButton} onPress={handleJoinAdmin}>
<Text style={styles.buttonText}>Join as Admin for Private Game</Text>
</Pressable>
</View>
)}
{!!error && <Text style={styles.errorText}>{error}</Text>}
</View>
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
flex: 1,
backgroundColor: '#ffffff',
},
container: {
flex: 1,
justifyContent: 'center',
gap: 12,
padding: 20,
},
adminJoinSection: {
marginTop: 14,
gap: 12,
},
helperText: {
color: '#4b5563',
fontSize: 13,
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
},
primaryButton: {
backgroundColor: '#2563eb',
borderRadius: 8,
alignItems: 'center',
paddingVertical: 12,
},
secondaryButton: {
backgroundColor: '#4b5563',
borderRadius: 8,
alignItems: 'center',
paddingVertical: 12,
},
buttonText: {
color: '#ffffff',
fontWeight: '600',
},
errorText: {
color: '#dc2626',
},
});
export default ManagePrivateSubtaskPage;
-174
View File
@@ -1,174 +0,0 @@
import React from 'react';
import {Pressable, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native';
import useSubgroupAdminViewModel from '../ViewModel/useSubgroupAdminViewModel';
import AdminFooterNavbar from '../components/AdminFooterNavbar';
import ScreenHeader from '../components/ScreenHeader';
import EntityCard from '../components/UI/EntityCard';
import SearchBar from '../components/UI/SearchBar';
import {getSession} from '../Model/SessionStore';
const ManageSubgroupsPage = () => {
const {
filteredSubgroups,
searchQuery,
subgroupName,
cacheTriggerMeters,
error,
setSearchQuery,
setSubgroupName,
setCacheTriggerMeters,
addSubgroup,
updateCacheForSubgroup,
} = useSubgroupAdminViewModel();
const { currentIsBusiness } = getSession();
if (!currentIsBusiness) {
return (
<View style={styles.wrapper}>
<ScreenHeader title="Manage Subgroups" />
<View style={styles.container}>
<Text style={styles.message}>
Subgroups are managed automatically for individual accounts.
</Text>
</View>
<AdminFooterNavbar />
</View>
);
}
return (
<View style={styles.wrapper}>
<ScreenHeader title="Manage Subgroups" />
<View style={styles.container}>
<Pressable style={styles.createButton} onPress={addSubgroup}>
<Text style={styles.createButtonText}>+ Create Subgroup</Text>
</Pressable>
<SearchBar
placeholder="Search by name or ID"
value={searchQuery}
onChangeText={setSearchQuery}
/>
<ScrollView style={styles.cardList} showsVerticalScrollIndicator={false}>
{filteredSubgroups.length === 0 && (
<Text style={styles.emptyText}>
{searchQuery ? 'No subgroups match your search.' : 'No subgroups yet. Create one to get started.'}
</Text>
)}
{filteredSubgroups.map((subgroup) => (
<EntityCard
key={`${subgroup.Gid}_${subgroup.SGid}`}
title={subgroup.SubGroupName}
subtitle={`ID: ${subgroup.SGid}`}
metaLabel1="Status"
metaValue1={subgroup.IsGameStarted ? 'Active' : 'Inactive'}
metaLabel2="Teams"
metaValue2={String(true)}
onPress={() => {
updateCacheForSubgroup(subgroup.SGid, (subgroup.CacheTriggerMeters || 20) + 5);
}}
/>
))}
</ScrollView>
<View style={styles.createForm}>
<Text style={styles.formTitle}>Create New Subgroup</Text>
<TextInput
style={styles.input}
placeholder="Subgroup name"
value={subgroupName}
onChangeText={setSubgroupName}
/>
<TextInput
style={styles.input}
placeholder="Cache trigger meters"
keyboardType="numeric"
value={cacheTriggerMeters}
onChangeText={setCacheTriggerMeters}
/>
<Pressable style={styles.submitButton} onPress={addSubgroup}>
<Text style={styles.submitButtonText}>Create</Text>
</Pressable>
</View>
{!!error && <Text style={styles.errorText}>{error}</Text>}
</View>
<AdminFooterNavbar />
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
flex: 1,
backgroundColor: '#ffffff',
},
container: {
flex: 1,
padding: 20,
flexDirection: 'column',
},
message: {
textAlign: 'center',
color: '#6b7280',
fontSize: 15,
},
createButton: {
backgroundColor: '#2563eb',
borderWidth: 1,
borderColor: '#2563eb',
borderRadius: 8,
paddingVertical: 10,
paddingHorizontal: 12,
alignItems: 'center',
marginBottom: 12,
},
createButtonText: {
color: '#ffffff',
fontWeight: '600',
fontSize: 14,
},
cardList: {
flex: 1,
gap: 8,
},
emptyText: {
textAlign: 'center',
color: '#9ca3af',
fontSize: 14,
marginTop: 20,
},
createForm: {
gap: 10,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: '#d1d5db',
marginTop: 12,
},
formTitle: {
fontSize: 13,
fontWeight: '600',
color: '#111827',
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
},
submitButton: {
backgroundColor: '#4b5563',
borderRadius: 8,
alignItems: 'center',
paddingVertical: 10,
},
submitButtonText: {
color: '#ffffff',
fontWeight: '600',
},
errorText: {
color: '#dc2626',
marginTop: 8,
},
});
export default ManageSubgroupsPage;
-40
View File
@@ -1,40 +0,0 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import MemberFooterNavbar from '../components/MemberFooterNavbar';
import useMapViewModel from '../ViewModel/useMapViewModel';
import ScreenHeader from '../components/ScreenHeader';
const MapPage = () => {
const { points } = useMapViewModel();
return (
<View style={styles.wrapper}>
<ScreenHeader title="Map" />
<View style={styles.container}>
<Text style={styles.body}>Map and hunt markers appear here.</Text>
<Text style={styles.body}>Points loaded: {points.length}</Text>
</View>
<MemberFooterNavbar visible />
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
flex: 1,
backgroundColor: '#ffffff',
},
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
gap: 6,
},
body: {
color: '#4b5563',
},
});
export default MapPage;
-40
View File
@@ -1,40 +0,0 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import MemberFooterNavbar from '../components/MemberFooterNavbar';
import useTeamViewModel from '../ViewModel/useTeamViewModel';
import ScreenHeader from '../components/ScreenHeader';
const TeamPage = () => {
const { team } = useTeamViewModel();
return (
<View style={styles.wrapper}>
<ScreenHeader title="Team" />
<View style={styles.container}>
<Text style={styles.body}>Team details and members appear here.</Text>
<Text style={styles.body}>{team?.team?.TeamName || 'No team joined yet'}</Text>
</View>
<MemberFooterNavbar visible />
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
flex: 1,
backgroundColor: '#ffffff',
},
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
gap: 6,
},
body: {
color: '#4b5563',
},
});
export default TeamPage;
-29
View File
@@ -1,29 +0,0 @@
import {
approveAdminAccess,
createPrivateGame,
requestAdminAccess,
updateGroupSettings,
} from '../API/API';
import { getSession, setSessionGroup } from '../Model/SessionStore';
const useAdminViewModel = () => {
const createGame = async (payload) => {
const { currentUid } = getSession();
const group = await createPrivateGame({ ...payload, userId: currentUid });
setSessionGroup(group.Gid, 0);
return group;
};
const requestAdmin = async (payload) => requestAdminAccess(payload);
const approveAdmin = async (payload) => approveAdminAccess(payload);
const setTeamsEnabled = async (gid, teamsEnabled) => updateGroupSettings({ gid, teamsEnabled });
return {
createGame,
requestAdmin,
approveAdmin,
setTeamsEnabled,
};
};
export default useAdminViewModel;
-71
View File
@@ -1,71 +0,0 @@
import { useMemo, useState } from 'react';
import { loginUser, registerUser } from '../API/API';
import { setSessionUser } from '../Model/SessionStore';
const useAuthViewModel = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [username, setUsername] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [accountType, setAccountType] = useState('Individual');
const [error, setError] = useState('');
const [currentUser, setCurrentUser] = useState(null);
const canRegister = useMemo(
() => Boolean(username && email && password && confirmPassword),
[username, email, password, confirmPassword],
);
const login = async () => {
setError('');
try {
const user = await loginUser(email.trim(), password);
setCurrentUser(user);
setSessionUser(user);
return user;
} catch (e) {
setError(e.message);
return null;
}
};
const register = async () => {
setError('');
try {
const user = await registerUser({
username: username.trim(),
email: email.trim().toLowerCase(),
password,
confirmPassword,
accountType,
});
setCurrentUser(user);
setSessionUser(user);
return user;
} catch (e) {
setError(e.message);
return null;
}
};
return {
email,
password,
username,
confirmPassword,
accountType,
error,
currentUser,
canRegister,
setEmail,
setPassword,
setUsername,
setConfirmPassword,
setAccountType,
login,
register,
};
};
export default useAuthViewModel;
-23
View File
@@ -1,23 +0,0 @@
import { useEffect, useState } from 'react';
import { getGameTypes } from '../API/API';
const useGameTypeViewModel = () => {
const [gameTypes, setGameTypes] = useState([]);
useEffect(() => {
let mounted = true;
getGameTypes().then((types) => {
if (mounted) {
setGameTypes(types);
}
});
return () => {
mounted = false;
};
}, []);
return { gameTypes };
};
export default useGameTypeViewModel;
-51
View File
@@ -1,51 +0,0 @@
import { useState } from 'react';
import { createTeam, getLobby, joinTeam } from '../API/API';
import { getSession, setSessionTeam } from '../Model/SessionStore';
const useLobbyViewModel = () => {
const [teamCode, setTeamCode] = useState('');
const [error, setError] = useState('');
const loadLobby = async () => {
const { currentGid } = getSession();
return getLobby(currentGid);
};
const createLobbyTeam = async ({ teamName }) => {
setError('');
try {
const { currentUid, currentGid } = getSession();
const team = await createTeam({ userId: currentUid, gid: currentGid, teamName });
setSessionTeam(team.Tid);
return team;
} catch (e) {
setError(e.message);
return null;
}
};
const joinLobbyTeam = async () => {
setError('');
try {
const { currentUid } = getSession();
const team = await joinTeam({ userId: currentUid, teamCode: teamCode.trim().toUpperCase() });
setSessionTeam(team.Tid);
return team;
} catch (e) {
setError(e.message);
return null;
}
};
return {
teamCode,
error,
setTeamCode,
loadLobby,
createLobbyTeam,
joinLobbyTeam,
};
};
export default useLobbyViewModel;
@@ -1,97 +0,0 @@
import {useState} from 'react';
import {createPrivateGame, getCreatedPrivateGame, joinAsAdmin} from '../API/API';
import {getSession, setSessionMembership} from '../Model/SessionStore';
const useManagePrivateSubtaskViewModel = () => {
const [businessOrSchoolName, setBusinessOrSchoolName] = useState('');
const [error, setError] = useState('');
const isBusinessUser = Boolean(getSession().currentIsBusiness);
const findExistingPrivateGame = async () => {
const { currentUid } = getSession();
if (!currentUid) {
return null;
}
return getCreatedPrivateGame(currentUid);
};
const createNewPrivateGame = async () => {
setError('');
const { currentUid } = getSession();
try {
const group = await createPrivateGame({
userId: currentUid,
groupName: businessOrSchoolName.trim() || 'New Private Game',
businessOrSchoolName: businessOrSchoolName.trim() || 'Unnamed Business',
isBusiness: isBusinessUser,
});
if (isBusinessUser) {
setSessionMembership({ gid: group.Gid, sgid: 0, isAcceptedAdmin: true });
} else {
setSessionMembership({ gid: group.Gid, sgid: 1, isAcceptedAdmin: false });
}
return group;
} catch (e) {
setError(e.message);
return null;
}
};
const resolveIndividualPrivateRoute = async () => {
setError('');
try {
const existingGroup = await findExistingPrivateGame();
if (existingGroup) {
setSessionMembership({ gid: existingGroup.Gid, sgid: 1, isAcceptedAdmin: false });
return '(routes)/manage-game';
}
const created = await createNewPrivateGame();
if (!created) {
return null;
}
return '(routes)/create-game';
} catch (e) {
setError(e.message);
return null;
}
};
const joinAdminForPrivate = async () => {
setError('');
const { currentUid } = getSession();
try {
const response = await joinAsAdmin({
userId: currentUid,
businessOrSchoolName: businessOrSchoolName.trim(),
});
const gid = response?.Gid ?? null;
if (gid !== null) {
setSessionMembership({ gid, sgid: 0, isAcceptedAdmin: !response.queued });
}
if (response?.queued) {
setError('Admin access request submitted. You will see admin tools after approval.');
}
return response;
} catch (e) {
setError(e.message);
return null;
}
};
return {
businessOrSchoolName,
error,
isBusinessUser,
setBusinessOrSchoolName,
createNewPrivateGame,
resolveIndividualPrivateRoute,
joinAdminForPrivate,
};
};
export default useManagePrivateSubtaskViewModel;
-30
View File
@@ -1,30 +0,0 @@
import { useEffect, useState } from 'react';
import { getMapPoints } from '../API/API';
import { getSession } from '../Model/SessionStore';
const useMapViewModel = (groupId = null) => {
const [points, setPoints] = useState([]);
const { currentGid, currentSGid } = getSession();
const resolvedGid = groupId ?? currentGid;
useEffect(() => {
if (!resolvedGid) {
setPoints([]);
return;
}
let active = true;
getMapPoints(resolvedGid, currentSGid).then((payload) => {
if (active) {
setPoints(payload);
}
});
return () => {
active = false;
};
}, [resolvedGid, currentSGid]);
return { points };
};
export default useMapViewModel;
-31
View File
@@ -1,31 +0,0 @@
import { useState } from 'react';
import { joinPrivateGame } from '../API/API';
import { getSession, setSessionGroup } from '../Model/SessionStore';
const usePrivateGameViewModel = () => {
const [joinCode, setJoinCode] = useState('');
const [error, setError] = useState('');
const joinWithCode = async () => {
setError('');
try {
const { currentUid } = getSession();
const group = await joinPrivateGame({ userId: currentUid, joinCode: joinCode.trim().toUpperCase() });
setSessionGroup(group.Gid);
return group;
} catch (e) {
setError(e.message);
return null;
}
};
return {
joinCode,
error,
setJoinCode,
joinWithCode,
};
};
export default usePrivateGameViewModel;
@@ -1,82 +0,0 @@
import {useCallback, useEffect, useMemo, useState} from 'react';
import {createSubgroup, getVisibleSubgroups, updateSubgroupCache} from '../API/API';
import {getSession} from '../Model/SessionStore';
const useSubgroupAdminViewModel = () => {
const [subgroups, setSubgroups] = useState([]);
const [subgroupName, setSubgroupName] = useState('');
const [cacheTriggerMeters, setCacheTriggerMeters] = useState('20');
const [error, setError] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const { currentGid } = getSession();
const loadSubgroups = useCallback(async () => {
if (!currentGid) {
return;
}
const rows = await getVisibleSubgroups(currentGid);
setSubgroups(rows);
}, [currentGid]);
const filteredSubgroups = useMemo(() => {
if (!searchQuery.trim()) {
return subgroups;
}
const q = searchQuery.trim().toLowerCase();
return subgroups.filter((sg) => {
const nameMatch = sg.SubGroupName.toLowerCase().includes(q);
const idMatch = String(sg.SGid) === q;
return nameMatch || idMatch;
});
}, [subgroups, searchQuery]);
useEffect(() => {
loadSubgroups();
}, [loadSubgroups]);
const addSubgroup = async () => {
setError('');
try {
await createSubgroup({
gid: currentGid,
subgroupName: subgroupName.trim(),
cacheTriggerMeters: Number(cacheTriggerMeters),
});
setSubgroupName('');
await loadSubgroups();
return true;
} catch (e) {
setError(e.message);
return false;
}
};
const updateCacheForSubgroup = async (sgid, triggerMeters) => {
setError('');
try {
await updateSubgroupCache({ gid: currentGid, sgid, triggerMeters });
await loadSubgroups();
return true;
} catch (e) {
setError(e.message);
return false;
}
};
return {
subgroups,
filteredSubgroups,
searchQuery,
subgroupName,
cacheTriggerMeters,
error,
setSearchQuery,
setSubgroupName,
setCacheTriggerMeters,
addSubgroup,
updateCacheForSubgroup,
};
};
export default useSubgroupAdminViewModel;
-33
View File
@@ -1,33 +0,0 @@
import { useEffect, useState } from 'react';
import { getTeam } from '../API/API';
import { getSession } from '../Model/SessionStore';
const useTeamViewModel = (teamId = null) => {
const [team, setTeam] = useState(null);
const resolvedTeamId = teamId ?? getSession().currentTGid;
useEffect(() => {
if (!resolvedTeamId) {
setTeam(null);
return;
}
let active = true;
getTeam(resolvedTeamId).then((payload) => {
if (active) {
setTeam(payload);
}
}).catch(() => {
if (active) {
setTeam(null);
}
});
return () => {
active = false;
};
}, [resolvedTeamId]);
return { team };
};
export default useTeamViewModel;
-36
View File
@@ -1,36 +0,0 @@
import {Stack} from "expo-router";
export default function RootLayout() {
return (
<Stack initialRouteName="(routes)/index" screenOptions={{ headerShown: false }}>
<Stack.Screen
name="Screens/LoginPage"
options={{
gestureEnabled: false,
}}
/>
<Stack.Screen
name="Screens/LoginPage"
options={{
gestureEnabled: false,
}}
/>
<Stack.Screen
name="Screens/RegisterPage"
options={{
gestureEnabled: false,
}}
/>
<Stack.Screen name="Screens/GameTypePage"/>
<Stack.Screen name="Screens/JoinPrivateGamePage" />
<Stack.Screen name="Screens/ManagePrivateSubtaskPage" />
<Stack.Screen name="Screens/CreateGamePage" />
<Stack.Screen name="Screens/ManageGamePage" />
<Stack.Screen name="Screens/GameLobbyPage" />
<Stack.Screen name="Screens/TeamPage" />
<Stack.Screen name="Screens/MapPage" />
<Stack.Screen name="Screens/AdminHubPage" />
<Stack.Screen name="Screens/ManageSubgroupsPage" />
</Stack>
);
}
-50
View File
@@ -1,50 +0,0 @@
import React from 'react';
import { View, Pressable, Text, StyleSheet } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const AdminFooterNavbar = () => {
const navigation = useNavigation();
const insets = useSafeAreaInsets();
return (
<View style={[styles.container, { paddingBottom: Math.max(insets.bottom, 12) + 8 }]}>
<Pressable style={styles.button} onPress={() => navigation.navigate('(routes)/admin-hub')}>
<Text style={styles.text}>Admin Hub</Text>
</Pressable>
<Pressable style={styles.button} onPress={() => navigation.navigate('(routes)/game-lobby')}>
<Text style={styles.text}>Manage Game</Text>
</Pressable>
<Pressable style={styles.button} onPress={() => navigation.navigate('(routes)/team')}>
<Text style={styles.text}>Manage Team</Text>
</Pressable>
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
borderTopWidth: 1,
borderColor: '#d4d4d8',
paddingHorizontal: 16,
paddingTop: 14,
backgroundColor: '#ffffff',
minHeight: 86,
},
button: {
flex: 1,
alignItems: 'center',
paddingVertical: 8,
},
text: {
fontSize: 14,
fontWeight: '600',
color: '#111827',
},
});
export default AdminFooterNavbar;
-54
View File
@@ -1,54 +0,0 @@
import React from 'react';
import { View, Pressable, Text, StyleSheet } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const MemberFooterNavbar = ({ visible = false }) => {
const navigation = useNavigation();
const insets = useSafeAreaInsets();
if (!visible) {
return null;
}
return (
<View style={[styles.container, { paddingBottom: Math.max(insets.bottom, 12) + 8 }]}>
<Pressable style={styles.button} onPress={() => navigation.navigate('(routes)/game-lobby')}>
<Text style={styles.text}>Lobby</Text>
</Pressable>
<Pressable style={styles.button} onPress={() => navigation.navigate('(routes)/team')}>
<Text style={styles.text}>Team</Text>
</Pressable>
<Pressable style={styles.button} onPress={() => navigation.navigate('(routes)/map')}>
<Text style={styles.text}>Map</Text>
</Pressable>
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
borderTopWidth: 1,
borderColor: '#d4d4d8',
paddingHorizontal: 16,
paddingTop: 14,
backgroundColor: '#ffffff',
minHeight: 86,
},
button: {
flex: 1,
alignItems: 'center',
paddingVertical: 8,
},
text: {
fontSize: 14,
fontWeight: '600',
color: '#111827',
},
});
export default MemberFooterNavbar;
-108
View File
@@ -1,108 +0,0 @@
import React from 'react';
import {Pressable, StyleSheet, Text, View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {useRouter, useSegments} from 'expo-router';
import {getSession, setSessionGroup, setSessionTeam, setSessionUser} from '../Model/SessionStore';
const ScreenHeader = ({ title, showBack = true }) => {
const insets = useSafeAreaInsets();
const router = useRouter();
const segments = useSegments();
const session = getSession();
// Check if user is logged in by checking if currentUid exists
const isLoggedIn = Boolean(session.currentUid);
// Pages where back navigation should be disabled
const blockedLastSegments = ['login', 'register', 'index'];
// compute last meaningful segment (filter out route groups like "(routes)")
const meaningfulSegments = segments.filter((s) => !s.startsWith('('));
const lastSegment = meaningfulSegments.length > 0 ? meaningfulSegments[meaningfulSegments.length - 1] : 'index';
const isBlocked = blockedLastSegments.includes(lastSegment);
const handleBack = () => {
if (isBlocked || !showBack) return;
try {
router.back();
} catch {
// fallback: nothing
}
};
const handleLogout = () => {
// clear session and route to login (replace so user cannot go back)
try {
setSessionUser(null);
setSessionGroup(null);
setSessionTeam(null);
} catch {
// ignore
}
try {
router.replace('/login');
} catch {
// noop
}
};
return (
<View style={[styles.container, { paddingTop: insets.top + 10 }]}>
{showBack && !isBlocked ? (
<Pressable style={styles.backButton} onPress={handleBack} accessibilityLabel="Go back">
<Text style={styles.backText}>←</Text>
</Pressable>
) : (
<View style={styles.backButtonPlaceholder} />
)}
<Text style={styles.title}>{title}</Text>
{isLoggedIn ? (
<Pressable style={styles.backButton} onPress={handleLogout} accessibilityLabel="Logout">
<Text style={styles.backText}>⎋</Text>
</Pressable>
) : (
<View style={styles.backButtonPlaceholder} />
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: '#000000',
paddingHorizontal: 16,
paddingBottom: 12,
minHeight: 72,
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: 'space-between',
},
title: {
color: '#ffffff',
fontSize: 20,
fontWeight: '700',
},
backButton: {
width: 44,
height: 44,
justifyContent: 'center',
alignItems: 'center',
},
backText: {
color: '#ffffff',
fontSize: 22,
lineHeight: 22,
},
backButtonPlaceholder: {
width: 44,
height: 44,
},
rightPlaceholder: {
width: 44,
height: 44,
},
});
export default ScreenHeader;
-64
View File
@@ -1,64 +0,0 @@
import React from 'react';
import {Pressable, StyleSheet, Text, View} from 'react-native';
const EntityCard = ({
title,
subtitle,
metaLabel1,
metaValue1,
metaLabel2,
metaValue2,
metaLabel3,
metaValue3,
onPress,
}) => {
return (
<Pressable style={styles.card} onPress={onPress}>
<Text style={styles.title}>{title}</Text>
{subtitle && <Text style={styles.subtitle}>{subtitle}</Text>}
<View style={styles.metaContainer}>
{metaLabel1 && metaValue1 && (
<Text style={styles.metaText}>{metaLabel1}: {metaValue1}</Text>
)}
{metaLabel2 && metaValue2 && (
<Text style={styles.metaText}>{metaLabel2}: {metaValue2}</Text>
)}
{metaLabel3 && metaValue3 && (
<Text style={styles.metaText}>{metaLabel3}: {metaValue3}</Text>
)}
</View>
</Pressable>
);
};
const styles = StyleSheet.create({
card: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
padding: 12,
marginBottom: 10,
backgroundColor: '#ffffff',
},
title: {
fontSize: 15,
fontWeight: '700',
color: '#111827',
marginBottom: 4,
},
subtitle: {
fontSize: 13,
color: '#6b7280',
marginBottom: 8,
},
metaContainer: {
gap: 4,
},
metaText: {
fontSize: 13,
color: '#4b5563',
},
});
export default EntityCard;
-34
View File
@@ -1,34 +0,0 @@
import React from 'react';
import {StyleSheet, TextInput, View} from 'react-native';
const SearchBar = ({ placeholder = 'Search...', value, onChangeText, style = {} }) => {
return (
<View style={[styles.container, style]}>
<TextInput
style={styles.input}
placeholder={placeholder}
value={value}
onChangeText={onChangeText}
placeholderTextColor="#9ca3af"
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: 12,
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 14,
color: '#111827',
},
});
export default SearchBar;
-1
View File
@@ -1,4 +1,3 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');
+93 -838
View File
File diff suppressed because it is too large. Load diff
+10 -10
View File
@@ -1,45 +1,45 @@
{
"name": "mad_treasure_hunt_app",
"main": "expo-router/entry",
"main": "node_modules/expo/AppEntry.js",
"version": "1.0.0",
"scripts": {
"api": "node server/server.js",
"api:standalone": "npm --prefix server start",
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"start:api": "npm run api",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@react-navigation/native": "^7.1.18",
"@react-navigation/native-stack": "^7.3.28",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.23",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-location": "~19.0.7",
"expo-web-browser": "~15.0.10",
"geolib": "^3.3.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-maps": "1.20.1",
"react-native-worklets": "0.5.1",
"expo-sensors": "~15.0.7",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0"
},
"devDependencies": {
"@types/react": "~19.1.0",
"typescript": "~5.9.2",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0"
},
+38
View File
@@ -0,0 +1,38 @@
import {Pressable, StyleSheet, Text, View} from "react-native";
export const Button = ({ label, icon, onClick, styleLabel, styleButton }) => {
return (
<Pressable onPress={onClick} style={[styles.button, styleButton]}>
{icon ? icon : null}
<Text style={[styles.label, styleLabel]}>{label}</Text>
</Pressable>
);
};
export const ButtonTray = ({ children }) => {
return <View style={styles.buttonTray}>{children}</View>;
};
const styles = StyleSheet.create({
button: {
minHeight: 50,
borderWidth: 1,
borderRadius: 7,
borderColor: "grey",
backgroundColor: "white",
alignItems: "center",
justifyContent: "center",
padding: 3,
flex: 1,
flexDirection: "row",
gap: 5,
},
label: {
fontSize: 16,
},
buttonTray: {
flexDirection: "row",
gap: 15,
},
});
@@ -0,0 +1,46 @@
import React from 'react';
import MapView, {Circle, Marker} from 'react-native-maps';
const AdminCacheEditorView = ({ userLocation, caches, onAddCache, onMoveCache }) => {
if (!userLocation) {
return null;
}
return (
<MapView
style={{ flex: 1 }}
initialRegion={{
...userLocation,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
}}
region={{
...userLocation,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
}}
onLongPress={(event) => onAddCache(event.nativeEvent.coordinate)}
showsUserLocation
>
{(caches || []).map((cache) => (
<React.Fragment key={cache.id}>
<Marker
coordinate={cache.coordinates}
draggable
onDragEnd={(event) => onMoveCache(cache.id, event.nativeEvent.coordinate)}
title={`Cache ${cache.id}`}
/>
<Circle
center={cache.coordinates}
radius={cache.radius}
fillColor="rgba(59, 130, 246, 0.15)"
strokeColor="rgba(59, 130, 246, 0.85)"
/>
</React.Fragment>
))}
</MapView>
);
};
export default AdminCacheEditorView;
+48
View File
@@ -0,0 +1,48 @@
import React, {useEffect, useState} from 'react';
import {StyleSheet, Text, View} from 'react-native';
const ClaimTimerView = ({ cache, onClaimSuccess, isClaiming }) => {
const [timeLeft, setTimeLeft] = useState(5);
useEffect(() => {
let timer;
if (isClaiming && timeLeft > 0) {
timer = setTimeout(() => setTimeLeft((prev) => prev - 1), 1000);
} else if (isClaiming && timeLeft === 0 && cache) {
onClaimSuccess(cache.id);
} else {
setTimeLeft(5);
}
return () => clearTimeout(timer);
}, [isClaiming, timeLeft, cache, onClaimSuccess]);
if (!isClaiming) {
return null;
}
return (
<View style={styles.overlay}>
<Text style={styles.text}>Hold steady. Claiming in {timeLeft}...</Text>
</View>
);
};
const styles = StyleSheet.create({
overlay: {
position: 'absolute',
bottom: 24,
alignSelf: 'center',
backgroundColor: 'rgba(0,0,0,0.75)',
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 10,
},
text: {
color: '#ffffff',
fontSize: 16,
fontWeight: '700',
},
});
export default ClaimTimerView;
+46
View File
@@ -0,0 +1,46 @@
import React from 'react';
import MapView, {Circle} from 'react-native-maps';
import ClaimTimerView from './ClaimTimerView';
const PlayerMapView = ({ userLocation, visibleCache, isClaiming, onClaimSuccess }) => {
if (!userLocation) {
return null;
}
return (
<>
<MapView
style={{ flex: 1 }}
initialRegion={{
...userLocation,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
}}
region={{
...userLocation,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
}}
showsUserLocation
>
{visibleCache ? (
<Circle
center={visibleCache.coordinates}
radius={visibleCache.radius}
fillColor="rgba(250, 204, 21, 0.20)"
strokeColor="rgba(250, 204, 21, 0.90)"
/>
) : null}
</MapView>
<ClaimTimerView
cache={visibleCache}
isClaiming={isClaiming}
onClaimSuccess={onClaimSuccess}
/>
</>
);
};
export default PlayerMapView;
+74
View File
@@ -0,0 +1,74 @@
import {Pressable, StyleSheet, Text, View} from 'react-native';
const BottomNavbar = ({ navigation, routeName }) => {
const tabs = [
{
label: 'Map',
route: 'MapScreen',
onPress: () => navigation.navigate('Game', { screen: 'MapScreen' }),
},
{
label: 'Leaderboard',
route: 'LeaderboardScreen',
onPress: () => navigation.navigate('Game', { screen: 'LeaderboardScreen' }),
},
{
label: 'Current Team',
route: 'TeamScreen',
onPress: () => navigation.navigate('Game', { screen: 'TeamScreen' }),
},
];
return (
<View style={styles.container}>
{tabs.map((tab) => {
const isActive = routeName === tab.route;
return (
<Pressable
key={tab.route}
onPress={tab.onPress}
style={[styles.tab, isActive && styles.tabActive]}
>
<Text style={[styles.tabText, isActive && styles.tabTextActive]}>{tab.label}</Text>
</Pressable>
);
})}
</View>
);
};
const styles = StyleSheet.create({
container: {
minHeight: 64,
borderTopWidth: 1,
borderTopColor: '#e5e7eb',
backgroundColor: '#ffffff',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-around',
paddingHorizontal: 8,
},
tab: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
borderRadius: 8,
marginHorizontal: 4,
},
tabActive: {
backgroundColor: '#eff6ff',
},
tabText: {
color: '#374151',
fontSize: 12,
fontWeight: '600',
textAlign: 'center',
},
tabTextActive: {
color: '#2563eb',
},
});
export default BottomNavbar;
+53
View File
@@ -0,0 +1,53 @@
import {StatusBar} from "expo-status-bar";
import {StyleSheet, View} from "react-native";
import {useNavigation, useRoute} from "@react-navigation/native";
import TopNavbar from "./TopNavbar";
import BottomNavbar from "./BottomNavbar";
const Screen = ({ children, style }) => {
const navigation = useNavigation();
const route = useRoute();
const titleMap = {
LoginScreen: "Login",
RegisterScreen: "Register",
GameTypeScreen: "Game Type",
JoinPrivateGameScreen: "Join Private Game",
ManagePrivateGameScreen: "Manage Private Game",
CreateGameScreen: "Create Game",
ManageGameScreen: "Manage Game",
GameLobbyScreen: "Leaderboard",
LeaderboardScreen: "Leaderboard",
TeamScreen: "Current Team",
MapScreen: "Map",
AdminHubScreen: "Admin Hub",
ManageSubgroupsScreen: "Manage Subgroups",
};
const routeName = route.name;
const showBack = routeName !== "LoginScreen" && navigation.canGoBack();
const pageTitle = titleMap[routeName] || "Treasure Hunt";
return (
<View style={[styles.screen, style]}>
<TopNavbar title={pageTitle} showBack={showBack} navigation={navigation} />
<View style={styles.content}>{children}</View>
<BottomNavbar navigation={navigation} routeName={routeName} />
<StatusBar style="light" />
</View>
);
};
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: "#fff",
},
content: {
flex: 1,
padding: 15,
},
});
export default Screen;
+90
View File
@@ -0,0 +1,90 @@
import {Pressable, StyleSheet, Text, View} from 'react-native';
import {clearSession} from '../../hooks/SessionStore';
const TopNavbar = ({ title, showBack, navigation }) => {
const routeName = navigation.getState()?.routes?.[navigation.getState().index]?.name;
const showLogout = routeName !== 'LoginScreen' && routeName !== 'RegisterScreen';
const handleBack = () => {
if (navigation.canGoBack()) {
navigation.goBack();
}
};
const handleLogout = () => {
clearSession();
navigation.reset({
index: 0,
routes: [{ name: 'Auth', params: { screen: 'LoginScreen' } }],
});
};
return (
<View style={styles.container}>
<View style={styles.leftSlot}>
{showBack ? (
<Pressable onPress={handleBack} style={styles.iconButton}>
<Text style={styles.iconText}>{'<'}</Text>
</Pressable>
) : null}
</View>
<Text style={styles.title}>{title}</Text>
<View style={styles.rightSlot}>
{showLogout ? (
<Pressable onPress={handleLogout} style={styles.logoutButton}>
<Text style={styles.logoutText}>Logout</Text>
</Pressable>
) : null}
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
minHeight: 56,
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
backgroundColor: '#ffffff',
},
leftSlot: {
width: 72,
alignItems: 'flex-start',
},
rightSlot: {
width: 72,
alignItems: 'flex-end',
},
iconButton: {
paddingVertical: 8,
paddingHorizontal: 10,
},
iconText: {
fontSize: 20,
color: '#111827',
fontWeight: '700',
},
title: {
flex: 1,
textAlign: 'center',
fontSize: 18,
fontWeight: '700',
color: '#111827',
},
logoutButton: {
paddingVertical: 6,
paddingHorizontal: 8,
},
logoutText: {
color: '#2563eb',
fontSize: 13,
fontWeight: '600',
},
});
export default TopNavbar;
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
import {StyleSheet, Text} from 'react-native';
import Screen from '../layout/Screen';
const AdminHubScreen = () => (
<Screen style={styles.container}>
<Text style={styles.title}>Admin Hub</Text>
<Text style={styles.body}>Admin hub setup goes here.</Text>
</Screen>
);
const styles = StyleSheet.create({
container: { justifyContent: 'center', alignItems: 'center' },
title: { fontSize: 24, fontWeight: 'bold' },
body: { color: '#4b5563', fontSize: 15, marginTop: 10 },
});
export default AdminHubScreen;
@@ -0,0 +1,18 @@
import React from 'react';
import {StyleSheet, Text} from 'react-native';
import Screen from '../layout/Screen';
const CreateGameScreen = () => (
<Screen style={styles.container}>
<Text style={styles.title}>Create Game</Text>
<Text style={styles.body}>Create game setup goes here.</Text>
</Screen>
);
const styles = StyleSheet.create({
container: { justifyContent: 'center', alignItems: 'center' },
title: { fontSize: 24, fontWeight: 'bold' },
body: { color: '#4b5563', fontSize: 15, marginTop: 10 },
});
export default CreateGameScreen;
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
import {StyleSheet, Text} from 'react-native';
import Screen from '../layout/Screen';
const GameLobbyScreen = () => (
<Screen style={styles.container}>
<Text style={styles.title}>Game Lobby</Text>
<Text style={styles.body}>Game lobby setup goes here.</Text>
</Screen>
);
const styles = StyleSheet.create({
container: { justifyContent: 'center', alignItems: 'center' },
title: { fontSize: 24, fontWeight: 'bold' },
body: { color: '#4b5563', fontSize: 15, marginTop: 10 },
});
export default GameLobbyScreen;
+50
View File
@@ -0,0 +1,50 @@
import React from 'react';
import {Pressable, StyleSheet, Text, View} from 'react-native';
import useGameHook from '../../hooks/useGameHook';
import Screen from '../layout/Screen';
const GameTypeScreen = ({ navigation }) => {
const { getGameTypes } = useGameHook();
const gameTypes = getGameTypes();
return (
<Screen style={styles.container}>
<Text style={styles.title}>Select Game Type</Text>
<View style={styles.content}>
{gameTypes && gameTypes.map((type) => (
<Pressable key={type} style={styles.button} onPress={() => navigation.navigate('GameLobbyScreen')}>
<Text style={styles.buttonText}>{type}</Text>
</Pressable>
))}
</View>
</Screen>
);
};
const styles = StyleSheet.create({
container: {
justifyContent: 'center',
},
content: {
gap: 12,
flex: 1,
},
title: {
fontSize: 24,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 20,
},
button: {
backgroundColor: '#2563eb',
borderRadius: 8,
paddingVertical: 12,
alignItems: 'center',
},
buttonText: {
color: '#ffffff',
fontWeight: '600',
},
});
export default GameTypeScreen;
@@ -0,0 +1,30 @@
import React from 'react';
import {StyleSheet, Text} from 'react-native';
import Screen from '../layout/Screen';
const JoinPrivateGameScreen = () => {
return (
<Screen style={styles.container}>
<Text style={styles.title}>Join Private Game</Text>
<Text style={styles.body}>Join private game setup goes here.</Text>
</Screen>
);
};
const styles = StyleSheet.create({
container: {
justifyContent: 'center',
alignItems: 'center',
},
title: {
fontSize: 24,
fontWeight: 'bold',
},
body: {
color: '#4b5563',
fontSize: 15,
marginTop: 10,
},
});
export default JoinPrivateGameScreen;
@@ -0,0 +1,19 @@
import React from 'react';
import {StyleSheet, Text} from 'react-native';
import Screen from '../layout/Screen';
const LeaderboardScreen = () => (
<Screen style={styles.container}>
<Text style={styles.title}>Leaderboard</Text>
<Text style={styles.body}>Leaderboard view goes here.</Text>
</Screen>
);
const styles = StyleSheet.create({
container: { justifyContent: 'center', alignItems: 'center' },
title: { fontSize: 24, fontWeight: 'bold' },
body: { color: '#4b5563', fontSize: 15, marginTop: 10 },
});
export default LeaderboardScreen;
+110
View File
@@ -0,0 +1,110 @@
import React, {useState} from 'react';
import {Pressable, StyleSheet, Text, TextInput, View} from 'react-native';
import useAuthHook from '../../hooks/useAuthHook';
import Screen from '../layout/Screen';
import {Button, ButtonTray} from '../UI/Button';
const LoginScreen = ({ navigation }) => {
const { email, password, error, isLoading, setEmail, setPassword, login } = useAuthHook();
const [submitting, setSubmitting] = useState(false);
const handleLogin = async () => {
setSubmitting(true);
const user = await login();
setSubmitting(false);
if (!user) {
return;
}
if (user.IsAcceptedAdmin) {
navigation.reset({ index: 0, routes: [{ name: 'Admin' }] });
return;
}
navigation.reset({ index: 0, routes: [{ name: 'Game' }] });
};
return (
<Screen style={styles.container}>
<View style={styles.content}>
<Text style={styles.title}>Login</Text>
<TextInput
style={styles.input}
placeholder="Email"
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
editable={!isLoading}
/>
<TextInput
style={styles.input}
placeholder="Password"
secureTextEntry
value={password}
onChangeText={setPassword}
editable={!isLoading}
/>
{error ? <Text style={styles.errorText}>{error}</Text> : null}
<ButtonTray>
<Button
label={submitting ? 'Logging in...' : 'Login'}
onClick={handleLogin}
styleButton={styles.submitButton}
styleLabel={styles.submitText}
/>
</ButtonTray>
<Pressable onPress={() => navigation.navigate('Auth', { screen: 'RegisterScreen' })}>
<Text style={styles.linkText}>Create new account</Text>
</Pressable>
</View>
</Screen>
);
};
const styles = StyleSheet.create({
container: {
justifyContent: 'center',
},
content: {
gap: 15,
},
title: {
fontSize: 24,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 10,
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
},
errorText: {
color: '#dc2626',
textAlign: 'center',
marginTop: 5,
},
submitButton: {
backgroundColor: '#2563eb',
borderColor: '#2563eb',
},
submitText: {
color: '#ffffff',
fontWeight: '600',
},
linkText: {
textAlign: 'center',
color: '#2563eb',
fontWeight: '600',
paddingVertical: 10,
},
});
export default LoginScreen;
+168
View File
@@ -0,0 +1,168 @@
import React, {useCallback, useEffect, useState} from 'react';
import {ActivityIndicator, StyleSheet, Text, View} from 'react-native';
import * as Location from 'expo-location';
import Screen from '../layout/Screen';
import AdminCacheEditorView from '../gameplay/AdminCacheEditorView';
import useGameHook from '../../hooks/useGameHook';
import {getSession} from '../../hooks/SessionStore';
const ManageGameScreen = () => {
const session = getSession();
const { getCaches, upsertCache } = useGameHook();
const [userLocation, setUserLocation] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [cacheRecords, setCacheRecords] = useState([]);
const loadCaches = useCallback(() => {
if (!session.currentGid) {
setCacheRecords([]);
return;
}
setCacheRecords(getCaches(session.currentGid, null));
}, [getCaches, session.currentGid]);
useEffect(() => {
loadCaches();
}, [loadCaches]);
useEffect(() => {
let locationSub;
const start = async () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
setError('Location permission denied');
setLoading(false);
return;
}
const current = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced });
setUserLocation({ latitude: current.coords.latitude, longitude: current.coords.longitude });
locationSub = await Location.watchPositionAsync(
{
accuracy: Location.Accuracy.Balanced,
distanceInterval: 1,
timeInterval: 1000,
},
(next) => {
setUserLocation({ latitude: next.coords.latitude, longitude: next.coords.longitude });
},
);
setLoading(false);
};
start();
return () => {
if (locationSub) {
locationSub.remove();
}
};
}, []);
const handleAddCache = (coordinate) => {
if (!session.currentGid) {
return;
}
upsertCache({
gid: session.currentGid,
latitude: coordinate.latitude,
longitude: coordinate.longitude,
radius: 20,
clue: `Cache ${cacheRecords.length + 1}`,
subgroupId: 1,
});
loadCaches();
};
const handleMoveCache = (cacheId, coordinate) => {
if (!session.currentGid) {
return;
}
const current = cacheRecords.find((cache) => cache.id === cacheId);
if (!current) {
return;
}
upsertCache({
gid: session.currentGid,
cacheId,
latitude: coordinate.latitude,
longitude: coordinate.longitude,
radius: current.radius,
clue: current.clue,
subgroupId: current.subgroupId,
});
loadCaches();
};
if (!session.currentGid) {
return (
<Screen style={styles.center}>
<Text style={styles.body}>Create or join a game first.</Text>
</Screen>
);
}
if (!session.isAcceptedAdmin) {
return (
<Screen style={styles.center}>
<Text style={styles.body}>Only admins can edit cache locations.</Text>
</Screen>
);
}
if (loading) {
return (
<Screen style={styles.center}>
<ActivityIndicator size="large" />
</Screen>
);
}
if (error) {
return (
<Screen style={styles.center}>
<Text style={styles.error}>{error}</Text>
</Screen>
);
}
return (
<Screen style={styles.container}>
<View style={styles.mapWrap}>
<AdminCacheEditorView
userLocation={userLocation}
caches={cacheRecords}
onAddCache={handleAddCache}
onMoveCache={handleMoveCache}
/>
</View>
</Screen>
);
};
const styles = StyleSheet.create({
container: {
padding: 0,
},
mapWrap: {
flex: 1,
},
center: {
justifyContent: 'center',
alignItems: 'center',
},
body: {
color: '#4b5563',
fontSize: 15,
},
error: {
color: '#dc2626',
fontSize: 15,
},
});
export default ManageGameScreen;
@@ -0,0 +1,20 @@
import React from 'react';
import {StyleSheet, Text} from 'react-native';
import Screen from '../layout/Screen';
const ManagePrivateGameScreen = () => {
return (
<Screen style={styles.container}>
<Text style={styles.title}>Manage Private Game</Text>
<Text style={styles.body}>Manage private game setup goes here.</Text>
</Screen>
);
};
const styles = StyleSheet.create({
container: { justifyContent: 'center', alignItems: 'center' },
title: { fontSize: 24, fontWeight: 'bold' },
body: { color: '#4b5563', fontSize: 15, marginTop: 10 },
});
export default ManagePrivateGameScreen;
@@ -0,0 +1,18 @@
import React from 'react';
import {StyleSheet, Text} from 'react-native';
import Screen from '../layout/Screen';
const ManageSubgroupsScreen = () => (
<Screen style={styles.container}>
<Text style={styles.title}>Manage Subgroups</Text>
<Text style={styles.body}>Manage subgroups setup goes here.</Text>
</Screen>
);
const styles = StyleSheet.create({
container: { justifyContent: 'center', alignItems: 'center' },
title: { fontSize: 24, fontWeight: 'bold' },
body: { color: '#4b5563', fontSize: 15, marginTop: 10 },
});
export default ManageSubgroupsScreen;
+183
View File
@@ -0,0 +1,183 @@
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {ActivityIndicator, StyleSheet, Text, View} from 'react-native';
import * as Location from 'expo-location';
import {Magnetometer} from 'expo-sensors';
import Screen from '../layout/Screen';
import PlayerMapView from '../gameplay/PlayerMapView';
import useGameHook from '../../hooks/useGameHook';
import usePlayerGame from '../../hooks/usePlayerGame';
import {getSession} from '../../hooks/SessionStore';
import Cache from '../../models/Cache';
const toHeading = ({ x, y }) => {
const angle = Math.atan2(y, x) * (180 / Math.PI);
return angle < 0 ? angle + 360 : angle;
};
const MapScreen = () => {
const session = getSession();
const { getCaches, claimCache } = useGameHook();
const [userLocation, setUserLocation] = useState(null);
const [heading, setHeading] = useState(null);
const [loading, setLoading] = useState(true);
const [cacheRecords, setCacheRecords] = useState([]);
const [error, setError] = useState('');
const activeCaches = useMemo(() => (
cacheRecords.map(
(cache) => new Cache(
cache.id,
cache.coordinates.latitude,
cache.coordinates.longitude,
cache.radius,
cache.clue,
cache.groupId,
cache.subgroupId,
),
)
), [cacheRecords]);
const subgroupFilter = session.currentSGid;
const { visibleCache, isClaiming, setIsClaiming } = usePlayerGame(userLocation, heading, activeCaches);
const loadCaches = useCallback(() => {
if (!session.currentGid) {
setCacheRecords([]);
return;
}
const rows = getCaches(session.currentGid, subgroupFilter);
setCacheRecords(rows);
}, [getCaches, session.currentGid, subgroupFilter]);
useEffect(() => {
loadCaches();
}, [loadCaches]);
useEffect(() => {
let locationSub;
let headingSub;
const start = async () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
setError('Location permission denied');
setLoading(false);
return;
}
const current = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced });
setUserLocation({ latitude: current.coords.latitude, longitude: current.coords.longitude });
locationSub = await Location.watchPositionAsync(
{
accuracy: Location.Accuracy.Balanced,
distanceInterval: 1,
timeInterval: 1000,
},
(next) => {
setUserLocation({ latitude: next.coords.latitude, longitude: next.coords.longitude });
},
);
Magnetometer.setUpdateInterval(500);
headingSub = Magnetometer.addListener((data) => {
setHeading(toHeading(data));
});
setLoading(false);
};
start();
return () => {
if (locationSub) {
locationSub.remove();
}
if (headingSub) {
headingSub.remove();
}
};
}, []);
const handleClaim = (cacheId) => {
if (!session.currentGid) {
return;
}
claimCache({
gid: session.currentGid,
cacheId,
uid: session.currentUid,
tid: session.currentTid,
});
setIsClaiming(false);
loadCaches();
};
if (!session.currentGid) {
return (
<Screen style={styles.center}>
<Text style={styles.body}>Join or create a game to use map features.</Text>
</Screen>
);
}
if (session.isAcceptedAdmin) {
return (
<Screen style={styles.center}>
<Text style={styles.body}>Admins manage caches from Manage Game.</Text>
</Screen>
);
}
if (loading) {
return (
<Screen style={styles.center}>
<ActivityIndicator size="large" />
</Screen>
);
}
if (error) {
return (
<Screen style={styles.center}>
<Text style={styles.error}>{error}</Text>
</Screen>
);
}
return (
<Screen style={styles.container}>
<View style={styles.mapWrap}>
<PlayerMapView
userLocation={userLocation}
visibleCache={visibleCache}
isClaiming={isClaiming}
onClaimSuccess={handleClaim}
/>
</View>
</Screen>
);
};
const styles = StyleSheet.create({
container: {
padding: 0,
},
mapWrap: {
flex: 1,
},
center: {
justifyContent: 'center',
alignItems: 'center',
},
body: {
color: '#4b5563',
fontSize: 15,
},
error: {
color: '#dc2626',
fontSize: 15,
},
});
export default MapScreen;
@@ -1,11 +1,10 @@
import React, { useState } from 'react';
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import useAuthViewModel from '../ViewModel/useAuthViewModel';
import ScreenHeader from '../components/ScreenHeader';
import React, {useState} from 'react';
import {Pressable, StyleSheet, Text, TextInput, View} from 'react-native';
import useAuthHook from '../../hooks/useAuthHook';
import Screen from '../layout/Screen';
import {Button, ButtonTray} from '../UI/Button';
const RegisterPage = () => {
const navigation = useNavigation();
const RegisterScreen = ({ navigation }) => {
const {
username,
email,
@@ -13,13 +12,14 @@ const RegisterPage = () => {
confirmPassword,
accountType,
error,
isLoading,
setUsername,
setEmail,
setPassword,
setConfirmPassword,
setAccountType,
register,
} = useAuthViewModel();
} = useAuthHook();
const [submitting, setSubmitting] = useState(false);
const handleRegister = async () => {
@@ -29,14 +29,25 @@ const RegisterPage = () => {
if (!user) {
return;
}
navigation.navigate(user.SGid === 0 ? '(routes)/admin-hub' : '(routes)/game-type');
if (user.IsAcceptedAdmin) {
navigation.reset({ index: 0, routes: [{ name: 'Admin' }] });
return;
}
navigation.reset({ index: 0, routes: [{ name: 'Game' }] });
};
return (
<View style={styles.wrapper}>
<ScreenHeader title="Register" />
<View style={styles.container}>
<TextInput style={styles.input} placeholder="Username" value={username} onChangeText={setUsername} />
<Screen style={styles.container}>
<View style={styles.content}>
<Text style={styles.title}>Create Account</Text>
<TextInput
style={styles.input}
placeholder="Username"
value={username}
onChangeText={setUsername}
editable={!isLoading}
/>
<TextInput
style={styles.input}
placeholder="Email"
@@ -44,60 +55,81 @@ const RegisterPage = () => {
keyboardType="email-address"
value={email}
onChangeText={setEmail}
editable={!isLoading}
/>
<TextInput
style={styles.input}
placeholder="Password"
secureTextEntry
value={password}
onChangeText={setPassword}
editable={!isLoading}
/>
<TextInput style={styles.input} placeholder="Password" value={password} onChangeText={setPassword} secureTextEntry />
<TextInput
style={styles.input}
placeholder="Confirm Password"
secureTextEntry
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
editable={!isLoading}
/>
<View style={styles.typeRow}>
<Pressable
style={[styles.typeButton, accountType === 'Individual' && styles.typeButtonActive]}
onPress={() => setAccountType('Individual')}
disabled={isLoading}
>
<Text style={[styles.typeText, accountType === 'Individual' && styles.typeTextActive]}>Individual</Text>
</Pressable>
<Pressable
style={[styles.typeButton, accountType === 'Business/School' && styles.typeButtonActive]}
onPress={() => setAccountType('Business/School')}
disabled={isLoading}
>
<Text style={[styles.typeText, accountType === 'Business/School' && styles.typeTextActive]}>Business/School</Text>
<Text style={[styles.typeText, accountType === 'Business/School' && styles.typeTextActive]}>Business</Text>
</Pressable>
</View>
<Pressable style={styles.submitButton} onPress={handleRegister}>
<Text style={styles.submitText}>{submitting ? 'Creating...' : 'Create Account'}</Text>
</Pressable>
<View style={styles.errorSlot}>
<Text style={[styles.errorText, !error && styles.errorHidden]}>{error || ' '}</Text>
</View>
<Pressable onPress={() => navigation.navigate('(routes)/login')}>
<Text style={styles.backText}>Back to Login</Text>
{error ? <Text style={styles.errorText}>{error}</Text> : null}
<ButtonTray>
<Button
label={submitting ? 'Creating...' : 'Create Account'}
onClick={handleRegister}
styleButton={styles.submitButton}
styleLabel={styles.submitText}
/>
</ButtonTray>
<Pressable onPress={() => navigation.navigate('Auth', { screen: 'LoginScreen' })}>
<Text style={styles.linkText}>Back to Login</Text>
</Pressable>
</View>
</View>
</Screen>
);
};
const styles = StyleSheet.create({
wrapper: {
flex: 1,
backgroundColor: '#ffffff',
},
container: {
flex: 1,
padding: 20,
justifyContent: 'center',
},
content: {
gap: 12,
},
title: {
fontSize: 24,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 10,
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
},
typeRow: {
flexDirection: 'row',
@@ -122,32 +154,25 @@ const styles = StyleSheet.create({
typeTextActive: {
color: '#ffffff',
},
errorText: {
color: '#dc2626',
textAlign: 'center',
marginTop: 5,
},
submitButton: {
backgroundColor: '#2563eb',
borderRadius: 8,
alignItems: 'center',
paddingVertical: 12,
borderColor: '#2563eb',
},
submitText: {
color: '#ffffff',
fontWeight: '600',
},
backText: {
linkText: {
textAlign: 'center',
color: '#2563eb',
fontWeight: '600',
},
errorSlot: {
minHeight: 20,
justifyContent: 'center',
},
errorText: {
textAlign: 'center',
color: '#dc2626',
},
errorHidden: {
opacity: 0,
paddingVertical: 10,
},
});
export default RegisterPage;
export default RegisterScreen;
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
import {StyleSheet, Text} from 'react-native';
import Screen from '../layout/Screen';
const TeamScreen = () => (
<Screen style={styles.container}>
<Text style={styles.title}>Team Management</Text>
<Text style={styles.body}>Team management setup goes here.</Text>
</Screen>
);
const styles = StyleSheet.create({
container: { justifyContent: 'center', alignItems: 'center' },
title: { fontSize: 24, fontWeight: 'bold' },
body: { color: '#4b5563', fontSize: 15, marginTop: 10 },
});
export default TeamScreen;
+495
View File
@@ -0,0 +1,495 @@
import buildDbLink from './dbLink';
class DbController {
constructor(db = buildDbLink()) {
this.db = db;
}
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)}`;
}
toMinuteIso(date = new Date()) {
return `${date.toISOString().slice(0, 16)}Z`;
}
generateJoinCode(length = 5) {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let code = '';
for (let i = 0; i < length; i += 1) {
code += charset[Math.floor(Math.random() * charset.length)];
}
return code;
}
generateUniqueJoinCode(collection, key) {
let code = this.generateJoinCode(Math.random() > 0.5 ? 5 : 6);
while (collection.some((item) => item[key] === code)) {
code = this.generateJoinCode(Math.random() > 0.5 ? 5 : 6);
}
return code;
}
nextId(counterKey) {
const keyToCollection = {
user: { collection: 'users', field: 'Uid' },
group: { collection: 'groups', field: 'Gid' },
team: { collection: 'teams', field: 'Tid' },
};
const config = keyToCollection[counterKey];
if (!config) {
throw new Error(`Unsupported counter key: ${counterKey}`);
}
const rows = this.db[config.collection] || [];
const maxId = rows.reduce((max, row) => {
const value = Number(row[config.field] || 0);
return value > max ? value : max;
}, 0);
return maxId + 1;
}
getUserByEmail(email) {
return this.db.users.find((u) => u.email.toLowerCase() === email.toLowerCase()) || null;
}
getUserById(uid) {
return this.db.users.find((u) => u.Uid === Number(uid)) || null;
}
getGroupById(gid) {
return this.db.groups.find((group) => group.Gid === Number(gid)) || null;
}
register(payload) {
const { username, email, password, confirmPassword, accountType } = payload;
if (!username || !email || !password || !confirmPassword) {
throw new Error('Missing required registration fields');
}
if (password !== confirmPassword) {
throw new Error('Passwords do not match');
}
if (this.getUserByEmail(email)) {
throw new Error('Email already in use');
}
const uid = this.nextId('user');
const user = {
Uid: uid,
username,
email,
passwordHash: this.hashPassword(password),
isBusiness: accountType === 'Business/School',
IsAcceptedAdmin: false,
Gid: null,
SGid: null,
TGid: null,
};
this.db.users.push(user);
return user;
}
login(payload) {
const { email, password } = payload;
const user = this.getUserByEmail(email);
if (!user) {
throw new Error('Email not found');
}
const incomingHash = this.hashPassword(password);
if (incomingHash !== user.passwordHash) {
throw new Error('Invalid password');
}
return user;
}
getGameTypes() {
return this.db.game_types;
}
getSubgroupByJoinCode(joinCode) {
return this.db.subgroups.find(
(subgroup) => subgroup.JoinCode && subgroup.JoinCode === joinCode.toUpperCase(),
) || null;
}
ensureMembership(uid, gid, sgid, isAcceptedAdmin) {
const existing = this.db.subgroup_memberships.find(
(membership) => membership.Uid === uid && membership.Gid === gid,
);
if (existing) {
existing.SGid = sgid;
existing.IsAcceptedAdmin = isAcceptedAdmin;
return existing;
}
const membership = {
Uid: uid,
Gid: gid,
SGid: sgid,
IsAcceptedAdmin: isAcceptedAdmin,
};
this.db.subgroup_memberships.push(membership);
return membership;
}
ensureGroupGameData(gid) {
const existing = this.db.game_data.find((entry) => entry.Gid === gid);
if (existing) {
return existing;
}
const payload = { Gid: gid, GeoCaches: [] };
this.db.game_data.push(payload);
return payload;
}
hasActiveAdmin(gid) {
return this.db.subgroup_memberships.some(
(membership) => membership.Gid === gid && membership.SGid === 0 && membership.IsAcceptedAdmin,
);
}
canJoinTeamForSubgroup(group, sgid) {
return group.TeamsEnabled && sgid > 0;
}
createPrivateGame(payload) {
const { userId, groupName, businessOrSchoolName, gameType = 'private', isBusiness = false } = payload;
const user = this.getUserById(Number(userId));
if (!user) {
throw new Error('User not found');
}
const existing = this.db.groups.find(
(group) => group.CreatedByUid === Number(userId) && group.GameType === 'private',
);
if (existing) {
return existing;
}
const gid = this.nextId('group');
const isBusinessGroup = Boolean(isBusiness);
const group = {
Gid: gid,
GroupName: groupName,
BusinessOrSchoolName: businessOrSchoolName || groupName,
GameType: gameType,
AdminJoinCode: this.generateUniqueJoinCode(this.db.groups, 'AdminJoinCode'),
CreatedByUid: user.Uid,
MaxMemberSubgroups: isBusinessGroup ? 999 : 1,
TeamsEnabled: true,
IsBusinessGroup: isBusinessGroup,
ApprovedAdmins: isBusinessGroup ? [user.Uid] : [],
CreatedAt: this.toMinuteIso(),
};
this.db.groups.push(group);
this.ensureGroupGameData(gid);
this.db.subgroups.push({
SGid: 0,
SubGroupName: 'Admins',
Gid: group.Gid,
JoinCode: null,
IsAdminGroup: true,
IsGameStarted: false,
CacheTriggerMeters: 20,
});
if (!isBusinessGroup) {
const memberSGid = this.db.subgroups.filter((sg) => sg.Gid === gid).length;
this.db.subgroups.push({
SGid: memberSGid,
SubGroupName: 'Members',
Gid: gid,
JoinCode: this.generateUniqueJoinCode(this.db.subgroups, 'JoinCode'),
IsAdminGroup: false,
IsGameStarted: false,
CacheTriggerMeters: 20,
});
}
const ownerSgid = isBusinessGroup ? 0 : 1;
this.ensureMembership(user.Uid, gid, ownerSgid, isBusinessGroup);
user.Gid = gid;
user.SGid = ownerSgid;
user.IsAcceptedAdmin = isBusinessGroup;
return group;
}
joinPrivateGame(payload) {
const { userId, joinCode } = payload;
const user = this.getUserById(Number(userId));
const subgroup = this.getSubgroupByJoinCode(joinCode);
if (!user || !subgroup || subgroup.IsAdminGroup) {
throw new Error('Invalid subgroup join code');
}
const group = this.getGroupById(subgroup.Gid);
if (!group) {
throw new Error('Group not found');
}
this.ensureMembership(user.Uid, subgroup.Gid, subgroup.SGid, false);
user.Gid = group.Gid;
user.SGid = subgroup.SGid;
user.IsAcceptedAdmin = false;
return group;
}
joinAsAdmin(payload) {
const { userId, adminJoinCode } = payload;
const user = this.getUserById(Number(userId));
const group = this.db.groups.find((g) => g.AdminJoinCode === adminJoinCode);
if (!user || !group) {
throw new Error('Invalid admin join code or user not found');
}
if (this.hasActiveAdmin(group.Gid)) {
this.db.admin_waitlist.push({
Uid: user.Uid,
Gid: group.Gid,
RequestedAt: this.toMinuteIso(),
});
return { status: 'pending', message: 'Admin approval pending' };
}
group.ApprovedAdmins.push(user.Uid);
this.ensureMembership(user.Uid, group.Gid, 0, true);
user.Gid = group.Gid;
user.SGid = 0;
user.IsAcceptedAdmin = true;
return { status: 'approved', group };
}
getCreatedPrivateGameByUser(uid) {
return this.db.groups.find(
(group) => group.CreatedByUid === Number(uid) && group.GameType === 'private',
) || null;
}
createSubgroup(payload) {
const { gid, subgroupName, cacheTriggerMeters = 20 } = payload;
const group = this.getGroupById(gid);
if (!group) {
throw new Error('Group not found');
}
const existingCount = this.db.subgroups.filter((sg) => sg.Gid === gid).length;
if (existingCount >= group.MaxMemberSubgroups + 1) {
throw new Error('Max subgroups reached');
}
const sgid = existingCount;
const newSubgroup = {
SGid: sgid,
SubGroupName: subgroupName,
Gid: gid,
JoinCode: this.generateUniqueJoinCode(this.db.subgroups, 'JoinCode'),
IsAdminGroup: false,
IsGameStarted: false,
CacheTriggerMeters: cacheTriggerMeters,
};
this.db.subgroups.push(newSubgroup);
return newSubgroup;
}
getSubgroups(gid) {
return this.db.subgroups.filter((sg) => sg.Gid === gid);
}
getVisibleSubgroups(gid) {
return this.db.subgroups.filter((sg) => sg.Gid === gid && sg.SGid > 0);
}
updateSubgroupCacheSettings(payload) {
const { gid, sgid, cacheTriggerMeters } = payload;
const subgroup = this.db.subgroups.find((sg) => sg.Gid === gid && sg.SGid === sgid);
if (!subgroup) {
throw new Error('Subgroup not found');
}
subgroup.CacheTriggerMeters = cacheTriggerMeters;
return subgroup;
}
createTeam(payload) {
const { gid, sgid, teamName, leaderId } = payload;
const tid = this.nextId('team');
const team = {
Tid: tid,
Gid: gid,
SGid: sgid,
TeamName: teamName,
LeaderId: leaderId,
JoinCode: this.generateUniqueJoinCode(this.db.teams, 'JoinCode'),
CreatedAt: this.toMinuteIso(),
};
this.db.teams.push(team);
this.db.team_members.push({ Uid: leaderId, Tid: tid });
return team;
}
joinTeamByCode(payload) {
const { userId, joinCode } = payload;
const team = this.db.teams.find((t) => t.JoinCode === joinCode.toUpperCase());
if (!team) {
throw new Error('Team join code not found');
}
const existing = this.db.team_members.find((tm) => tm.Uid === userId && tm.Tid === team.Tid);
if (existing) {
throw new Error('User already in team');
}
this.db.team_members.push({ Uid: userId, Tid: team.Tid });
return team;
}
getLobby(gid) {
const group = this.getGroupById(gid);
const subgroups = this.getSubgroups(gid);
const teams = this.db.teams.filter((t) => t.Gid === gid);
const teamMembers = this.db.team_members;
return { group, subgroups, teams, teamMembers };
}
getTeam(tid) {
return this.db.teams.find((t) => t.Tid === tid);
}
getMapPoints(gid, sgid = null) {
const gameData = this.db.game_data.find((entry) => entry.Gid === Number(gid));
if (!gameData) {
return [];
}
if (sgid === null || sgid === undefined) {
return gameData.GeoCaches;
}
return gameData.GeoCaches.filter((cache) => cache.SGid === Number(sgid));
}
normalizeCache(cache, gid) {
return {
id: cache.CacheId ?? cache.id,
coordinates: {
latitude: Number(cache.Latitude ?? cache.coordinates?.latitude ?? 0),
longitude: Number(cache.Longitude ?? cache.coordinates?.longitude ?? 0),
},
radius: Number(cache.TriggerMeters ?? cache.radius ?? 20),
clue: cache.Title ?? cache.clue ?? 'Cache',
groupId: Number(gid),
subgroupId: Number(cache.SGid ?? cache.subgroupId ?? 1),
claimedBy: cache.ClaimedBy ?? null,
claimedAt: cache.ClaimedAt ?? null,
};
}
getCaches(gid, sgid = null) {
const gameData = this.db.game_data.find((entry) => entry.Gid === Number(gid));
if (!gameData) {
return [];
}
const caches = gameData.GeoCaches || [];
const filtered = sgid === null || sgid === undefined
? caches
: caches.filter((cache) => Number(cache.SGid) === Number(sgid));
return filtered.map((cache) => this.normalizeCache(cache, gid));
}
upsertCache(payload) {
const {
gid,
cacheId,
latitude,
longitude,
radius = 20,
clue = 'Cache',
subgroupId = 1,
} = payload;
const groupId = Number(gid);
const gameData = this.ensureGroupGameData(groupId);
const id = cacheId || `C${Date.now()}`;
const existingIndex = (gameData.GeoCaches || []).findIndex(
(cache) => (cache.CacheId ?? cache.id) === id,
);
const nextCache = {
CacheId: id,
Title: clue,
Latitude: Number(latitude),
Longitude: Number(longitude),
TriggerMeters: Number(radius),
SGid: Number(subgroupId),
ClaimedBy: existingIndex >= 0 ? gameData.GeoCaches[existingIndex].ClaimedBy ?? null : null,
ClaimedAt: existingIndex >= 0 ? gameData.GeoCaches[existingIndex].ClaimedAt ?? null : null,
};
if (!gameData.GeoCaches) {
gameData.GeoCaches = [];
}
if (existingIndex >= 0) {
gameData.GeoCaches[existingIndex] = nextCache;
} else {
gameData.GeoCaches.push(nextCache);
}
return this.normalizeCache(nextCache, groupId);
}
claimCache(payload) {
const { gid, cacheId, uid = null, tid = null } = payload;
const gameData = this.db.game_data.find((entry) => entry.Gid === Number(gid));
if (!gameData || !gameData.GeoCaches) {
throw new Error('Game data not found');
}
const target = gameData.GeoCaches.find((cache) => (cache.CacheId ?? cache.id) === cacheId);
if (!target) {
throw new Error('Cache not found');
}
target.ClaimedBy = tid || uid;
target.ClaimedAt = this.toMinuteIso();
return this.normalizeCache(target, gid);
}
updateGroupSettings(payload) {
const { gid, ...settings } = payload;
const group = this.getGroupById(gid);
if (!group) {
throw new Error('Group not found');
}
Object.assign(group, settings);
return group;
}
getState() {
return this.db;
}
}
const dbController = new DbController();
export { DbController };
export default dbController;
+41
View File
@@ -0,0 +1,41 @@
const session = {
currentUid: null,
currentGid: null,
currentSGid: null,
currentTid: null,
isBusiness: null,
isAcceptedAdmin: false,
};
const setSessionUser = (user) => {
session.currentUid = user?.Uid ?? null;
session.currentGid = user?.Gid ?? null;
session.currentSGid = user?.SGid ?? null;
session.currentTid = user?.TGid ?? null;
session.isBusiness = user?.isBusiness ?? null;
session.isAcceptedAdmin = Boolean(user?.IsAcceptedAdmin);
};
const setSessionGroup = (gid, sgid = null) => {
session.currentGid = gid ?? null;
if (sgid !== null && sgid !== undefined) {
session.currentSGid = sgid;
}
};
const setSessionTeam = (tid) => {
session.currentTid = tid ?? null;
};
const getSession = () => ({ ...session });
const clearSession = () => {
session.currentUid = null;
session.currentGid = null;
session.currentSGid = null;
session.currentTid = null;
session.isBusiness = null;
session.isAcceptedAdmin = false;
};
export { setSessionUser, setSessionGroup, setSessionTeam, getSession, clearSession };
+24
View File
@@ -0,0 +1,24 @@
import usersSeed from '../../DB/users.json';
import groupsSeed from '../../DB/groups.json';
import subgroupsSeed from '../../DB/subgroups.json';
import subgroupMembershipsSeed from '../../DB/subgroup-memberships.json';
import adminWaitlistSeed from '../../DB/admin-waitlist.json';
import teamsSeed from '../../DB/teams.json';
import teamMembersSeed from '../../DB/team-members.json';
import gameDataSeed from '../../DB/game-data.json';
import gameTypesSeed from '../../DB/game-types.json';
const buildDbLink = () => ({
users: usersSeed,
groups: groupsSeed,
subgroups: subgroupsSeed,
subgroup_memberships: subgroupMembershipsSeed,
admin_waitlist: adminWaitlistSeed,
teams: teamsSeed,
team_members: teamMembersSeed,
game_data: gameDataSeed,
game_types: gameTypesSeed,
});
export default buildDbLink;
+117
View File
@@ -0,0 +1,117 @@
import {useState} from 'react';
import Constants from 'expo-constants';
import dbController from './DbController';
import {clearSession, setSessionUser} from './SessionStore';
const hostFromExpo = Constants.expoConfig?.hostUri?.split(':')[0] || null;
const apiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || (hostFromExpo ? `http://${hostFromExpo}:3000` : 'http://localhost:3000');
const postJson = async (path, payload) => {
const response = await fetch(`${apiBaseUrl}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || 'Request failed');
}
return result;
};
const useAuthHook = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [username, setUsername] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [accountType, setAccountType] = useState('Individual');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const login = async () => {
setError('');
setIsLoading(true);
try {
const user = await postJson('/auth/login', { email: email.trim(), password });
setSessionUser(user);
setIsLoading(false);
return user;
} catch (e) {
try {
const fallbackUser = dbController.login({ email: email.trim(), password });
setSessionUser(fallbackUser);
setIsLoading(false);
return fallbackUser;
} catch {
setError(e.message);
setIsLoading(false);
return null;
}
}
};
const register = async () => {
setError('');
setIsLoading(true);
try {
const user = await postJson('/auth/register', {
username: username.trim(),
email: email.trim().toLowerCase(),
password,
confirmPassword,
accountType,
});
setSessionUser(user);
setIsLoading(false);
return user;
} catch (e) {
try {
const fallbackUser = dbController.register({
username: username.trim(),
email: email.trim().toLowerCase(),
password,
confirmPassword,
accountType,
});
setSessionUser(fallbackUser);
setIsLoading(false);
return fallbackUser;
} catch {
setError(e.message);
setIsLoading(false);
return null;
}
}
};
const logout = () => {
clearSession();
setEmail('');
setPassword('');
setUsername('');
setConfirmPassword('');
setAccountType('Individual');
setError('');
};
return {
email,
password,
username,
confirmPassword,
accountType,
error,
isLoading,
setEmail,
setPassword,
setUsername,
setConfirmPassword,
setAccountType,
login,
register,
logout,
};
};
export default useAuthHook;
+72
View File
@@ -0,0 +1,72 @@
import dbController from './DbController';
const useGameHook = () => {
const getGameTypes = () => dbController.getGameTypes();
const getCreatedPrivateGame = (userId) => {
return dbController.getCreatedPrivateGameByUser(userId);
};
const createPrivateGame = (payload) => {
return dbController.createPrivateGame(payload);
};
const joinPrivateGame = (payload) => {
return dbController.joinPrivateGame(payload);
};
const joinAsAdmin = (payload) => {
return dbController.joinAsAdmin(payload);
};
const getLobby = (gid) => {
return dbController.getLobby(gid);
};
const getTeam = (tid) => {
return dbController.getTeam(tid);
};
const createTeam = (payload) => {
return dbController.createTeam(payload);
};
const joinTeamByCode = (payload) => {
return dbController.joinTeamByCode(payload);
};
const getMapPoints = (gid, sgid = null) => {
return dbController.getMapPoints(gid, sgid);
};
const getCaches = (gid, sgid = null) => {
return dbController.getCaches(gid, sgid);
};
const upsertCache = (payload) => {
return dbController.upsertCache(payload);
};
const claimCache = (payload) => {
return dbController.claimCache(payload);
};
return {
getGameTypes,
getCreatedPrivateGame,
createPrivateGame,
joinPrivateGame,
joinAsAdmin,
getLobby,
getTeam,
createTeam,
joinTeamByCode,
getMapPoints,
getCaches,
upsertCache,
claimCache,
};
};
export default useGameHook;
+38
View File
@@ -0,0 +1,38 @@
import {useEffect, useState} from 'react';
import {isLookingAtCache, isWithinRadius} from '../utils/geoMath';
const usePlayerGame = (playerLocation, playerHeading, activeCaches) => {
const [visibleCache, setVisibleCache] = useState(null);
const [isClaiming, setIsClaiming] = useState(false);
useEffect(() => {
if (!playerLocation || playerHeading === null || playerHeading === undefined) {
setVisibleCache(null);
setIsClaiming(false);
return;
}
const nearbyCache = (activeCaches || []).find((cache) =>
isWithinRadius(playerLocation, cache.coordinates, cache.radius));
if (!nearbyCache) {
setVisibleCache(null);
setIsClaiming(false);
return;
}
const isTargeted = isLookingAtCache(
playerHeading,
playerLocation,
nearbyCache.coordinates,
);
setVisibleCache(nearbyCache);
setIsClaiming(isTargeted);
}, [playerLocation, playerHeading, activeCaches]);
return { visibleCache, isClaiming, setIsClaiming };
};
export default usePlayerGame;
+17
View File
@@ -0,0 +1,17 @@
class Cache {
constructor(id, latitude, longitude, radius, clue, groupId, subgroupId) {
this.id = id;
this.coordinates = { latitude, longitude };
this.radius = radius;
this.clue = clue;
this.groupId = groupId;
this.subgroupId = subgroupId;
}
toJSON() {
return { ...this };
}
}
export default Cache;
+18
View File
@@ -0,0 +1,18 @@
import * as geolib from 'geolib';
const isWithinRadius = (playerCoords, cacheCoords, radius) => {
const distance = geolib.getDistance(playerCoords, cacheCoords);
return distance <= radius;
};
const isLookingAtCache = (playerHeading, playerCoords, cacheCoords, tolerance = 20) => {
if (playerHeading === null || playerHeading === undefined) {
return false;
}
const bearingToCache = geolib.getRhumbLineBearing(playerCoords, cacheCoords);
const diff = Math.abs(bearingToCache - playerHeading);
return diff <= tolerance || diff >= 360 - tolerance;
};
export { isWithinRadius, isLookingAtCache };
-17
View File
@@ -1,17 +0,0 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}