mirror of
https://github.com/mudabbir-ahmad/MAD-Treasure-Hunt.git
synced 2026-10-07 19:00:20 +00:00
Add new screens for game management, team management, and cache handling; implement caching logic and session management
This commit is contained in:
1 parent
8686f0e1ff
commit
17ed120ce8
70 files changed
+2172
-3892
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,4 +1,3 @@
|
||||
// https://docs.expo.dev/guides/using-eslint/
|
||||
const { defineConfig } = require('eslint/config');
|
||||
const expoConfig = require('eslint-config-expo/flat');
|
||||
|
||||
|
||||
Generated
+93
-838
File diff suppressed because it is too large.
Load diff
+10
-10
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user