mirror of
https://github.com/mudabbir-ahmad/MAD-Treasure-Hunt.git
synced 2026-10-07 19:00:20 +00:00
Merge pull request #3
Add create game and manage game pages, update navigation and session handling for individual and business users
This commit is contained in:
21 files changed
+831
-137
No files matched your search
+96
-6
@@ -1,4 +1,4 @@
|
||||
# Database Structure
|
||||
# Database Structure & Game Rules
|
||||
|
||||
This project uses split JSON files in `DB/` so each collection behaves like its own REST endpoint.
|
||||
|
||||
@@ -10,9 +10,9 @@ This project uses split JSON files in `DB/` so each collection behaves like its
|
||||
|
||||
## Files
|
||||
|
||||
- `users.json`: user accounts and role state (`Uid`, `Gid`, `SGid`, `TGid`, `IsAcceptedAdmin`).
|
||||
- `groups.json`: top-level game groups (`Gid`), admin approvals, and group settings.
|
||||
- `subgroups.json`: subgroup definitions within each group (`SGid` scoped by `Gid`), join codes, cache trigger defaults.
|
||||
- `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.
|
||||
@@ -25,13 +25,79 @@ This project uses split JSON files in `DB/` so each collection behaves like its
|
||||
|
||||
- `Uid`: unique user ID.
|
||||
- `Gid`: unique group/game ID.
|
||||
- `SGid`: subgroup ID within a group (`0` is admin subgroup).
|
||||
- `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 set as admin (`SGid = 0`) and accepted.
|
||||
- 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.
|
||||
@@ -42,3 +108,27 @@ This project uses split JSON files in `DB/` so each collection behaves like its
|
||||
- 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)
|
||||
```
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
1. Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm i
|
||||
```
|
||||
|
||||
2. Start the app
|
||||
|
||||
```bash
|
||||
npx expo start
|
||||
expo start --android
|
||||
```
|
||||
|
||||
|
||||
@@ -28,10 +28,9 @@ This project uses split JSON files in `DB/` so each collection behaves like its
|
||||
|
||||
## Files
|
||||
|
||||
- `meta.json`: global version and numeric ID counters.
|
||||
- `users.json`: user accounts and role state (`Uid`, `Gid`, `SGid`, `TGid`, `IsAcceptedAdmin`).
|
||||
- `groups.json`: top-level game groups (`Gid`), admin approvals, and group settings.
|
||||
- `subgroups.json`: subgroup definitions within each group (`SGid` scoped by `Gid`), join codes, cache trigger defaults.
|
||||
- `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.
|
||||
@@ -44,15 +43,86 @@ This project uses split JSON files in `DB/` so each collection behaves like its
|
||||
|
||||
- `Uid`: unique user ID.
|
||||
- `Gid`: unique group/game ID.
|
||||
- `SGid`: subgroup ID within a group (`0` is admin subgroup).
|
||||
- `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 set as admin (`SGid = 0`) and accepted.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import CreateGamePage from '../Screens/CreateGamePage';
|
||||
|
||||
export default CreateGamePage;
|
||||
@@ -0,0 +1,3 @@
|
||||
import ManageGamePage from '../Screens/ManageGamePage';
|
||||
|
||||
export default ManageGamePage;
|
||||
@@ -58,6 +58,9 @@ const localPost = (endpoint, payload) => {
|
||||
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);
|
||||
}
|
||||
@@ -167,6 +170,7 @@ const getGameTypes = async () => {
|
||||
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 });
|
||||
@@ -176,6 +180,12 @@ const getSubgroups = async (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 });
|
||||
@@ -215,10 +225,12 @@ export {
|
||||
joinPrivateGame,
|
||||
joinAsAdmin,
|
||||
createPrivateGame,
|
||||
getCreatedPrivateGame,
|
||||
requestAdminAccess,
|
||||
approveAdminAccess,
|
||||
createSubgroup,
|
||||
getSubgroups,
|
||||
getVisibleSubgroups,
|
||||
updateSubgroupCache,
|
||||
updateGroupSettings,
|
||||
createTeam,
|
||||
@@ -227,3 +239,11 @@ export {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -96,6 +96,12 @@ class DbController {
|
||||
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');
|
||||
@@ -213,7 +219,13 @@ class DbController {
|
||||
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,
|
||||
@@ -221,9 +233,10 @@ class DbController {
|
||||
GameType: gameType,
|
||||
AdminJoinCode: this.generateUniqueJoinCode(this.db.groups, 'AdminJoinCode'),
|
||||
CreatedByUid: user.Uid,
|
||||
MaxMemberSubgroups: isBusiness ? 999 : 1,
|
||||
MaxMemberSubgroups: isBusinessGroup ? 999 : 1,
|
||||
TeamsEnabled: true,
|
||||
ApprovedAdmins: [user.Uid],
|
||||
IsBusinessGroup: isBusinessGroup,
|
||||
ApprovedAdmins: isBusinessGroup ? [user.Uid] : [],
|
||||
CreatedAt: this.toMinuteIso(),
|
||||
});
|
||||
|
||||
@@ -236,15 +249,21 @@ class DbController {
|
||||
Gid: group.Gid,
|
||||
JoinCode: null,
|
||||
IsAdminGroup: true,
|
||||
IsGameStarted: false,
|
||||
CacheTriggerMeters: 20,
|
||||
});
|
||||
|
||||
this.createSubgroup({ gid: group.Gid, subgroupName: 'Members', cacheTriggerMeters: 20 });
|
||||
if (!isBusinessGroup) {
|
||||
this.createSubgroup({ gid: group.Gid, subgroupName: 'Members', cacheTriggerMeters: 20 });
|
||||
}
|
||||
|
||||
this.ensureMembership(user.Uid, group.Gid, 0, true);
|
||||
const ownerSgid = isBusinessGroup ? 0 : 1;
|
||||
const ownerIsAcceptedAdmin = isBusinessGroup;
|
||||
|
||||
this.ensureMembership(user.Uid, group.Gid, ownerSgid, ownerIsAcceptedAdmin);
|
||||
user.Gid = group.Gid;
|
||||
user.SGid = 0;
|
||||
user.IsAcceptedAdmin = true;
|
||||
user.SGid = ownerSgid;
|
||||
user.IsAcceptedAdmin = ownerIsAcceptedAdmin;
|
||||
|
||||
return group.toJSON();
|
||||
}
|
||||
@@ -356,6 +375,7 @@ class DbController {
|
||||
Gid: group.Gid,
|
||||
JoinCode: this.generateUniqueJoinCode(this.db.subgroups.filter((entry) => !entry.IsAdminGroup), 'JoinCode'),
|
||||
IsAdminGroup: false,
|
||||
IsGameStarted: false,
|
||||
CacheTriggerMeters: cacheTriggerMeters,
|
||||
};
|
||||
|
||||
@@ -367,6 +387,10 @@ class DbController {
|
||||
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),
|
||||
|
||||
@@ -8,6 +8,7 @@ class GroupModel {
|
||||
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;
|
||||
}
|
||||
@@ -22,6 +23,7 @@ class GroupModel {
|
||||
CreatedByUid: this.CreatedByUid,
|
||||
MaxMemberSubgroups: this.MaxMemberSubgroups,
|
||||
TeamsEnabled: this.TeamsEnabled,
|
||||
IsBusinessGroup: this.IsBusinessGroup,
|
||||
ApprovedAdmins: this.ApprovedAdmins,
|
||||
CreatedAt: this.CreatedAt,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@ const session = {
|
||||
currentGid: null,
|
||||
currentSGid: null,
|
||||
currentTGid: null,
|
||||
currentIsBusiness: null,
|
||||
currentIsAcceptedAdmin: false,
|
||||
};
|
||||
|
||||
const setSessionUser = (user) => {
|
||||
@@ -10,6 +12,8 @@ const setSessionUser = (user) => {
|
||||
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) => {
|
||||
@@ -19,12 +23,23 @@ const setSessionGroup = (gid, sgid = null) => {
|
||||
}
|
||||
};
|
||||
|
||||
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, setSessionTeam, getSession };
|
||||
export { setSessionUser, setSessionGroup, setSessionMembership, setSessionTeam, getSession };
|
||||
|
||||
// Default placeholder export to avoid Expo Router treating this non-component file as a route without a default.
|
||||
export default function SessionStorePlaceholder() {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
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 () => {
|
||||
@@ -19,19 +21,38 @@ const AdminHubPage = () => {
|
||||
navigation.navigate('(routes)/game-lobby');
|
||||
};
|
||||
|
||||
if (!currentIsBusiness) {
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<ScreenHeader title="Admin Hub" />
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.message}>
|
||||
As an individual, your game is managed automatically. You can view and manage your active team from the Game Lobby.
|
||||
</Text>
|
||||
<Pressable style={styles.button} onPress={() => navigation.navigate('(routes)/game-lobby')}>
|
||||
<Text style={styles.buttonText}>Go to Game Lobby</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<AdminFooterNavbar />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<ScreenHeader title="Admin Hub" />
|
||||
<View style={styles.container}>
|
||||
<Pressable style={styles.button} onPress={handleCreateGame}>
|
||||
<Text style={styles.buttonText}>Create a Game</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.button} onPress={() => navigation.navigate('(routes)/game-lobby')}>
|
||||
<Text style={styles.buttonText}>Manage Existing Game</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.button} onPress={() => navigation.navigate('(routes)/manage-subgroups')}>
|
||||
<Text style={styles.buttonText}>Manage Subgroups</Text>
|
||||
</Pressable>
|
||||
<ScrollView style={styles.buttonList}>
|
||||
<Pressable style={styles.button} onPress={handleCreateGame}>
|
||||
<Text style={styles.buttonText}>Create a Game</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.button} onPress={() => navigation.navigate('(routes)/game-lobby')}>
|
||||
<Text style={styles.buttonText}>Manage Existing Game</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.button} onPress={() => navigation.navigate('(routes)/manage-subgroups')}>
|
||||
<Text style={styles.buttonText}>Manage Subgroups</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
</View>
|
||||
<AdminFooterNavbar />
|
||||
</View>
|
||||
@@ -49,11 +70,21 @@ const styles = StyleSheet.create({
|
||||
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',
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import {StyleSheet, Text, View} from 'react-native';
|
||||
import ScreenHeader from '../components/ScreenHeader';
|
||||
|
||||
const CreateGamePage = () => {
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<ScreenHeader title="Create Game" />
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.body}>Create game setup goes here.</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
flex: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
body: {
|
||||
color: '#4b5563',
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
|
||||
export default CreateGamePage;
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, Pressable, TextInput, StyleSheet } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
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)}`,
|
||||
@@ -34,20 +38,31 @@ const GameLobbyPage = () => {
|
||||
<View style={styles.wrapper}>
|
||||
<ScreenHeader title="Game Lobby" />
|
||||
<View style={styles.container}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Join Team Code"
|
||||
autoCapitalize="characters"
|
||||
maxLength={6}
|
||||
value={teamCode}
|
||||
onChangeText={setTeamCode}
|
||||
/>
|
||||
<Pressable style={styles.secondaryButton} onPress={handleJoinTeam}>
|
||||
<Text style={styles.buttonText}>Join Team</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.primaryButton} onPress={handleCreateTeam}>
|
||||
<Text style={styles.buttonText}>Create Team</Text>
|
||||
</Pressable>
|
||||
{isAdminUser && (
|
||||
<View style={styles.adminMessage}>
|
||||
<Text style={styles.adminMessageText}>
|
||||
Admins cannot participate in teams. Manage your game from the Admin Hub instead.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{!isAdminUser && (
|
||||
<>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Join Team Code"
|
||||
autoCapitalize="characters"
|
||||
maxLength={6}
|
||||
value={teamCode}
|
||||
onChangeText={setTeamCode}
|
||||
/>
|
||||
<Pressable style={styles.secondaryButton} onPress={handleJoinTeam}>
|
||||
<Text style={styles.buttonText}>Join Team</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.primaryButton} onPress={handleCreateTeam}>
|
||||
<Text style={styles.buttonText}>Create Team</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
<Pressable style={styles.secondaryButton} onPress={() => navigation.navigate('(routes)/map')}>
|
||||
<Text style={styles.buttonText}>Open Map</Text>
|
||||
</Pressable>
|
||||
@@ -95,6 +110,19 @@ const styles = StyleSheet.create({
|
||||
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;
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import {Pressable, StyleSheet, Text, View} from 'react-native';
|
||||
import {useNavigation} from '@react-navigation/native';
|
||||
import ScreenHeader from '../components/ScreenHeader';
|
||||
|
||||
const ManageGamePage = () => {
|
||||
const navigation = useNavigation();
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<ScreenHeader title="Manage Game" />
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.body}>Manage your active team from here.</Text>
|
||||
<Pressable style={styles.button} onPress={() => navigation.navigate('(routes)/team')}>
|
||||
<Text style={styles.buttonText}>Open Team Manager</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
flex: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: 20,
|
||||
},
|
||||
body: {
|
||||
color: '#4b5563',
|
||||
fontSize: 15,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#ffffff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default ManageGamePage;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
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';
|
||||
|
||||
@@ -9,14 +9,25 @@ const ManagePrivateSubtaskPage = () => {
|
||||
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) {
|
||||
@@ -40,20 +51,22 @@ const ManagePrivateSubtaskPage = () => {
|
||||
<ScreenHeader title="Manage Private Game" />
|
||||
<View style={styles.container}>
|
||||
<Pressable style={styles.primaryButton} onPress={handleCreate}>
|
||||
<Text style={styles.buttonText}>{submitting ? 'Please wait...' : 'Create New Private Game'}</Text>
|
||||
<Text style={styles.buttonText}>{submitting ? 'Please wait...' : isBusinessUser ? 'Create New Private Game' : 'Create or Manage My Game'}</Text>
|
||||
</Pressable>
|
||||
<View style={styles.adminJoinSection}>
|
||||
<Text style={styles.helperText}>Business/School Name is only required for admin join.</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Business/School Name"
|
||||
value={businessOrSchoolName}
|
||||
onChangeText={setBusinessOrSchoolName}
|
||||
/>
|
||||
<Pressable style={styles.secondaryButton} onPress={handleJoinAdmin}>
|
||||
<Text style={styles.buttonText}>Join as Admin for Private Game</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{isBusinessUser && (
|
||||
<View style={styles.adminJoinSection}>
|
||||
<Text style={styles.helperText}>Business/School Name is only required for admin join.</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Business/School Name"
|
||||
value={businessOrSchoolName}
|
||||
onChangeText={setBusinessOrSchoolName}
|
||||
/>
|
||||
<Pressable style={styles.secondaryButton} onPress={handleJoinAdmin}>
|
||||
<Text style={styles.buttonText}>Join as Admin for Private Game</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
{!!error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,54 +1,93 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
|
||||
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 {
|
||||
subgroups,
|
||||
filteredSubgroups,
|
||||
searchQuery,
|
||||
subgroupName,
|
||||
cacheTriggerMeters,
|
||||
error,
|
||||
setSearchQuery,
|
||||
setSubgroupName,
|
||||
setCacheTriggerMeters,
|
||||
addSubgroup,
|
||||
updateCacheForSubgroup,
|
||||
} = useSubgroupAdminViewModel();
|
||||
const { currentIsBusiness } = getSession();
|
||||
|
||||
if (!currentIsBusiness) {
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<ScreenHeader title="Manage Subgroups" />
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.message}>
|
||||
Subgroups are managed automatically for individual accounts.
|
||||
</Text>
|
||||
</View>
|
||||
<AdminFooterNavbar />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<ScreenHeader title="Manage Subgroups" />
|
||||
<View style={styles.container}>
|
||||
{subgroups.map((subgroup) => (
|
||||
<View key={`${subgroup.Gid}_${subgroup.SGid}`} style={styles.row}>
|
||||
<Text style={styles.rowText}>
|
||||
SGid {subgroup.SGid} | {subgroup.SubGroupName} | Code: {subgroup.JoinCode || 'ADMIN'} | Trigger: {subgroup.CacheTriggerMeters}m
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.inlineButton}
|
||||
onPress={() => updateCacheForSubgroup(subgroup.SGid, (subgroup.CacheTriggerMeters || 20) + 5)}
|
||||
>
|
||||
<Text style={styles.inlineButtonText}>+5m</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="New subgroup name"
|
||||
value={subgroupName}
|
||||
onChangeText={setSubgroupName}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Cache trigger meters"
|
||||
keyboardType="numeric"
|
||||
value={cacheTriggerMeters}
|
||||
onChangeText={setCacheTriggerMeters}
|
||||
/>
|
||||
<Pressable style={styles.button} onPress={addSubgroup}>
|
||||
<Text style={styles.buttonText}>Create Subgroup</Text>
|
||||
<Pressable style={styles.createButton} onPress={addSubgroup}>
|
||||
<Text style={styles.createButtonText}>+ Create Subgroup</Text>
|
||||
</Pressable>
|
||||
<SearchBar
|
||||
placeholder="Search by name or ID"
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
/>
|
||||
<ScrollView style={styles.cardList} showsVerticalScrollIndicator={false}>
|
||||
{filteredSubgroups.length === 0 && (
|
||||
<Text style={styles.emptyText}>
|
||||
{searchQuery ? 'No subgroups match your search.' : 'No subgroups yet. Create one to get started.'}
|
||||
</Text>
|
||||
)}
|
||||
{filteredSubgroups.map((subgroup) => (
|
||||
<EntityCard
|
||||
key={`${subgroup.Gid}_${subgroup.SGid}`}
|
||||
title={subgroup.SubGroupName}
|
||||
subtitle={`ID: ${subgroup.SGid}`}
|
||||
metaLabel1="Status"
|
||||
metaValue1={subgroup.IsGameStarted ? 'Active' : 'Inactive'}
|
||||
metaLabel2="Teams"
|
||||
metaValue2={String(true)}
|
||||
onPress={() => {
|
||||
updateCacheForSubgroup(subgroup.SGid, (subgroup.CacheTriggerMeters || 20) + 5);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
<View style={styles.createForm}>
|
||||
<Text style={styles.formTitle}>Create New Subgroup</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Subgroup name"
|
||||
value={subgroupName}
|
||||
onChangeText={setSubgroupName}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Cache trigger meters"
|
||||
keyboardType="numeric"
|
||||
value={cacheTriggerMeters}
|
||||
onChangeText={setCacheTriggerMeters}
|
||||
/>
|
||||
<Pressable style={styles.submitButton} onPress={addSubgroup}>
|
||||
<Text style={styles.submitButtonText}>Create</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{!!error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
<AdminFooterNavbar />
|
||||
@@ -63,30 +102,50 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
gap: 10,
|
||||
padding: 20,
|
||||
flexDirection: 'column',
|
||||
},
|
||||
row: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
gap: 8,
|
||||
message: {
|
||||
textAlign: 'center',
|
||||
color: '#6b7280',
|
||||
fontSize: 15,
|
||||
},
|
||||
rowText: {
|
||||
color: '#111827',
|
||||
fontSize: 13,
|
||||
},
|
||||
inlineButton: {
|
||||
alignSelf: 'flex-start',
|
||||
createButton: {
|
||||
backgroundColor: '#2563eb',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: '#2563eb',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 12,
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
inlineButtonText: {
|
||||
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,
|
||||
@@ -95,18 +154,19 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#2563eb',
|
||||
submitButton: {
|
||||
backgroundColor: '#4b5563',
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
buttonText: {
|
||||
submitButtonText: {
|
||||
color: '#ffffff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
errorText: {
|
||||
color: '#dc2626',
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import { createPrivateGame, joinAsAdmin } from '../API/API';
|
||||
import { getSession, setSessionGroup } from '../Model/SessionStore';
|
||||
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();
|
||||
@@ -14,9 +24,15 @@ const useManagePrivateSubtaskViewModel = () => {
|
||||
userId: currentUid,
|
||||
groupName: businessOrSchoolName.trim() || 'New Private Game',
|
||||
businessOrSchoolName: businessOrSchoolName.trim() || 'Unnamed Business',
|
||||
isBusiness: true,
|
||||
isBusiness: isBusinessUser,
|
||||
});
|
||||
setSessionGroup(group.Gid, 0);
|
||||
|
||||
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);
|
||||
@@ -24,6 +40,26 @@ const useManagePrivateSubtaskViewModel = () => {
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
@@ -34,7 +70,10 @@ const useManagePrivateSubtaskViewModel = () => {
|
||||
});
|
||||
const gid = response?.Gid ?? null;
|
||||
if (gid !== null) {
|
||||
setSessionGroup(gid, 0);
|
||||
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) {
|
||||
@@ -46,8 +85,10 @@ const useManagePrivateSubtaskViewModel = () => {
|
||||
return {
|
||||
businessOrSchoolName,
|
||||
error,
|
||||
isBusinessUser,
|
||||
setBusinessOrSchoolName,
|
||||
createNewPrivateGame,
|
||||
resolveIndividualPrivateRoute,
|
||||
joinAdminForPrivate,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { createSubgroup, getSubgroups, updateSubgroupCache } from '../API/API';
|
||||
import { getSession } from '../Model/SessionStore';
|
||||
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 getSubgroups(currentGid);
|
||||
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]);
|
||||
@@ -52,9 +65,12 @@ const useSubgroupAdminViewModel = () => {
|
||||
|
||||
return {
|
||||
subgroups,
|
||||
filteredSubgroups,
|
||||
searchQuery,
|
||||
subgroupName,
|
||||
cacheTriggerMeters,
|
||||
error,
|
||||
setSearchQuery,
|
||||
setSubgroupName,
|
||||
setCacheTriggerMeters,
|
||||
addSubgroup,
|
||||
|
||||
+21
-4
@@ -1,14 +1,31 @@
|
||||
import { Stack } from "expo-router";
|
||||
import {Stack} from "expo-router";
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<Stack initialRouteName="(routes)/index" screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="(routes)/index" />
|
||||
<Stack.Screen name="(routes)/login" />
|
||||
<Stack.Screen name="(routes)/register" />
|
||||
<Stack.Screen
|
||||
name="(routes)/index"
|
||||
options={{
|
||||
gestureEnabled: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(routes)/login"
|
||||
options={{
|
||||
gestureEnabled: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(routes)/register"
|
||||
options={{
|
||||
gestureEnabled: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="(routes)/game-type" />
|
||||
<Stack.Screen name="(routes)/join-private-game" />
|
||||
<Stack.Screen name="(routes)/manage-private-subtask" />
|
||||
<Stack.Screen name="(routes)/create-game" />
|
||||
<Stack.Screen name="(routes)/manage-game" />
|
||||
<Stack.Screen name="(routes)/game-lobby" />
|
||||
<Stack.Screen name="(routes)/team" />
|
||||
<Stack.Screen name="(routes)/map" />
|
||||
|
||||
@@ -1,13 +1,69 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
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 }) => {
|
||||
const ScreenHeader = ({ title, showBack = true }) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const session = getSession();
|
||||
|
||||
// Check if user is logged in by checking if currentUid exists
|
||||
const isLoggedIn = Boolean(session.currentUid);
|
||||
|
||||
// Pages where back navigation should be disabled
|
||||
const blockedLastSegments = ['login', 'register', 'index'];
|
||||
|
||||
// compute last meaningful segment (filter out route groups like "(routes)")
|
||||
const meaningfulSegments = segments.filter((s) => !s.startsWith('('));
|
||||
const lastSegment = meaningfulSegments.length > 0 ? meaningfulSegments[meaningfulSegments.length - 1] : 'index';
|
||||
const isBlocked = blockedLastSegments.includes(lastSegment);
|
||||
|
||||
|
||||
const handleBack = () => {
|
||||
if (isBlocked || !showBack) return;
|
||||
try {
|
||||
router.back();
|
||||
} catch {
|
||||
// fallback: nothing
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
// clear session and route to login (replace so user cannot go back)
|
||||
try {
|
||||
setSessionUser(null);
|
||||
setSessionGroup(null);
|
||||
setSessionTeam(null);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
router.replace('/login');
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + 10 }]}>
|
||||
{showBack && !isBlocked ? (
|
||||
<Pressable style={styles.backButton} onPress={handleBack} accessibilityLabel="Go back">
|
||||
<Text style={styles.backText}>←</Text>
|
||||
</Pressable>
|
||||
) : (
|
||||
<View style={styles.backButtonPlaceholder} />
|
||||
)}
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{isLoggedIn ? (
|
||||
<Pressable style={styles.backButton} onPress={handleLogout} accessibilityLabel="Logout">
|
||||
<Text style={styles.backText}>⎋</Text>
|
||||
</Pressable>
|
||||
) : (
|
||||
<View style={styles.backButtonPlaceholder} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -18,13 +74,34 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 12,
|
||||
minHeight: 72,
|
||||
justifyContent: 'flex-end',
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import {Pressable, StyleSheet, Text, View} from 'react-native';
|
||||
|
||||
const EntityCard = ({
|
||||
title,
|
||||
subtitle,
|
||||
metaLabel1,
|
||||
metaValue1,
|
||||
metaLabel2,
|
||||
metaValue2,
|
||||
metaLabel3,
|
||||
metaValue3,
|
||||
onPress,
|
||||
}) => {
|
||||
return (
|
||||
<Pressable style={styles.card} onPress={onPress}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{subtitle && <Text style={styles.subtitle}>{subtitle}</Text>}
|
||||
<View style={styles.metaContainer}>
|
||||
{metaLabel1 && metaValue1 && (
|
||||
<Text style={styles.metaText}>{metaLabel1}: {metaValue1}</Text>
|
||||
)}
|
||||
{metaLabel2 && metaValue2 && (
|
||||
<Text style={styles.metaText}>{metaLabel2}: {metaValue2}</Text>
|
||||
)}
|
||||
{metaLabel3 && metaValue3 && (
|
||||
<Text style={styles.metaText}>{metaLabel3}: {metaValue3}</Text>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 10,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
title: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
marginBottom: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
color: '#6b7280',
|
||||
marginBottom: 8,
|
||||
},
|
||||
metaContainer: {
|
||||
gap: 4,
|
||||
},
|
||||
metaText: {
|
||||
fontSize: 13,
|
||||
color: '#4b5563',
|
||||
},
|
||||
});
|
||||
|
||||
export default EntityCard;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import {StyleSheet, TextInput, View} from 'react-native';
|
||||
|
||||
const SearchBar = ({ placeholder = 'Search...', value, onChangeText, style = {} }) => {
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholderTextColor="#9ca3af"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 14,
|
||||
color: '#111827',
|
||||
},
|
||||
});
|
||||
|
||||
export default SearchBar;
|
||||
|
||||
Generated
+4
-3
@@ -11133,9 +11133,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.23.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
|
||||
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
|
||||
"version": "6.24.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
|
||||
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user