diff --git a/App.js b/App.js
new file mode 100644
index 0000000..0ae59de
--- /dev/null
+++ b/App.js
@@ -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 = () => (
+
+
+
+
+);
+
+const GameStack = () => (
+
+
+
+
+
+
+
+
+
+
+
+);
+
+const AdminStack = () => (
+
+
+
+
+
+
+
+
+
+);
+
+export const App = () => {
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+export default App;
+
diff --git a/DB/DB-instructions.md b/DB/DB-instructions.md
deleted file mode 100644
index b5fefdd..0000000
--- a/DB/DB-instructions.md
+++ /dev/null
@@ -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)
-```
-
diff --git a/README.md b/README.md
index 7a02635..3886e14 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/app.json b/app.json
index 4cedf42..be4b335 100644
--- a/app.json
+++ b/app.json
@@ -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"
}
}
}
diff --git a/app/API/API-ReadMe.md b/app/API/API-ReadMe.md
deleted file mode 100644
index 06e887d..0000000
--- a/app/API/API-ReadMe.md
+++ /dev/null
@@ -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.]
\ No newline at end of file
diff --git a/app/API/API.js b/app/API/API.js
deleted file mode 100644
index a608f3c..0000000
--- a/app/API/API.js
+++ /dev/null
@@ -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;
-}
-
diff --git a/app/Controller/LoginLogic.js b/app/Controller/LoginLogic.js
deleted file mode 100644
index ceb2b83..0000000
--- a/app/Controller/LoginLogic.js
+++ /dev/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;
diff --git a/app/Model/DbController.js b/app/Model/DbController.js
deleted file mode 100644
index e179919..0000000
--- a/app/Model/DbController.js
+++ /dev/null
@@ -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;
-
diff --git a/app/Model/GroupModel.js b/app/Model/GroupModel.js
deleted file mode 100644
index a0f5c35..0000000
--- a/app/Model/GroupModel.js
+++ /dev/null
@@ -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;
-
diff --git a/app/Model/SessionStore.js b/app/Model/SessionStore.js
deleted file mode 100644
index 9ec9486..0000000
--- a/app/Model/SessionStore.js
+++ /dev/null
@@ -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;
-}
-
-
diff --git a/app/Model/TeamModel.js b/app/Model/TeamModel.js
deleted file mode 100644
index a22618e..0000000
--- a/app/Model/TeamModel.js
+++ /dev/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;
-
diff --git a/app/Model/UserModel.js b/app/Model/UserModel.js
deleted file mode 100644
index 42888d2..0000000
--- a/app/Model/UserModel.js
+++ /dev/null
@@ -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;
-
diff --git a/app/Screens/AdminHubPage.js b/app/Screens/AdminHubPage.js
deleted file mode 100644
index e53f1be..0000000
--- a/app/Screens/AdminHubPage.js
+++ /dev/null
@@ -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 (
-
-
-
-
- As an individual, your game is managed automatically. You can view and manage your active team from the Game Lobby.
-
- navigation.navigate('(routes)/game-lobby')}>
- Go to Game Lobby
-
-
-
-
- );
- }
-
- return (
-
-
-
-
-
- Create a Game
-
- navigation.navigate('(routes)/game-lobby')}>
- Manage Existing Game
-
- navigation.navigate('(routes)/manage-subgroups')}>
- Manage Subgroups
-
-
-
-
-
- );
-};
-
-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;
-
diff --git a/app/Screens/CreateGamePage.js b/app/Screens/CreateGamePage.js
deleted file mode 100644
index 0b880fb..0000000
--- a/app/Screens/CreateGamePage.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import React from 'react';
-import {StyleSheet, Text, View} from 'react-native';
-import ScreenHeader from '../components/ScreenHeader';
-
-const CreateGamePage = () => {
- return (
-
-
-
- Create game setup goes here.
-
-
- );
-};
-
-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;
-
diff --git a/app/Screens/GameLobbyPage.js b/app/Screens/GameLobbyPage.js
deleted file mode 100644
index f37a649..0000000
--- a/app/Screens/GameLobbyPage.js
+++ /dev/null
@@ -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 (
-
-
-
- {isAdminUser && (
-
-
- Admins cannot participate in teams. Manage your game from the Admin Hub instead.
-
-
- )}
- {!isAdminUser && (
- <>
-
-
- Join Team
-
-
- Create Team
-
- >
- )}
- navigation.navigate('(routes)/map')}>
- Open Map
-
- {!!error && {error}}
-
-
-
- );
-};
-
-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;
diff --git a/app/Screens/GameTypePage.js b/app/Screens/GameTypePage.js
deleted file mode 100644
index c342325..0000000
--- a/app/Screens/GameTypePage.js
+++ /dev/null
@@ -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 (
-
-
-
- {gameTypes.map((label) => (
- navigateByType(label)}>
- {label}
-
- ))}
-
-
- );
-};
-
-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;
diff --git a/app/Screens/JoinPrivateGamePage.js b/app/Screens/JoinPrivateGamePage.js
deleted file mode 100644
index 4525005..0000000
--- a/app/Screens/JoinPrivateGamePage.js
+++ /dev/null
@@ -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 (
-
-
-
-
-
- {submitting ? 'Joining...' : 'Join Game'}
-
- {!!error && {error}}
-
-
- );
-};
-
-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;
diff --git a/app/Screens/LoginPage.js b/app/Screens/LoginPage.js
deleted file mode 100644
index 12ecf67..0000000
--- a/app/Screens/LoginPage.js
+++ /dev/null
@@ -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 (
-
-
-
-
-
-
- navigation.navigate('(routes)/register')}>
- Register
-
-
- {submitting ? 'Logging in...' : 'Login'}
-
-
- {!!error && {error}}
-
-
- );
-};
-
-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;
-
diff --git a/app/Screens/ManageGamePage.js b/app/Screens/ManageGamePage.js
deleted file mode 100644
index 9b15b44..0000000
--- a/app/Screens/ManageGamePage.js
+++ /dev/null
@@ -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 (
-
-
-
- Manage your active team from here.
- navigation.navigate('(routes)/team')}>
- Open Team Manager
-
-
-
- );
-};
-
-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;
-
diff --git a/app/Screens/ManagePrivateSubtaskPage.js b/app/Screens/ManagePrivateSubtaskPage.js
deleted file mode 100644
index f7d91c6..0000000
--- a/app/Screens/ManagePrivateSubtaskPage.js
+++ /dev/null
@@ -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 (
-
-
-
-
- {submitting ? 'Please wait...' : isBusinessUser ? 'Create New Private Game' : 'Create or Manage My Game'}
-
- {isBusinessUser && (
-
- Business/School Name is only required for admin join.
-
-
- Join as Admin for Private Game
-
-
- )}
- {!!error && {error}}
-
-
- );
-};
-
-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;
-
diff --git a/app/Screens/ManageSubgroupsPage.js b/app/Screens/ManageSubgroupsPage.js
deleted file mode 100644
index d15d4bd..0000000
--- a/app/Screens/ManageSubgroupsPage.js
+++ /dev/null
@@ -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 (
-
-
-
-
- Subgroups are managed automatically for individual accounts.
-
-
-
-
- );
- }
-
- return (
-
-
-
-
- + Create Subgroup
-
-
-
- {filteredSubgroups.length === 0 && (
-
- {searchQuery ? 'No subgroups match your search.' : 'No subgroups yet. Create one to get started.'}
-
- )}
- {filteredSubgroups.map((subgroup) => (
- {
- updateCacheForSubgroup(subgroup.SGid, (subgroup.CacheTriggerMeters || 20) + 5);
- }}
- />
- ))}
-
-
- Create New Subgroup
-
-
-
- Create
-
-
- {!!error && {error}}
-
-
-
- );
-};
-
-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;
-
diff --git a/app/Screens/MapPage.js b/app/Screens/MapPage.js
deleted file mode 100644
index cbc6e56..0000000
--- a/app/Screens/MapPage.js
+++ /dev/null
@@ -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 (
-
-
-
- Map and hunt markers appear here.
- Points loaded: {points.length}
-
-
-
- );
-};
-
-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;
-
diff --git a/app/Screens/TeamPage.js b/app/Screens/TeamPage.js
deleted file mode 100644
index 34e8035..0000000
--- a/app/Screens/TeamPage.js
+++ /dev/null
@@ -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 (
-
-
-
- Team details and members appear here.
- {team?.team?.TeamName || 'No team joined yet'}
-
-
-
- );
-};
-
-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;
-
diff --git a/app/ViewModel/useAdminViewModel.js b/app/ViewModel/useAdminViewModel.js
deleted file mode 100644
index 0893f70..0000000
--- a/app/ViewModel/useAdminViewModel.js
+++ /dev/null
@@ -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;
-
diff --git a/app/ViewModel/useAuthViewModel.js b/app/ViewModel/useAuthViewModel.js
deleted file mode 100644
index d45b1c6..0000000
--- a/app/ViewModel/useAuthViewModel.js
+++ /dev/null
@@ -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;
-
diff --git a/app/ViewModel/useGameTypeViewModel.js b/app/ViewModel/useGameTypeViewModel.js
deleted file mode 100644
index dfe1e10..0000000
--- a/app/ViewModel/useGameTypeViewModel.js
+++ /dev/null
@@ -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;
-
diff --git a/app/ViewModel/useLobbyViewModel.js b/app/ViewModel/useLobbyViewModel.js
deleted file mode 100644
index 3a98a0d..0000000
--- a/app/ViewModel/useLobbyViewModel.js
+++ /dev/null
@@ -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;
-
diff --git a/app/ViewModel/useManagePrivateSubtaskViewModel.js b/app/ViewModel/useManagePrivateSubtaskViewModel.js
deleted file mode 100644
index 30ed155..0000000
--- a/app/ViewModel/useManagePrivateSubtaskViewModel.js
+++ /dev/null
@@ -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;
-
diff --git a/app/ViewModel/useMapViewModel.js b/app/ViewModel/useMapViewModel.js
deleted file mode 100644
index e0eeeeb..0000000
--- a/app/ViewModel/useMapViewModel.js
+++ /dev/null
@@ -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;
-
diff --git a/app/ViewModel/usePrivateGameViewModel.js b/app/ViewModel/usePrivateGameViewModel.js
deleted file mode 100644
index 13f7721..0000000
--- a/app/ViewModel/usePrivateGameViewModel.js
+++ /dev/null
@@ -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;
-
diff --git a/app/ViewModel/useSubgroupAdminViewModel.js b/app/ViewModel/useSubgroupAdminViewModel.js
deleted file mode 100644
index 6937337..0000000
--- a/app/ViewModel/useSubgroupAdminViewModel.js
+++ /dev/null
@@ -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;
-
diff --git a/app/ViewModel/useTeamViewModel.js b/app/ViewModel/useTeamViewModel.js
deleted file mode 100644
index f918518..0000000
--- a/app/ViewModel/useTeamViewModel.js
+++ /dev/null
@@ -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;
-
diff --git a/app/_layout.tsx b/app/_layout.tsx
deleted file mode 100644
index a2edc37..0000000
--- a/app/_layout.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import {Stack} from "expo-router";
-
-export default function RootLayout() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/app/components/AdminFooterNavbar.js b/app/components/AdminFooterNavbar.js
deleted file mode 100644
index afae78d..0000000
--- a/app/components/AdminFooterNavbar.js
+++ /dev/null
@@ -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 (
-
- navigation.navigate('(routes)/admin-hub')}>
- Admin Hub
-
- navigation.navigate('(routes)/game-lobby')}>
- Manage Game
-
- navigation.navigate('(routes)/team')}>
- Manage Team
-
-
- );
-};
-
-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;
-
diff --git a/app/components/MemberFooterNavbar.js b/app/components/MemberFooterNavbar.js
deleted file mode 100644
index 4af4578..0000000
--- a/app/components/MemberFooterNavbar.js
+++ /dev/null
@@ -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 (
-
- navigation.navigate('(routes)/game-lobby')}>
- Lobby
-
- navigation.navigate('(routes)/team')}>
- Team
-
- navigation.navigate('(routes)/map')}>
- Map
-
-
- );
-};
-
-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;
-
diff --git a/app/components/ScreenHeader.js b/app/components/ScreenHeader.js
deleted file mode 100644
index 52e94d4..0000000
--- a/app/components/ScreenHeader.js
+++ /dev/null
@@ -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 (
-
- {showBack && !isBlocked ? (
-
- ←
-
- ) : (
-
- )}
- {title}
- {isLoggedIn ? (
-
- ⎋
-
- ) : (
-
- )}
-
- );
-};
-
-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;
-
diff --git a/app/components/UI/EntityCard.jsx b/app/components/UI/EntityCard.jsx
deleted file mode 100644
index 9458b2a..0000000
--- a/app/components/UI/EntityCard.jsx
+++ /dev/null
@@ -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 (
-
- {title}
- {subtitle && {subtitle}}
-
- {metaLabel1 && metaValue1 && (
- {metaLabel1}: {metaValue1}
- )}
- {metaLabel2 && metaValue2 && (
- {metaLabel2}: {metaValue2}
- )}
- {metaLabel3 && metaValue3 && (
- {metaLabel3}: {metaValue3}
- )}
-
-
- );
-};
-
-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;
-
diff --git a/app/components/UI/SearchBar.jsx b/app/components/UI/SearchBar.jsx
deleted file mode 100644
index b7a111c..0000000
--- a/app/components/UI/SearchBar.jsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import React from 'react';
-import {StyleSheet, TextInput, View} from 'react-native';
-
-const SearchBar = ({ placeholder = 'Search...', value, onChangeText, style = {} }) => {
- return (
-
-
-
- );
-};
-
-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;
-
diff --git a/eslint.config.js b/eslint.config.js
index 5025da6..19c6e42 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -1,4 +1,3 @@
-// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');
diff --git a/package-lock.json b/package-lock.json
index 4e2e1f2..94e37af 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,26 +8,26 @@
"name": "mad_treasure_hunt_app",
"version": "1.0.0",
"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-location": "~19.0.7",
+ "expo-sensors": "~15.0.7",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"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-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
@@ -35,10 +35,8 @@
"react-native-worklets": "0.5.1"
},
"devDependencies": {
- "@types/react": "~19.1.0",
"eslint": "^9.25.0",
- "eslint-config-expo": "~10.0.0",
- "typescript": "~5.9.2"
+ "eslint-config-expo": "~10.0.0"
}
},
"node_modules/@0no-co/graphql.web": {
@@ -1906,28 +1904,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
- "node_modules/@expo/metro-runtime": {
- "version": "6.1.2",
- "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz",
- "integrity": "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g==",
- "dependencies": {
- "anser": "^1.4.9",
- "pretty-format": "^29.7.0",
- "stacktrace-parser": "^0.1.10",
- "whatwg-fetch": "^3.0.0"
- },
- "peerDependencies": {
- "expo": "*",
- "react": "*",
- "react-dom": "*",
- "react-native": "*"
- },
- "peerDependenciesMeta": {
- "react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@expo/osascript": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.4.2.tgz",
@@ -2376,181 +2352,6 @@
"node": ">=12.4.0"
}
},
- "node_modules/@radix-ui/primitive": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
- "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="
- },
- "node_modules/@radix-ui/react-compose-refs": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
- "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-context": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
- "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-direction": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
- "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-focus-guards": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
- "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-id": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
- "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
- "dependencies": {
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-slot": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
- "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==",
- "dependencies": {
- "@radix-ui/react-compose-refs": "1.1.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-use-callback-ref": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
- "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-use-controllable-state": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
- "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
- "dependencies": {
- "@radix-ui/react-use-effect-event": "0.0.2",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-use-effect-event": {
- "version": "0.0.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
- "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
- "dependencies": {
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-use-escape-keydown": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
- "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
- "dependencies": {
- "@radix-ui/react-use-callback-ref": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-use-layout-effect": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
- "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
"node_modules/@react-native/assets-registry": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
@@ -2767,27 +2568,11 @@
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz",
"integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="
},
- "node_modules/@react-navigation/bottom-tabs": {
- "version": "7.15.5",
- "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.15.5.tgz",
- "integrity": "sha512-wQHredlCrRmShWQ1vF4HUcLdaiJ8fUgnbaeQH7BJ7MQVQh4mdzab0IOY/4QSmUyNRB350oyu1biTycyQ5FKWMQ==",
- "dependencies": {
- "@react-navigation/elements": "^2.9.10",
- "color": "^4.2.3",
- "sf-symbols-typescript": "^2.1.0"
- },
- "peerDependencies": {
- "@react-navigation/native": "^7.1.33",
- "react": ">= 18.2.0",
- "react-native": "*",
- "react-native-safe-area-context": ">= 4.0.0",
- "react-native-screens": ">= 4.0.0"
- }
- },
"node_modules/@react-navigation/core": {
- "version": "7.16.1",
- "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.16.1.tgz",
- "integrity": "sha512-xhquoyhKdqDfiL7LuupbwYnmauUGfVFGDEJO34m26k8zSN1eDjQ2stBZcHN8ILOI1PrG9885nf8ZmfaQxPS0ww==",
+ "version": "7.17.2",
+ "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.17.2.tgz",
+ "integrity": "sha512-Rt2OZwcgOmjv401uLGAKaRM6xo0fiBce/A7LfRHI1oe5FV+KooWcgAoZ2XOtgKj6UzVMuQWt3b2e6rxo/mDJRA==",
+ "license": "MIT",
"dependencies": {
"@react-navigation/routers": "^7.5.3",
"escape-string-regexp": "^4.0.0",
@@ -2803,9 +2588,10 @@
}
},
"node_modules/@react-navigation/elements": {
- "version": "2.9.10",
- "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.10.tgz",
- "integrity": "sha512-N8tuBekzTRb0pkMHFJGvmC6Q5OisSbt6gzvw7RHMnp4NDo5auVllT12sWFaTXf8mTduaLKNSrD/NZNaOqThCBg==",
+ "version": "2.9.14",
+ "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.14.tgz",
+ "integrity": "sha512-lKqzu+su2pI/YIZmR7L7xdOs4UL+rVXKJAMpRMBrwInEy96SjIFst6QDGpE89Dunnu3VjVpjWfByo9f2GWBHDQ==",
+ "license": "MIT",
"dependencies": {
"color": "^4.2.3",
"use-latest-callback": "^0.2.4",
@@ -2813,7 +2599,7 @@
},
"peerDependencies": {
"@react-native-masked-view/masked-view": ">= 0.2.0",
- "@react-navigation/native": "^7.1.33",
+ "@react-navigation/native": "^7.2.2",
"react": ">= 18.2.0",
"react-native": "*",
"react-native-safe-area-context": ">= 4.0.0"
@@ -2825,11 +2611,12 @@
}
},
"node_modules/@react-navigation/native": {
- "version": "7.1.33",
- "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.33.tgz",
- "integrity": "sha512-DpFdWGcgLajKZ1TuIvDNQsblN2QaUFWpTQaB8v7WRP9Mix8H/6TFoIrZd93pbymI2hybd6UYrD+lI408eWVcfw==",
+ "version": "7.2.2",
+ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.2.2.tgz",
+ "integrity": "sha512-kem1Ko2BcbAjmbQIv66dNmr6EtfDut3QU0qjsVhMnLLhktwyXb6FzZYp8gTrUb6AvkAbaJoi+BF5Pl55pAUa5w==",
+ "license": "MIT",
"dependencies": {
- "@react-navigation/core": "^7.16.1",
+ "@react-navigation/core": "^7.17.2",
"escape-string-regexp": "^4.0.0",
"fast-deep-equal": "^3.1.3",
"nanoid": "^3.3.11",
@@ -2841,17 +2628,18 @@
}
},
"node_modules/@react-navigation/native-stack": {
- "version": "7.14.4",
- "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.14.4.tgz",
- "integrity": "sha512-HFEnM5Q7JY3FmmiolD/zvgY+9sxZAyVGPZJoz7BdTvJmi1VHOdplf24YiH45mqeitlGnaOlvNT55rH4abHJ5eA==",
+ "version": "7.14.10",
+ "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.14.10.tgz",
+ "integrity": "sha512-mCbYbYhi7Em2R2nEgwYGdLU38smy+KK+HMMVcwuzllWsF3Qb+jOUEYbB6Or7LvE7SS77BZ6sHdx4HptCEv50hQ==",
+ "license": "MIT",
"dependencies": {
- "@react-navigation/elements": "^2.9.10",
+ "@react-navigation/elements": "^2.9.14",
"color": "^4.2.3",
"sf-symbols-typescript": "^2.1.0",
"warn-once": "^0.1.1"
},
"peerDependencies": {
- "@react-navigation/native": "^7.1.33",
+ "@react-navigation/native": "^7.2.2",
"react": ">= 18.2.0",
"react-native": "*",
"react-native-safe-area-context": ">= 4.0.0",
@@ -2862,6 +2650,7 @@
"version": "7.5.3",
"resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.3.tgz",
"integrity": "sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==",
+ "license": "MIT",
"dependencies": {
"nanoid": "^3.3.11"
}
@@ -2946,6 +2735,12 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true
},
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "license": "MIT"
+ },
"node_modules/@types/graceful-fs": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@@ -3000,15 +2795,6 @@
"undici-types": "~7.18.0"
}
},
- "node_modules/@types/react": {
- "version": "19.1.17",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz",
- "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==",
- "devOptional": true,
- "dependencies": {
- "csstype": "^3.0.2"
- }
- },
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@@ -3736,17 +3522,6 @@
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
- "node_modules/aria-hidden": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
- "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
- "dependencies": {
- "tslib": "^2.0.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/array-buffer-byte-length": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
@@ -4480,11 +4255,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/client-only": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
- "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
- },
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -4510,6 +4280,7 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
+ "license": "MIT",
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
@@ -4538,6 +4309,7 @@
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
+ "license": "MIT",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
@@ -4678,12 +4450,6 @@
"hyphenate-style-name": "^1.0.3"
}
},
- "node_modules/csstype": {
- "version": "3.2.3",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
- "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "devOptional": true
- },
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -4755,6 +4521,7 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
"integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==",
+ "license": "MIT",
"engines": {
"node": ">=0.10"
}
@@ -4859,11 +4626,6 @@
"node": ">=8"
}
},
- "node_modules/detect-node-es": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
- "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="
- },
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -5692,17 +5454,13 @@
"react": "*"
}
},
- "node_modules/expo-linking": {
- "version": "8.0.11",
- "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz",
- "integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==",
- "dependencies": {
- "expo-constants": "~18.0.12",
- "invariant": "^2.2.4"
- },
+ "node_modules/expo-location": {
+ "version": "19.0.8",
+ "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-19.0.8.tgz",
+ "integrity": "sha512-H/FI75VuJ1coodJbbMu82pf+Zjess8X8Xkiv9Bv58ZgPKS/2ztjC1YO1/XMcGz7+s9DrbLuMIw22dFuP4HqneA==",
+ "license": "MIT",
"peerDependencies": {
- "react": "*",
- "react-native": "*"
+ "expo": "*"
}
},
"node_modules/expo-modules-autolinking": {
@@ -5732,248 +5490,17 @@
"react-native": "*"
}
},
- "node_modules/expo-router": {
- "version": "6.0.23",
- "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.23.tgz",
- "integrity": "sha512-qCxVAiCrCyu0npky6azEZ6dJDMt77OmCzEbpF6RbUTlfkaCA417LvY14SBkk0xyGruSxy/7pvJOI6tuThaUVCA==",
+ "node_modules/expo-sensors": {
+ "version": "15.0.8",
+ "resolved": "https://registry.npmjs.org/expo-sensors/-/expo-sensors-15.0.8.tgz",
+ "integrity": "sha512-ttibOSCYjFAMIfjV+vVukO1v7GKlbcPRfxcRqbTaSMGneewDwVSXbGFImY530fj1BR3mWq4n9jHnuDp8tAEY9g==",
+ "license": "MIT",
"dependencies": {
- "@expo/metro-runtime": "^6.1.2",
- "@expo/schema-utils": "^0.1.8",
- "@radix-ui/react-slot": "1.2.0",
- "@radix-ui/react-tabs": "^1.1.12",
- "@react-navigation/bottom-tabs": "^7.4.0",
- "@react-navigation/native": "^7.1.8",
- "@react-navigation/native-stack": "^7.3.16",
- "client-only": "^0.0.1",
- "debug": "^4.3.4",
- "escape-string-regexp": "^4.0.0",
- "expo-server": "^1.0.5",
- "fast-deep-equal": "^3.1.3",
- "invariant": "^2.2.4",
- "nanoid": "^3.3.8",
- "query-string": "^7.1.3",
- "react-fast-compare": "^3.2.2",
- "react-native-is-edge-to-edge": "^1.1.6",
- "semver": "~7.6.3",
- "server-only": "^0.0.1",
- "sf-symbols-typescript": "^2.1.0",
- "shallowequal": "^1.1.0",
- "use-latest-callback": "^0.2.1",
- "vaul": "^1.1.2"
+ "invariant": "^2.2.4"
},
"peerDependencies": {
- "@expo/metro-runtime": "^6.1.2",
- "@react-navigation/drawer": "^7.5.0",
- "@testing-library/react-native": ">= 12.0.0",
"expo": "*",
- "expo-constants": "^18.0.13",
- "expo-linking": "^8.0.11",
- "react": "*",
- "react-dom": "*",
- "react-native": "*",
- "react-native-gesture-handler": "*",
- "react-native-reanimated": "*",
- "react-native-safe-area-context": ">= 5.4.0",
- "react-native-screens": "*",
- "react-native-web": "*",
- "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4"
- },
- "peerDependenciesMeta": {
- "@react-navigation/drawer": {
- "optional": true
- },
- "@testing-library/react-native": {
- "optional": true
- },
- "react-dom": {
- "optional": true
- },
- "react-native-gesture-handler": {
- "optional": true
- },
- "react-native-reanimated": {
- "optional": true
- },
- "react-native-web": {
- "optional": true
- },
- "react-server-dom-webpack": {
- "optional": true
- }
- }
- },
- "node_modules/expo-router/node_modules/@radix-ui/react-collection": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
- "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
- "dependencies": {
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/expo-router/node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
- "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
- "dependencies": {
- "@radix-ui/react-compose-refs": "1.1.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/expo-router/node_modules/@radix-ui/react-presence": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
- "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
- "dependencies": {
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/expo-router/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/expo-router/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
- "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
- "dependencies": {
- "@radix-ui/react-compose-refs": "1.1.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/expo-router/node_modules/@radix-ui/react-roving-focus": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
- "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-collection": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-controllable-state": "1.2.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/expo-router/node_modules/@radix-ui/react-tabs": {
- "version": "1.1.13",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
- "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-roving-focus": "1.1.11",
- "@radix-ui/react-use-controllable-state": "1.2.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/expo-router/node_modules/semver": {
- "version": "7.6.3",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
- "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
+ "react-native": "*"
}
},
"node_modules/expo-server": {
@@ -6294,6 +5821,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
"integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==",
+ "license": "MIT",
"engines": {
"node": ">=0.10.0"
}
@@ -6476,6 +6004,12 @@
"node": ">=6.9.0"
}
},
+ "node_modules/geolib": {
+ "version": "3.3.4",
+ "resolved": "https://registry.npmjs.org/geolib/-/geolib-3.3.4.tgz",
+ "integrity": "sha512-EicrlLLL3S42gE9/wde+11uiaYAaeSVDwCUIv2uMIoRBfNJCn8EsSI+6nS3r4TCKDO6+RQNM9ayLq2at+oZQWQ==",
+ "license": "MIT"
+ },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -6508,14 +6042,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/get-nonce": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
- "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/get-package-type": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
@@ -6995,7 +6521,8 @@
"node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
- "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="
+ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
+ "license": "MIT"
},
"node_modules/is-async-function": {
"version": "2.1.1",
@@ -9338,6 +8865,7 @@
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
"integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==",
+ "license": "MIT",
"dependencies": {
"decode-uri-component": "^0.2.2",
"filter-obj": "^1.1.0",
@@ -9417,11 +8945,6 @@
"react": "^19.1.0"
}
},
- "node_modules/react-fast-compare": {
- "version": "3.2.2",
- "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
- "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
- },
"node_modules/react-freeze": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz",
@@ -9436,7 +8959,8 @@
"node_modules/react-is": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
- "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA=="
+ "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
+ "license": "MIT"
},
"node_modules/react-native": {
"version": "0.81.5",
@@ -9517,6 +9041,28 @@
"react-native": "*"
}
},
+ "node_modules/react-native-maps": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/react-native-maps/-/react-native-maps-1.20.1.tgz",
+ "integrity": "sha512-NZI3B5Z6kxAb8gzb2Wxzu/+P2SlFIg1waHGIpQmazDSCRkNoHNY4g96g+xS0QPSaG/9xRBbDNnd2f2/OW6t6LQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "^7946.0.13"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "react": ">= 17.0.1",
+ "react-native": ">= 0.64.3",
+ "react-native-web": ">= 0.11"
+ },
+ "peerDependenciesMeta": {
+ "react-native-web": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-native-reanimated": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz",
@@ -9706,72 +9252,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/react-remove-scroll": {
- "version": "2.7.2",
- "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
- "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
- "dependencies": {
- "react-remove-scroll-bar": "^2.3.7",
- "react-style-singleton": "^2.2.3",
- "tslib": "^2.1.0",
- "use-callback-ref": "^1.3.3",
- "use-sidecar": "^1.1.3"
- },
- "engines": {
- "node": ">=10"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/react-remove-scroll-bar": {
- "version": "2.3.8",
- "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
- "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
- "dependencies": {
- "react-style-singleton": "^2.2.2",
- "tslib": "^2.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/react-style-singleton": {
- "version": "2.2.3",
- "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
- "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
- "dependencies": {
- "get-nonce": "^1.0.0",
- "tslib": "^2.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -10185,11 +9665,6 @@
"node": ">= 0.8"
}
},
- "node_modules/server-only": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz",
- "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="
- },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -10254,11 +9729,6 @@
"node": ">=10"
}
},
- "node_modules/shallowequal": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
- "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="
- },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -10380,6 +9850,7 @@
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
+ "license": "MIT",
"dependencies": {
"is-arrayish": "^0.3.1"
}
@@ -10442,6 +9913,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
"integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==",
+ "license": "MIT",
"engines": {
"node": ">=6"
}
@@ -10525,6 +9997,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
"integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==",
+ "license": "MIT",
"engines": {
"node": ">=4"
}
@@ -10972,7 +10445,9 @@
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
- "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "optional": true
},
"node_modules/type-check": {
"version": "0.4.0",
@@ -11081,6 +10556,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -11262,59 +10738,20 @@
"punycode": "^2.1.0"
}
},
- "node_modules/use-callback-ref": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
- "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
- "dependencies": {
- "tslib": "^2.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
"node_modules/use-latest-callback": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.6.tgz",
"integrity": "sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==",
+ "license": "MIT",
"peerDependencies": {
"react": ">=16.8"
}
},
- "node_modules/use-sidecar": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
- "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
- "dependencies": {
- "detect-node-es": "^1.1.0",
- "tslib": "^2.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
@@ -11351,188 +10788,6 @@
"node": ">= 0.8"
}
},
- "node_modules/vaul": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
- "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
- "dependencies": {
- "@radix-ui/react-dialog": "^1.1.1"
- },
- "peerDependencies": {
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
- }
- },
- "node_modules/vaul/node_modules/@radix-ui/react-dialog": {
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
- "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-dismissable-layer": "1.1.11",
- "@radix-ui/react-focus-guards": "1.1.3",
- "@radix-ui/react-focus-scope": "1.1.7",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-portal": "1.1.9",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-slot": "1.2.3",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "aria-hidden": "^1.2.4",
- "react-remove-scroll": "^2.6.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/vaul/node_modules/@radix-ui/react-dismissable-layer": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
- "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-escape-keydown": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/vaul/node_modules/@radix-ui/react-focus-scope": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
- "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
- "dependencies": {
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/vaul/node_modules/@radix-ui/react-portal": {
- "version": "1.1.9",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
- "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
- "dependencies": {
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/vaul/node_modules/@radix-ui/react-presence": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
- "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
- "dependencies": {
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/vaul/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/vaul/node_modules/@radix-ui/react-slot": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
- "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
- "dependencies": {
- "@radix-ui/react-compose-refs": "1.1.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
"node_modules/vlq": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz",
diff --git a/package.json b/package.json
index eabd380..ca70bae 100644
--- a/package.json
+++ b/package.json
@@ -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"
},
diff --git a/src/components/UI/Button.js b/src/components/UI/Button.js
new file mode 100644
index 0000000..7c3bf4e
--- /dev/null
+++ b/src/components/UI/Button.js
@@ -0,0 +1,38 @@
+import {Pressable, StyleSheet, Text, View} from "react-native";
+
+export const Button = ({ label, icon, onClick, styleLabel, styleButton }) => {
+ return (
+
+ {icon ? icon : null}
+ {label}
+
+ );
+};
+
+export const ButtonTray = ({ children }) => {
+ return {children};
+};
+
+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,
+ },
+});
+
diff --git a/src/components/gameplay/AdminCacheEditorView.js b/src/components/gameplay/AdminCacheEditorView.js
new file mode 100644
index 0000000..9635b6b
--- /dev/null
+++ b/src/components/gameplay/AdminCacheEditorView.js
@@ -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 (
+ onAddCache(event.nativeEvent.coordinate)}
+ showsUserLocation
+ >
+ {(caches || []).map((cache) => (
+
+ onMoveCache(cache.id, event.nativeEvent.coordinate)}
+ title={`Cache ${cache.id}`}
+ />
+
+
+ ))}
+
+ );
+};
+
+export default AdminCacheEditorView;
+
diff --git a/src/components/gameplay/ClaimTimerView.js b/src/components/gameplay/ClaimTimerView.js
new file mode 100644
index 0000000..6c723bc
--- /dev/null
+++ b/src/components/gameplay/ClaimTimerView.js
@@ -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 (
+
+ Hold steady. Claiming in {timeLeft}...
+
+ );
+};
+
+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;
+
diff --git a/src/components/gameplay/PlayerMapView.js b/src/components/gameplay/PlayerMapView.js
new file mode 100644
index 0000000..707901c
--- /dev/null
+++ b/src/components/gameplay/PlayerMapView.js
@@ -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 (
+ <>
+
+ {visibleCache ? (
+
+ ) : null}
+
+
+
+ >
+ );
+};
+
+export default PlayerMapView;
+
diff --git a/src/components/layout/BottomNavbar.js b/src/components/layout/BottomNavbar.js
new file mode 100644
index 0000000..8ad987d
--- /dev/null
+++ b/src/components/layout/BottomNavbar.js
@@ -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 (
+
+ {tabs.map((tab) => {
+ const isActive = routeName === tab.route;
+ return (
+
+ {tab.label}
+
+ );
+ })}
+
+ );
+};
+
+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;
+
diff --git a/src/components/layout/Screen.js b/src/components/layout/Screen.js
new file mode 100644
index 0000000..3c8a5f7
--- /dev/null
+++ b/src/components/layout/Screen.js
@@ -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 (
+
+
+ {children}
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ screen: {
+ flex: 1,
+ backgroundColor: "#fff",
+ },
+ content: {
+ flex: 1,
+ padding: 15,
+ },
+});
+
+export default Screen;
+
diff --git a/src/components/layout/TopNavbar.js b/src/components/layout/TopNavbar.js
new file mode 100644
index 0000000..088517a
--- /dev/null
+++ b/src/components/layout/TopNavbar.js
@@ -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 (
+
+
+ {showBack ? (
+
+ {'<'}
+
+ ) : null}
+
+ {title}
+
+ {showLogout ? (
+
+ Logout
+
+ ) : null}
+
+
+ );
+};
+
+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;
+
diff --git a/src/components/screens/AdminHubScreen.js b/src/components/screens/AdminHubScreen.js
new file mode 100644
index 0000000..1e68d6f
--- /dev/null
+++ b/src/components/screens/AdminHubScreen.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import {StyleSheet, Text} from 'react-native';
+import Screen from '../layout/Screen';
+
+const AdminHubScreen = () => (
+
+ Admin Hub
+ Admin hub setup goes here.
+
+);
+
+const styles = StyleSheet.create({
+ container: { justifyContent: 'center', alignItems: 'center' },
+ title: { fontSize: 24, fontWeight: 'bold' },
+ body: { color: '#4b5563', fontSize: 15, marginTop: 10 },
+});
+
+export default AdminHubScreen;
diff --git a/src/components/screens/CreateGameScreen.js b/src/components/screens/CreateGameScreen.js
new file mode 100644
index 0000000..d00cdae
--- /dev/null
+++ b/src/components/screens/CreateGameScreen.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import {StyleSheet, Text} from 'react-native';
+import Screen from '../layout/Screen';
+
+const CreateGameScreen = () => (
+
+ Create Game
+ Create game setup goes here.
+
+);
+
+const styles = StyleSheet.create({
+ container: { justifyContent: 'center', alignItems: 'center' },
+ title: { fontSize: 24, fontWeight: 'bold' },
+ body: { color: '#4b5563', fontSize: 15, marginTop: 10 },
+});
+
+export default CreateGameScreen;
diff --git a/src/components/screens/GameLobbyScreen.js b/src/components/screens/GameLobbyScreen.js
new file mode 100644
index 0000000..6312cc6
--- /dev/null
+++ b/src/components/screens/GameLobbyScreen.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import {StyleSheet, Text} from 'react-native';
+import Screen from '../layout/Screen';
+
+const GameLobbyScreen = () => (
+
+ Game Lobby
+ Game lobby setup goes here.
+
+);
+
+const styles = StyleSheet.create({
+ container: { justifyContent: 'center', alignItems: 'center' },
+ title: { fontSize: 24, fontWeight: 'bold' },
+ body: { color: '#4b5563', fontSize: 15, marginTop: 10 },
+});
+
+export default GameLobbyScreen;
diff --git a/src/components/screens/GameTypeScreen.js b/src/components/screens/GameTypeScreen.js
new file mode 100644
index 0000000..e0c7dc4
--- /dev/null
+++ b/src/components/screens/GameTypeScreen.js
@@ -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 (
+
+ Select Game Type
+
+ {gameTypes && gameTypes.map((type) => (
+ navigation.navigate('GameLobbyScreen')}>
+ {type}
+
+ ))}
+
+
+ );
+};
+
+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;
diff --git a/src/components/screens/JoinPrivateGameScreen.js b/src/components/screens/JoinPrivateGameScreen.js
new file mode 100644
index 0000000..4579833
--- /dev/null
+++ b/src/components/screens/JoinPrivateGameScreen.js
@@ -0,0 +1,30 @@
+import React from 'react';
+import {StyleSheet, Text} from 'react-native';
+import Screen from '../layout/Screen';
+
+const JoinPrivateGameScreen = () => {
+ return (
+
+ Join Private Game
+ Join private game setup goes here.
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ title: {
+ fontSize: 24,
+ fontWeight: 'bold',
+ },
+ body: {
+ color: '#4b5563',
+ fontSize: 15,
+ marginTop: 10,
+ },
+});
+
+export default JoinPrivateGameScreen;
diff --git a/src/components/screens/LeaderboardScreen.js b/src/components/screens/LeaderboardScreen.js
new file mode 100644
index 0000000..79ff6a4
--- /dev/null
+++ b/src/components/screens/LeaderboardScreen.js
@@ -0,0 +1,19 @@
+import React from 'react';
+import {StyleSheet, Text} from 'react-native';
+import Screen from '../layout/Screen';
+
+const LeaderboardScreen = () => (
+
+ Leaderboard
+ Leaderboard view goes here.
+
+);
+
+const styles = StyleSheet.create({
+ container: { justifyContent: 'center', alignItems: 'center' },
+ title: { fontSize: 24, fontWeight: 'bold' },
+ body: { color: '#4b5563', fontSize: 15, marginTop: 10 },
+});
+
+export default LeaderboardScreen;
+
diff --git a/src/components/screens/LoginScreen.js b/src/components/screens/LoginScreen.js
new file mode 100644
index 0000000..06172b3
--- /dev/null
+++ b/src/components/screens/LoginScreen.js
@@ -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 (
+
+
+ Login
+
+
+
+
+ {error ? {error} : null}
+
+
+
+
+
+ navigation.navigate('Auth', { screen: 'RegisterScreen' })}>
+ Create new account
+
+
+
+ );
+};
+
+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;
+
diff --git a/src/components/screens/ManageGameScreen.js b/src/components/screens/ManageGameScreen.js
new file mode 100644
index 0000000..038c7da
--- /dev/null
+++ b/src/components/screens/ManageGameScreen.js
@@ -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 (
+
+ Create or join a game first.
+
+ );
+ }
+
+ if (!session.isAcceptedAdmin) {
+ return (
+
+ Only admins can edit cache locations.
+
+ );
+ }
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {error}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ );
+};
+
+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;
diff --git a/src/components/screens/ManagePrivateGameScreen.js b/src/components/screens/ManagePrivateGameScreen.js
new file mode 100644
index 0000000..3de6a59
--- /dev/null
+++ b/src/components/screens/ManagePrivateGameScreen.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import {StyleSheet, Text} from 'react-native';
+import Screen from '../layout/Screen';
+
+const ManagePrivateGameScreen = () => {
+ return (
+
+ Manage Private Game
+ Manage private game setup goes here.
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: { justifyContent: 'center', alignItems: 'center' },
+ title: { fontSize: 24, fontWeight: 'bold' },
+ body: { color: '#4b5563', fontSize: 15, marginTop: 10 },
+});
+
+export default ManagePrivateGameScreen;
diff --git a/src/components/screens/ManageSubgroupsScreen.js b/src/components/screens/ManageSubgroupsScreen.js
new file mode 100644
index 0000000..3cdde37
--- /dev/null
+++ b/src/components/screens/ManageSubgroupsScreen.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import {StyleSheet, Text} from 'react-native';
+import Screen from '../layout/Screen';
+
+const ManageSubgroupsScreen = () => (
+
+ Manage Subgroups
+ Manage subgroups setup goes here.
+
+);
+
+const styles = StyleSheet.create({
+ container: { justifyContent: 'center', alignItems: 'center' },
+ title: { fontSize: 24, fontWeight: 'bold' },
+ body: { color: '#4b5563', fontSize: 15, marginTop: 10 },
+});
+
+export default ManageSubgroupsScreen;
diff --git a/src/components/screens/MapScreen.js b/src/components/screens/MapScreen.js
new file mode 100644
index 0000000..8587033
--- /dev/null
+++ b/src/components/screens/MapScreen.js
@@ -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 (
+
+ Join or create a game to use map features.
+
+ );
+ }
+
+ if (session.isAcceptedAdmin) {
+ return (
+
+ Admins manage caches from Manage Game.
+
+ );
+ }
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {error}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ );
+};
+
+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;
diff --git a/app/Screens/RegisterPage.js b/src/components/screens/RegisterScreen.js
similarity index 55%
rename from app/Screens/RegisterPage.js
rename to src/components/screens/RegisterScreen.js
index cfc95cb..a575459 100644
--- a/app/Screens/RegisterPage.js
+++ b/src/components/screens/RegisterScreen.js
@@ -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 (
-
-
-
-
+
+
+ Create Account
+
+
{
keyboardType="email-address"
value={email}
onChangeText={setEmail}
+ editable={!isLoading}
+ />
+
-
+
setAccountType('Individual')}
+ disabled={isLoading}
>
Individual
setAccountType('Business/School')}
+ disabled={isLoading}
>
- Business/School
+ Business
-
- {submitting ? 'Creating...' : 'Create Account'}
-
-
- {error || ' '}
-
- navigation.navigate('(routes)/login')}>
- Back to Login
+
+ {error ? {error} : null}
+
+
+
+
+
+ navigation.navigate('Auth', { screen: 'LoginScreen' })}>
+ Back to Login
-
+
);
};
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;
diff --git a/src/components/screens/TeamScreen.js b/src/components/screens/TeamScreen.js
new file mode 100644
index 0000000..d77b6d5
--- /dev/null
+++ b/src/components/screens/TeamScreen.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import {StyleSheet, Text} from 'react-native';
+import Screen from '../layout/Screen';
+
+const TeamScreen = () => (
+
+ Team Management
+ Team management setup goes here.
+
+);
+
+const styles = StyleSheet.create({
+ container: { justifyContent: 'center', alignItems: 'center' },
+ title: { fontSize: 24, fontWeight: 'bold' },
+ body: { color: '#4b5563', fontSize: 15, marginTop: 10 },
+});
+
+export default TeamScreen;
diff --git a/src/hooks/DbController.js b/src/hooks/DbController.js
new file mode 100644
index 0000000..3d48f3b
--- /dev/null
+++ b/src/hooks/DbController.js
@@ -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;
diff --git a/src/hooks/SessionStore.js b/src/hooks/SessionStore.js
new file mode 100644
index 0000000..ee1f591
--- /dev/null
+++ b/src/hooks/SessionStore.js
@@ -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 };
diff --git a/src/hooks/dbLink.js b/src/hooks/dbLink.js
new file mode 100644
index 0000000..4d84170
--- /dev/null
+++ b/src/hooks/dbLink.js
@@ -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;
+
diff --git a/src/hooks/useAuthHook.js b/src/hooks/useAuthHook.js
new file mode 100644
index 0000000..ef03984
--- /dev/null
+++ b/src/hooks/useAuthHook.js
@@ -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;
+
diff --git a/src/hooks/useGameHook.js b/src/hooks/useGameHook.js
new file mode 100644
index 0000000..4d4b640
--- /dev/null
+++ b/src/hooks/useGameHook.js
@@ -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;
+
diff --git a/src/hooks/usePlayerGame.js b/src/hooks/usePlayerGame.js
new file mode 100644
index 0000000..a179780
--- /dev/null
+++ b/src/hooks/usePlayerGame.js
@@ -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;
+
diff --git a/src/models/Cache.js b/src/models/Cache.js
new file mode 100644
index 0000000..b1598d5
--- /dev/null
+++ b/src/models/Cache.js
@@ -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;
+
diff --git a/src/utils/geoMath.js b/src/utils/geoMath.js
new file mode 100644
index 0000000..3691d44
--- /dev/null
+++ b/src/utils/geoMath.js
@@ -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 };
+
diff --git a/tsconfig.json b/tsconfig.json
deleted file mode 100644
index 909e901..0000000
--- a/tsconfig.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "extends": "expo/tsconfig.base",
- "compilerOptions": {
- "strict": true,
- "paths": {
- "@/*": [
- "./*"
- ]
- }
- },
- "include": [
- "**/*.ts",
- "**/*.tsx",
- ".expo/types/**/*.ts",
- "expo-env.d.ts"
- ]
-}