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:
bobbert authored and GitHub committed 2026-03-17 23:34:40 +00:00
commit 0a50220d66
21 files changed
+772 -78

No files matched your search

+96 -6
View File
@@ -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)
```
+78 -8
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
import CreateGamePage from '../Screens/CreateGamePage';
export default CreateGamePage;
+3
View File
@@ -0,0 +1,3 @@
import ManageGamePage from '../Screens/ManageGamePage';
export default ManageGamePage;
+20
View File
@@ -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;
}
+29 -5
View File
@@ -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,
});
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),
+2
View File
@@ -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,
};
+16 -1
View File
@@ -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;
}
+32 -1
View File
@@ -1,12 +1,14 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-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,10 +21,28 @@ 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}>
<ScrollView style={styles.buttonList}>
<Pressable style={styles.button} onPress={handleCreateGame}>
<Text style={styles.buttonText}>Create a Game</Text>
</Pressable>
@@ -32,6 +52,7 @@ const AdminHubPage = () => {
<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',
+34
View File
@@ -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;
+29 -1
View File
@@ -1,15 +1,19 @@
import React, {useState} from 'react';
import { View, Text, Pressable, TextInput, StyleSheet } from 'react-native';
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,6 +38,15 @@ const GameLobbyPage = () => {
<View style={styles.wrapper}>
<ScreenHeader title="Game Lobby" />
<View style={styles.container}>
{isAdminUser && (
<View style={styles.adminMessage}>
<Text style={styles.adminMessageText}>
Admins cannot participate in teams. Manage your game from the Admin Hub instead.
</Text>
</View>
)}
{!isAdminUser && (
<>
<TextInput
style={styles.input}
placeholder="Join Team Code"
@@ -48,6 +61,8 @@ const GameLobbyPage = () => {
<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;
+51
View File
@@ -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;
+15 -2
View File
@@ -1,5 +1,5 @@
import React, {useState} from 'react';
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
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,8 +51,9 @@ 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>
{isBusinessUser && (
<View style={styles.adminJoinSection}>
<Text style={styles.helperText}>Business/School Name is only required for admin join.</Text>
<TextInput
@@ -54,6 +66,7 @@ const ManagePrivateSubtaskPage = () => {
<Text style={styles.buttonText}>Join as Admin for Private Game</Text>
</Pressable>
</View>
)}
{!!error && <Text style={styles.errorText}>{error}</Text>}
</View>
</View>
+97 -37
View File
@@ -1,41 +1,79 @@
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 style={styles.createButton} onPress={addSubgroup}>
<Text style={styles.createButtonText}>+ Create Subgroup</Text>
</Pressable>
</View>
<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="New subgroup name"
placeholder="Subgroup name"
value={subgroupName}
onChangeText={setSubgroupName}
/>
@@ -46,9 +84,10 @@ const ManageSubgroupsPage = () => {
value={cacheTriggerMeters}
onChangeText={setCacheTriggerMeters}
/>
<Pressable style={styles.button} onPress={addSubgroup}>
<Text style={styles.buttonText}>Create Subgroup</Text>
<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 {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,
};
};
+19 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { createSubgroup, getSubgroups, updateSubgroupCache } from '../API/API';
import {useCallback, useEffect, useMemo, useState} from 'react';
import {createSubgroup, getVisibleSubgroups, updateSubgroupCache} from '../API/API';
import {getSession} from '../Model/SessionStore';
const useSubgroupAdminViewModel = () => {
@@ -7,16 +7,29 @@ const useSubgroupAdminViewModel = () => {
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,
+20 -3
View File
@@ -3,12 +3,29 @@ 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" />
+80 -3
View File
@@ -1,13 +1,69 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
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;
+64
View File
@@ -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;
+34
View File
@@ -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;
+4 -3
View File
@@ -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"
}