Add initial JSON data files and implement core page components for game functionality

This commit is contained in:
bobbert committed 2026-03-17 21:49:44 +00:00
1 parent 81847a9188
commit ca27f8d6ed
54 files changed
+2424 -250

No files matched your search

+215 -34
View File
@@ -1,48 +1,229 @@
// This file contains functions to interact with the API or local database.
import dbController from '../Model/DbController';
const API_BASE_URL = 'http://localhost:3000'; // Change this to the actual API endpoint when needed
const apiConfig = {
useLocalDb: true,
baseUrl: 'http://localhost:3000',
};
// Generic fetch function
const APIFetch = async (endpoint, options = {}) => {
try {
const response = await fetch(`${API_BASE_URL}/${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
const setApiConfig = (nextConfig = {}) => {
Object.assign(apiConfig, nextConfig);
};
if (!response.ok) {
throw new Error(`API error: ${response.statusText}`);
}
const isSameId = (item, id) =>
item.id?.toString() === id
|| item.Uid?.toString() === id
|| item.Gid?.toString() === id
|| item.SGid?.toString() === id
|| item.Tid?.toString() === id;
return await response.json();
} catch (error) {
console.error('Fetch error:', error);
throw error;
const normalize = (endpoint) => endpoint.replace(/^\/+/, '').split('/');
const localGet = (endpoint) => {
const [resource, id] = normalize(endpoint);
const state = dbController.getState();
if (!resource) {
return state;
}
if (!state[resource]) {
throw new Error(`Resource not found: ${resource}`);
}
if (!id) {
return state[resource];
}
return state[resource].find((item) => isSameId(item, id)) || null;
};
// Example function to fetch a user by username
const fetchUserByUsername = async (username) => {
return await APIFetch(`users?username=${username}`);
const localPost = (endpoint, payload) => {
const [resource] = normalize(endpoint);
if (resource === 'auth' && payload.action === 'login') {
return dbController.login(payload.data);
}
if (resource === 'auth' && payload.action === 'register') {
return dbController.register(payload.data);
}
if (resource === 'groups' && payload.action === 'joinPrivate') {
return dbController.joinPrivateGame(payload.data);
}
if (resource === 'groups' && payload.action === 'joinAsAdmin') {
return dbController.joinAsAdmin(payload.data);
}
if (resource === 'groups' && payload.action === 'createPrivate') {
return dbController.createPrivateGame(payload.data);
}
if (resource === 'groups' && payload.action === 'requestAdmin') {
return dbController.requestAdminApproval(payload.data);
}
if (resource === 'groups' && payload.action === 'approveAdmin') {
return dbController.approveAdmin(payload.data);
}
if (resource === 'subgroups' && payload.action === 'create') {
return dbController.createSubgroup(payload.data);
}
if (resource === 'teams' && payload.action === 'create') {
return dbController.createTeam(payload.data);
}
if (resource === 'teams' && payload.action === 'join') {
return dbController.joinTeamByCode(payload.data);
}
const state = dbController.getState();
if (!state[resource]) {
throw new Error(`Resource not found: ${resource}`);
}
state[resource].push(payload);
return payload;
};
// Example function to fetch all groups
const fetchGroups = async () => {
return await APIFetch('groups');
const localPut = (endpoint, payload) => {
const [resource, id] = normalize(endpoint);
const state = dbController.getState();
if (!state[resource]) {
throw new Error(`Resource not found: ${resource}`);
}
const index = state[resource].findIndex((item) =>
isSameId(item, id),
);
if (index < 0) {
throw new Error('Record not found');
}
state[resource][index] = { ...state[resource][index], ...payload };
return state[resource][index];
};
// Example function to fetch subgroups by group ID
const fetchSubgroupsByGroupId = async (groupId) => {
return await APIFetch(`groups/${groupId}/subgroups`);
const localDelete = (endpoint) => {
const [resource, id] = normalize(endpoint);
const state = dbController.getState();
if (!state[resource]) {
throw new Error(`Resource not found: ${resource}`);
}
const index = state[resource].findIndex((item) =>
isSameId(item, id),
);
if (index < 0) {
return { deleted: false };
}
state[resource].splice(index, 1);
return { deleted: true };
};
// RESTful API functions
const fetchItems = async (endpoint) => await APIFetch(endpoint);
const createItem = async (endpoint, data) => await APIFetch(endpoint, { method: 'POST', body: JSON.stringify(data) });
const updateItem = async (endpoint, data) => await APIFetch(endpoint, { method: 'PUT', body: JSON.stringify(data) });
const deleteItem = async (endpoint) => await APIFetch(endpoint, { method: 'DELETE' });
const remoteRequest = async (endpoint, options = {}) => {
const response = await fetch(`${apiConfig.baseUrl}/${endpoint.replace(/^\/+/, '')}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
};
export { APIFetch, fetchUserByUsername, fetchGroups, fetchSubgroupsByGroupId, fetchItems, createItem, updateItem, deleteItem };
const APIFetch = async (endpoint) => {
if (apiConfig.useLocalDb) {
return localGet(endpoint);
}
return remoteRequest(endpoint, { method: 'GET' });
};
const APIPost = async (endpoint, data) => {
if (apiConfig.useLocalDb) {
return localPost(endpoint, data);
}
return remoteRequest(endpoint, { method: 'POST', body: JSON.stringify(data) });
};
const APIPut = async (endpoint, data) => {
if (apiConfig.useLocalDb) {
if (endpoint === 'groups/settings') {
return dbController.updateGroupSettings(data);
}
if (endpoint === 'subgroups/cache') {
return dbController.updateSubgroupCacheSettings(data);
}
return localPut(endpoint, data);
}
return remoteRequest(endpoint, { method: 'PUT', body: JSON.stringify(data) });
};
const APIDelete = async (endpoint) => {
if (apiConfig.useLocalDb) {
return localDelete(endpoint);
}
return remoteRequest(endpoint, { method: 'DELETE' });
};
const loginUser = async (email, password) => APIPost('auth', { action: 'login', data: { email, password } });
const registerUser = async (payload) => APIPost('auth', { action: 'register', data: payload });
const getGameTypes = async () => {
if (apiConfig.useLocalDb) {
return dbController.getGameTypes();
}
return APIFetch('game_types');
};
const joinPrivateGame = async (payload) => APIPost('groups', { action: 'joinPrivate', data: payload });
const joinAsAdmin = async (payload) => APIPost('groups', { action: 'joinAsAdmin', data: payload });
const createPrivateGame = async (payload) => APIPost('groups', { action: 'createPrivate', data: payload });
const requestAdminAccess = async (payload) => APIPost('groups', { action: 'requestAdmin', data: payload });
const approveAdminAccess = async (payload) => APIPost('groups', { action: 'approveAdmin', data: payload });
const createSubgroup = async (payload) => APIPost('subgroups', { action: 'create', data: payload });
const getSubgroups = async (gid) => {
if (apiConfig.useLocalDb) {
return dbController.getSubgroups(gid);
}
return APIFetch(`groups/${gid}/subgroups`);
};
const updateSubgroupCache = async (payload) => APIPut('subgroups/cache', payload);
const updateGroupSettings = async (payload) => APIPut('groups/settings', payload);
const createTeam = async (payload) => APIPost('teams', { action: 'create', data: payload });
const joinTeam = async (payload) => APIPost('teams', { action: 'join', data: payload });
const getLobby = async (gid) => {
if (apiConfig.useLocalDb) {
return dbController.getLobby(gid);
}
return APIFetch(`groups/${gid}/lobby`);
};
const getTeam = async (tid) => {
if (apiConfig.useLocalDb) {
return dbController.getTeam(tid);
}
return APIFetch(`teams/${tid}`);
};
const getMapPoints = async (gid, sgid = null) => {
if (apiConfig.useLocalDb) {
return dbController.getMapPoints(gid, sgid);
}
if (sgid === null || sgid === undefined) {
return APIFetch(`groups/${gid}/map`);
}
return APIFetch(`groups/${gid}/map/${sgid}`);
};
export {
apiConfig,
setApiConfig,
APIFetch,
APIPost,
APIPut,
APIDelete,
loginUser,
registerUser,
getGameTypes,
joinPrivateGame,
joinAsAdmin,
createPrivateGame,
requestAdminAccess,
approveAdminAccess,
createSubgroup,
getSubgroups,
updateSubgroupCache,
updateGroupSettings,
createTeam,
joinTeam,
getLobby,
getTeam,
getMapPoints,
};
+21 -48
View File
@@ -1,59 +1,32 @@
// If a user's subgroupID is 0, Treat them as ADMIN
// If a user's subgroupID is 1, Treat them as USER [For Public instances only subgroups avail are 0 and 1]
// If a user's subgroupID is 2, Treat them as USER [For Private instances, subgroups could range from 0-999 where 0 = admin, non 0 = member group]
// Use GroupID to link Subgroup to a group in the groups table, where subgroup --> defines permissions and whether the person is allowed to play or not.
// within subgroups have TeamGroups --> TeamGroups can come and go, they are not a fixed table [Team groups are enabled per admin request.]
// Team group ==> IF Teams are enabled a user can create a team group, if they're admin in team group they can kick people.
// All team groups have a unique JOIN CODE.
// User's table should have their [NULLABLE] unique Gid, SGid and TGid.
// Gid == GroupID || SGid == SubgroupID || TGid == TeamGroupID
// When a user logs in, we check their Gid, SGid and TGid to determine their permissions and what they can see on the front end.
// If a user is in a team group, they can only see other members of that team group and the games that team group is playing.
// If a user is in a subgroup, they can see all members of that subgroup and the games that subgroup is playing.
// If a user is in the admin subgroup, they can see all members and all games.
import { useState } from 'react';
import { useNavigation } from '@react-navigation/native';
const useLoginLogic = () => {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const navigation = useNavigation();
const dispatch = useDispatch();
const handleLogin = async () => {
try {
// Replace with your actual API endpoint
const response = await fetch('https://yourapi.com/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
alert("API error");
}
const data = await response.json();
// Assuming the API returns user data including Gid, SGid, and TGid
const { Gid, SGid, TGid } = data;
// Dispatch user data to Redux store
dispatch(setUser({ Gid, SGid, TGid }));
// Navigate to the appropriate screen based on user role
if (SGid === 0) {
navigation.navigate('AdminDashboard');
} else if (SGid === 1) {
navigation.navigate('UserDashboard');
} else {
navigation.navigate('PrivateDashboard');
}
} catch (error) {
Alert.alert('Login Error', error.message);
if (!email || !password) {
return false;
}
if (email.trim().toLowerCase().includes('admin')) {
navigation.navigate('admin-hub');
return true;
}
navigation.navigate('game-type');
return true;
};
export default LoginLogic;
return {
email,
password,
setEmail,
setPassword,
handleLogin,
};
};
export default useLoginLogic;
+502
View File
@@ -0,0 +1,502 @@
import usersSeed from '../../DB/users.json';
import groupsSeed from '../../DB/groups.json';
import subgroupsSeed from '../../DB/subgroups.json';
import subgroupMembershipsSeed from '../../DB/subgroup-memberships.json';
import adminWaitlistSeed from '../../DB/admin-waitlist.json';
import teamsSeed from '../../DB/teams.json';
import teamMembersSeed from '../../DB/team-members.json';
import gameDataSeed from '../../DB/game-data.json';
import gameTypesSeed from '../../DB/game-types.json';
import UserModel from './UserModel';
import GroupModel from './GroupModel';
import TeamModel from './TeamModel';
const buildSeedState = () => ({
users: usersSeed,
groups: groupsSeed,
subgroups: subgroupsSeed,
subgroup_memberships: subgroupMembershipsSeed,
admin_waitlist: adminWaitlistSeed,
teams: teamsSeed,
team_members: teamMembersSeed,
game_data: gameDataSeed,
game_types: gameTypesSeed,
});
class DbController {
constructor(seed = buildSeedState()) {
this.seed = JSON.parse(JSON.stringify(seed));
this.reset();
}
reset() {
this.db = JSON.parse(JSON.stringify(this.seed));
}
hashPassword(value) {
let hash = 0;
for (let i = 0; i < value.length; i += 1) {
hash = (hash << 5) - hash + value.charCodeAt(i);
hash |= 0;
}
return `H_${Math.abs(hash)}`;
}
toMinuteIso(date = new Date()) {
return `${date.toISOString().slice(0, 16)}Z`;
}
generateJoinCode(length = 5) {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let code = '';
for (let i = 0; i < length; i += 1) {
code += charset[Math.floor(Math.random() * charset.length)];
}
return code;
}
generateUniqueJoinCode(collection, key) {
let code = this.generateJoinCode(Math.random() > 0.5 ? 5 : 6);
while (collection.some((item) => item[key] === code)) {
code = this.generateJoinCode(Math.random() > 0.5 ? 5 : 6);
}
return code;
}
nextId(counterKey) {
const keyToCollection = {
user: { collection: 'users', field: 'Uid' },
group: { collection: 'groups', field: 'Gid' },
team: { collection: 'teams', field: 'Tid' },
};
const config = keyToCollection[counterKey];
if (!config) {
throw new Error(`Unsupported counter key: ${counterKey}`);
}
const rows = this.db[config.collection] || [];
const maxId = rows.reduce((max, row) => {
const value = Number(row[config.field] || 0);
return value > max ? value : max;
}, 0);
return maxId + 1;
}
getUserByEmail(email) {
return this.db.users.find((u) => u.email.toLowerCase() === email.toLowerCase()) || null;
}
getUserById(uid) {
return this.db.users.find((u) => u.Uid === Number(uid)) || null;
}
getGroupById(gid) {
return this.db.groups.find((group) => group.Gid === Number(gid)) || null;
}
register({ username, email, password, confirmPassword, accountType }) {
if (!username || !email || !password || !confirmPassword) {
throw new Error('Missing required registration fields');
}
if (password !== confirmPassword) {
throw new Error('Passwords do not match');
}
if (this.getUserByEmail(email)) {
throw new Error('Email already in use');
}
const uid = this.nextId('user');
const user = new UserModel({
Uid: uid,
username,
email,
passwordHash: this.hashPassword(password),
isBusiness: accountType === 'Business/School',
IsAcceptedAdmin: false,
Gid: null,
SGid: null,
TGid: null,
});
this.db.users.push(user.toJSON());
return user.toJSON();
}
login({ email, password }) {
const user = this.getUserByEmail(email);
if (!user) {
throw new Error('Email not found');
}
const incomingHash = this.hashPassword(password);
if (incomingHash !== user.passwordHash) {
throw new Error('Invalid password');
}
return user;
}
getGameTypes() {
return this.db.game_types;
}
getSubgroupByJoinCode(joinCode) {
return this.db.subgroups.find(
(subgroup) => subgroup.JoinCode && subgroup.JoinCode === joinCode.toUpperCase(),
) || null;
}
ensureMembership(uid, gid, sgid, isAcceptedAdmin) {
const existing = this.db.subgroup_memberships.find(
(membership) => membership.Uid === uid && membership.Gid === gid,
);
if (existing) {
existing.SGid = sgid;
existing.IsAcceptedAdmin = isAcceptedAdmin;
return existing;
}
const membership = {
Uid: uid,
Gid: gid,
SGid: sgid,
IsAcceptedAdmin: isAcceptedAdmin,
};
this.db.subgroup_memberships.push(membership);
return membership;
}
ensureGroupGameData(gid) {
const existing = this.db.game_data.find((entry) => entry.Gid === gid);
if (existing) {
return existing;
}
const payload = { Gid: gid, GeoCaches: [] };
this.db.game_data.push(payload);
return payload;
}
hasActiveAdmin(gid) {
return this.db.subgroup_memberships.some(
(membership) => membership.Gid === gid && membership.SGid === 0 && membership.IsAcceptedAdmin,
);
}
canJoinTeamForSubgroup(group, sgid) {
return group.TeamsEnabled && sgid > 0;
}
joinPrivateGame({ userId, joinCode }) {
const user = this.getUserById(Number(userId));
const subgroup = this.getSubgroupByJoinCode(joinCode);
if (!user || !subgroup || subgroup.IsAdminGroup) {
throw new Error('Invalid subgroup join code');
}
const group = this.getGroupById(subgroup.Gid);
if (!group) {
throw new Error('Group not found');
}
this.ensureMembership(user.Uid, subgroup.Gid, subgroup.SGid, false);
user.Gid = group.Gid;
user.SGid = subgroup.SGid;
user.IsAcceptedAdmin = false;
return group;
}
createPrivateGame({ userId, groupName, businessOrSchoolName, gameType = 'private', isBusiness = false }) {
const user = this.getUserById(Number(userId));
if (!user) {
throw new Error('User not found');
}
const gid = this.nextId('group');
const group = new GroupModel({
Gid: gid,
GroupName: groupName,
BusinessOrSchoolName: businessOrSchoolName || groupName,
GameType: gameType,
AdminJoinCode: this.generateUniqueJoinCode(this.db.groups, 'AdminJoinCode'),
CreatedByUid: user.Uid,
MaxMemberSubgroups: isBusiness ? 999 : 1,
TeamsEnabled: true,
ApprovedAdmins: [user.Uid],
CreatedAt: this.toMinuteIso(),
});
this.db.groups.push(group.toJSON());
this.ensureGroupGameData(gid);
this.db.subgroups.push({
SGid: 0,
SubGroupName: 'Admins',
Gid: group.Gid,
JoinCode: null,
IsAdminGroup: true,
CacheTriggerMeters: 20,
});
this.createSubgroup({ gid: group.Gid, subgroupName: 'Members', cacheTriggerMeters: 20 });
this.ensureMembership(user.Uid, group.Gid, 0, true);
user.Gid = group.Gid;
user.SGid = 0;
user.IsAcceptedAdmin = true;
return group.toJSON();
}
joinAsAdmin({ userId, businessOrSchoolName }) {
const user = this.getUserById(Number(userId));
const group = this.db.groups.find(
(entry) => entry.BusinessOrSchoolName.toLowerCase() === businessOrSchoolName.trim().toLowerCase(),
);
if (!user || !group) {
throw new Error('User or business/school not found');
}
this.ensureMembership(user.Uid, group.Gid, 0, false);
user.Gid = group.Gid;
user.SGid = 0;
user.IsAcceptedAdmin = false;
const activeAdminExists = this.hasActiveAdmin(group.Gid);
if (!activeAdminExists) {
return this.promoteAdmin({ uid: user.Uid, gid: group.Gid, approvedByUid: user.Uid });
}
const alreadyQueued = this.db.admin_waitlist.some(
(entry) => entry.Uid === user.Uid && entry.Gid === group.Gid && entry.Status === 'pending',
);
if (!alreadyQueued) {
this.db.admin_waitlist.push({
RequestId: `${group.Gid}_${user.Uid}`,
Uid: user.Uid,
Gid: group.Gid,
RequestedAt: this.toMinuteIso(),
Status: 'pending',
});
}
return { queued: true, Gid: group.Gid, Uid: user.Uid };
}
promoteAdmin({ uid, gid, approvedByUid }) {
const user = this.getUserById(Number(uid));
const group = this.getGroupById(Number(gid));
if (!user || !group) {
throw new Error('User or group not found');
}
this.ensureMembership(user.Uid, group.Gid, 0, true);
user.Gid = group.Gid;
user.SGid = 0;
user.IsAcceptedAdmin = true;
if (!group.ApprovedAdmins.includes(user.Uid)) {
group.ApprovedAdmins.push(user.Uid);
}
if (!group.ApprovedAdmins.includes(Number(approvedByUid))) {
group.ApprovedAdmins.push(Number(approvedByUid));
}
this.db.admin_waitlist = this.db.admin_waitlist.map((entry) => {
if (entry.Uid === user.Uid && entry.Gid === group.Gid && entry.Status === 'pending') {
return { ...entry, Status: 'approved' };
}
return entry;
});
return user;
}
requestAdminApproval({ userId, groupId }) {
const group = this.getGroupById(Number(groupId));
if (!group) {
throw new Error('Group not found');
}
return this.joinAsAdmin({ userId, businessOrSchoolName: group.BusinessOrSchoolName });
}
approveAdmin({ approverUserId, userId, groupId }) {
const approverMembership = this.db.subgroup_memberships.find(
(membership) => membership.Uid === Number(approverUserId)
&& membership.Gid === Number(groupId)
&& membership.SGid === 0
&& membership.IsAcceptedAdmin,
);
if (!approverMembership) {
throw new Error('Approver is not an admin for this group');
}
return this.promoteAdmin({ uid: Number(userId), gid: Number(groupId), approvedByUid: Number(approverUserId) });
}
createSubgroup({ gid, subgroupName, cacheTriggerMeters = 20 }) {
const group = this.getGroupById(Number(gid));
if (!group) {
throw new Error('Group not found');
}
const nonAdminCount = this.db.subgroups.filter((entry) => entry.Gid === group.Gid && !entry.IsAdminGroup).length;
if (nonAdminCount >= group.MaxMemberSubgroups) {
throw new Error('Maximum subgroup count reached');
}
const nextSgid = this.db.subgroups
.filter((entry) => entry.Gid === group.Gid && !entry.IsAdminGroup)
.reduce((max, entry) => Math.max(max, entry.SGid), 0) + 1;
const subgroup = {
SGid: nextSgid,
SubGroupName: subgroupName,
Gid: group.Gid,
JoinCode: this.generateUniqueJoinCode(this.db.subgroups.filter((entry) => !entry.IsAdminGroup), 'JoinCode'),
IsAdminGroup: false,
CacheTriggerMeters: cacheTriggerMeters,
};
this.db.subgroups.push(subgroup);
return subgroup;
}
getSubgroups(gid) {
return this.db.subgroups.filter((subgroup) => subgroup.Gid === Number(gid));
}
updateSubgroupCacheSettings({ gid, sgid, triggerMeters }) {
const subgroup = this.db.subgroups.find(
(entry) => entry.Gid === Number(gid) && entry.SGid === Number(sgid),
);
if (!subgroup) {
throw new Error('Subgroup not found');
}
subgroup.CacheTriggerMeters = Number(triggerMeters);
return subgroup;
}
createTeam({ userId, gid, teamName }) {
const user = this.getUserById(Number(userId));
if (!user) {
throw new Error('User not found');
}
const group = this.getGroupById(Number(gid));
if (!group) {
throw new Error('Group not found');
}
if (!this.canJoinTeamForSubgroup(group, user.SGid || 0)) {
throw new Error('Teams are disabled or user is in admin subgroup');
}
const tid = this.nextId('team');
const team = new TeamModel({
Tid: tid,
TeamName: teamName,
Gid: group.Gid,
JoinCode: this.generateUniqueJoinCode(this.db.teams, 'JoinCode'),
CreatorUid: user.Uid,
SGid: user.SGid,
CreatedAt: this.toMinuteIso(),
});
this.db.teams.push(team.toJSON());
this.db.team_members.push({
Tid: team.Tid,
Uid: user.Uid,
IsTeamCreator: true,
});
user.TGid = team.Tid;
return team.toJSON();
}
joinTeamByCode({ userId, teamCode }) {
const user = this.getUserById(Number(userId));
const team = this.db.teams.find((t) => t.JoinCode === teamCode.toUpperCase());
if (!user || !team) {
throw new Error('Invalid user or team code');
}
const group = this.getGroupById(team.Gid);
if (!group || !this.canJoinTeamForSubgroup(group, user.SGid || 0)) {
throw new Error('Teams are disabled or user is in admin subgroup');
}
if (user.Gid !== team.Gid) {
throw new Error('User is not in this game group');
}
const alreadyInTeam = this.db.team_members.some((tm) => tm.Tid === team.Tid && tm.Uid === user.Uid);
if (!alreadyInTeam) {
this.db.team_members.push({ Tid: team.Tid, Uid: user.Uid, IsTeamCreator: false });
}
user.TGid = team.Tid;
return team;
}
getLobby(gid) {
const group = this.getGroupById(Number(gid));
if (!group) {
return null;
}
const members = this.db.subgroup_memberships
.filter((membership) => membership.Gid === group.Gid)
.map((membership) => this.getUserById(membership.Uid))
.filter(Boolean);
return {
group,
members,
subgroups: this.getSubgroups(group.Gid),
teams: this.db.teams.filter((team) => team.Gid === group.Gid),
};
}
getTeam(tid) {
const team = this.db.teams.find((entry) => entry.Tid === Number(tid));
if (!team) {
return null;
}
const members = this.db.team_members
.filter((member) => member.Tid === team.Tid)
.map((member) => this.getUserById(member.Uid))
.filter(Boolean);
return { team, members };
}
getMapPoints(gid, sgid = null) {
const gameData = this.db.game_data.find((entry) => entry.Gid === Number(gid));
if (!gameData) {
return [];
}
if (sgid === null || sgid === undefined) {
return gameData.GeoCaches;
}
return gameData.GeoCaches.filter((cache) => cache.SGid === Number(sgid));
}
updateGroupSettings({ gid, teamsEnabled }) {
const group = this.getGroupById(Number(gid));
if (!group) {
throw new Error('Group not found');
}
group.TeamsEnabled = Boolean(teamsEnabled);
return group;
}
getState() {
return this.db;
}
}
const dbController = new DbController();
export { DbController };
export default dbController;
+32
View File
@@ -0,0 +1,32 @@
class GroupModel {
constructor(payload) {
this.Gid = payload.Gid;
this.GroupName = payload.GroupName;
this.BusinessOrSchoolName = payload.BusinessOrSchoolName;
this.GameType = payload.GameType;
this.AdminJoinCode = payload.AdminJoinCode;
this.CreatedByUid = payload.CreatedByUid;
this.MaxMemberSubgroups = payload.MaxMemberSubgroups;
this.TeamsEnabled = Boolean(payload.TeamsEnabled);
this.ApprovedAdmins = payload.ApprovedAdmins || [];
this.CreatedAt = payload.CreatedAt;
}
toJSON() {
return {
Gid: this.Gid,
GroupName: this.GroupName,
BusinessOrSchoolName: this.BusinessOrSchoolName,
GameType: this.GameType,
AdminJoinCode: this.AdminJoinCode,
CreatedByUid: this.CreatedByUid,
MaxMemberSubgroups: this.MaxMemberSubgroups,
TeamsEnabled: this.TeamsEnabled,
ApprovedAdmins: this.ApprovedAdmins,
CreatedAt: this.CreatedAt,
};
}
}
export default GroupModel;
+30
View File
@@ -0,0 +1,30 @@
const session = {
currentUid: null,
currentGid: null,
currentSGid: null,
currentTGid: null,
};
const setSessionUser = (user) => {
session.currentUid = user?.Uid ?? null;
session.currentGid = user?.Gid ?? null;
session.currentSGid = user?.SGid ?? null;
session.currentTGid = user?.TGid ?? null;
};
const setSessionGroup = (gid, sgid = null) => {
session.currentGid = gid ?? null;
if (sgid !== null && sgid !== undefined) {
session.currentSGid = sgid;
}
};
const setSessionTeam = (tgid) => {
session.currentTGid = tgid ?? null;
};
const getSession = () => ({ ...session });
export { setSessionUser, setSessionGroup, setSessionTeam, getSession };
+26
View File
@@ -0,0 +1,26 @@
class TeamModel {
constructor(payload) {
this.Tid = payload.Tid;
this.TeamName = payload.TeamName;
this.Gid = payload.Gid;
this.JoinCode = payload.JoinCode;
this.CreatorUid = payload.CreatorUid;
this.SGid = payload.SGid;
this.CreatedAt = payload.CreatedAt;
}
toJSON() {
return {
Tid: this.Tid,
TeamName: this.TeamName,
Gid: this.Gid,
JoinCode: this.JoinCode,
CreatorUid: this.CreatorUid,
SGid: this.SGid,
CreatedAt: this.CreatedAt,
};
}
}
export default TeamModel;
+30
View File
@@ -0,0 +1,30 @@
class UserModel {
constructor(payload) {
this.Uid = payload.Uid;
this.username = payload.username;
this.email = payload.email;
this.passwordHash = payload.passwordHash;
this.isBusiness = Boolean(payload.isBusiness);
this.IsAcceptedAdmin = Boolean(payload.IsAcceptedAdmin);
this.Gid = payload.Gid ?? null;
this.SGid = payload.SGid ?? null;
this.TGid = payload.TGid ?? null;
}
toJSON() {
return {
Uid: this.Uid,
username: this.username,
email: this.email,
passwordHash: this.passwordHash,
isBusiness: this.isBusiness,
IsAcceptedAdmin: this.IsAcceptedAdmin,
Gid: this.Gid,
SGid: this.SGid,
TGid: this.TGid,
};
}
}
export default UserModel;
+70
View File
@@ -0,0 +1,70 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import AdminFooterNavbar from '../components/AdminFooterNavbar';
import useAdminViewModel from '../ViewModel/useAdminViewModel';
const AdminHubPage = () => {
const navigation = useNavigation();
const { createGame } = useAdminViewModel();
const handleCreateGame = async () => {
await createGame({
groupName: `Admin Game ${Date.now().toString().slice(-4)}`,
businessOrSchoolName: 'New Business',
gameType: 'private',
isBusiness: true,
});
navigation.navigate('game-lobby');
};
return (
<View style={styles.wrapper}>
<View style={styles.container}>
<Text style={styles.title}>Admin Hub</Text>
<Pressable style={styles.button} onPress={handleCreateGame}>
<Text style={styles.buttonText}>Create a Game</Text>
</Pressable>
<Pressable style={styles.button} onPress={() => navigation.navigate('game-lobby')}>
<Text style={styles.buttonText}>Manage Existing Game</Text>
</Pressable>
<Pressable style={styles.button} onPress={() => navigation.navigate('manage-subgroups')}>
<Text style={styles.buttonText}>Manage Subgroups</Text>
</Pressable>
</View>
<AdminFooterNavbar />
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
flex: 1,
backgroundColor: '#ffffff',
},
container: {
flex: 1,
justifyContent: 'center',
padding: 20,
gap: 12,
},
title: {
fontSize: 26,
fontWeight: '700',
color: '#111827',
marginBottom: 8,
},
button: {
backgroundColor: '#2563eb',
borderRadius: 8,
paddingVertical: 12,
alignItems: 'center',
},
buttonText: {
color: '#ffffff',
fontWeight: '600',
},
});
export default AdminHubPage;
+97 -7
View File
@@ -1,15 +1,105 @@
import React from 'react';
import { View, Text, Button, TextInput } from 'react-native';
import React, { useState } from 'react';
import { View, Text, Pressable, TextInput, StyleSheet } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import MemberFooterNavbar from '../components/MemberFooterNavbar';
import useLobbyViewModel from '../ViewModel/useLobbyViewModel';
const GameLobbyPage = () => {
const navigation = useNavigation();
const { teamCode, error, setTeamCode, createLobbyTeam, joinLobbyTeam } = useLobbyViewModel();
const [hasTeam, setHasTeam] = useState(false);
const handleCreateTeam = async () => {
const team = await createLobbyTeam({
teamName: `Team-${Date.now().toString().slice(-4)}`,
});
if (!team) {
return;
}
setHasTeam(true);
navigation.navigate('team');
};
const handleJoinTeam = async () => {
const team = await joinLobbyTeam();
if (!team) {
return;
}
setHasTeam(true);
navigation.navigate('team');
};
return (
<View>
<Text>Game Lobby</Text>
<Button title="Create Team" onPress={() => {}} />
<Text>Join Team Code:</Text>
<TextInput placeholder="Enter Team Join Code" />
<View style={styles.wrapper}>
<View style={styles.container}>
<Text style={styles.title}>Game Lobby</Text>
<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('map')}>
<Text style={styles.buttonText}>Open Map</Text>
</Pressable>
{!!error && <Text style={styles.errorText}>{error}</Text>}
</View>
<MemberFooterNavbar visible={hasTeam} />
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
flex: 1,
backgroundColor: '#ffffff',
},
container: {
flex: 1,
justifyContent: 'center',
padding: 20,
gap: 12,
},
title: {
fontSize: 26,
fontWeight: '700',
color: '#111827',
marginBottom: 8,
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
},
primaryButton: {
backgroundColor: '#2563eb',
borderRadius: 8,
paddingVertical: 12,
alignItems: 'center',
},
secondaryButton: {
backgroundColor: '#4b5563',
borderRadius: 8,
paddingVertical: 12,
alignItems: 'center',
},
buttonText: {
color: '#ffffff',
fontWeight: '600',
},
errorText: {
color: '#dc2626',
},
});
export default GameLobbyPage;
+51 -5
View File
@@ -1,14 +1,60 @@
import React from 'react';
import { View, Button } from 'react-native';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import useGameTypeViewModel from '../ViewModel/useGameTypeViewModel';
const GameTypePage = () => {
const navigation = useNavigation();
const { gameTypes } = useGameTypeViewModel();
const navigateByType = (label) => {
if (label === 'Join Global Game') {
navigation.navigate('game-lobby');
return;
}
if (label === 'Join Private Game') {
navigation.navigate('join-private-game');
return;
}
navigation.navigate('manage-private-subtask');
};
return (
<View>
<Button title="Join Global Game" onPress={() => {}} />
<Button title="Join Private Game" onPress={() => {}} />
<Button title="Manage Private Game" onPress={() => {}} />
<View style={styles.container}>
<Text style={styles.title}>Select Game Type</Text>
{gameTypes.map((label) => (
<Pressable key={label} style={styles.button} onPress={() => navigateByType(label)}>
<Text style={styles.buttonText}>{label}</Text>
</Pressable>
))}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
padding: 20,
gap: 12,
backgroundColor: '#ffffff',
},
title: {
fontSize: 26,
fontWeight: '700',
color: '#111827',
marginBottom: 8,
},
button: {
backgroundColor: '#2563eb',
borderRadius: 8,
paddingVertical: 12,
alignItems: 'center',
},
buttonText: {
color: '#ffffff',
fontWeight: '600',
},
});
export default GameTypePage;
+64 -7
View File
@@ -1,19 +1,76 @@
import React, { useState } from 'react';
import { View, TextInput, Button } from 'react-native';
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import usePrivateGameViewModel from '../ViewModel/usePrivateGameViewModel';
const JoinPrivateGamePage = () => {
const [joinCode, setJoinCode] = useState('');
const navigation = useNavigation();
const { joinCode, error, setJoinCode, joinWithCode } = usePrivateGameViewModel();
const [submitting, setSubmitting] = useState(false);
const handleJoinGame = () => {
// Logic to check join code and redirect to game lobby
const handleJoinGame = async () => {
setSubmitting(true);
const group = await joinWithCode();
setSubmitting(false);
if (!group) {
return;
}
navigation.navigate('game-lobby');
};
return (
<View>
<TextInput placeholder="Enter Join Code" value={joinCode} onChangeText={setJoinCode} />
<Button title="Join Game" onPress={handleJoinGame} />
<View style={styles.container}>
<Text style={styles.title}>Join Private Game</Text>
<TextInput
style={styles.input}
placeholder="Enter Join Code"
autoCapitalize="characters"
maxLength={6}
value={joinCode}
onChangeText={setJoinCode}
/>
<Pressable style={styles.button} onPress={handleJoinGame}>
<Text style={styles.buttonText}>{submitting ? 'Joining...' : 'Join Game'}</Text>
</Pressable>
{!!error && <Text style={styles.errorText}>{error}</Text>}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
padding: 20,
gap: 12,
backgroundColor: '#ffffff',
},
title: {
fontSize: 26,
fontWeight: '700',
color: '#111827',
marginBottom: 8,
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
},
button: {
backgroundColor: '#2563eb',
borderRadius: 8,
paddingVertical: 12,
alignItems: 'center',
},
buttonText: {
color: '#ffffff',
fontWeight: '600',
},
errorText: {
color: '#dc2626',
},
});
export default JoinPrivateGamePage;
+114
View File
@@ -0,0 +1,114 @@
import React, { useState } from 'react';
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import useAuthViewModel from '../ViewModel/useAuthViewModel';
const LoginPage = () => {
const navigation = useNavigation();
const {
email,
password,
error,
setEmail,
setPassword,
login,
} = useAuthViewModel();
const [submitting, setSubmitting] = useState(false);
const handleLogin = async () => {
setSubmitting(true);
const user = await login();
setSubmitting(false);
if (!user) {
return;
}
if (user.SGid === 0) {
navigation.navigate('admin-hub');
return;
}
navigation.navigate('game-type');
};
return (
<View style={styles.container}>
<Text style={styles.title}>Login</Text>
<TextInput
style={styles.input}
placeholder="Email"
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
/>
<TextInput
style={styles.input}
placeholder="Password"
secureTextEntry
value={password}
onChangeText={setPassword}
/>
<View style={styles.row}>
<Pressable style={styles.leftButton} onPress={() => navigation.navigate('register')}>
<Text style={styles.buttonText}>Register</Text>
</Pressable>
<Pressable style={styles.rightButton} onPress={handleLogin}>
<Text style={styles.buttonText}>{submitting ? 'Logging in...' : 'Login'}</Text>
</Pressable>
</View>
{!!error && <Text style={styles.errorText}>{error}</Text>}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
justifyContent: 'center',
gap: 12,
backgroundColor: '#ffffff',
},
title: {
fontSize: 28,
fontWeight: '700',
marginBottom: 8,
color: '#111827',
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
},
row: {
flexDirection: 'row',
gap: 10,
marginTop: 4,
},
leftButton: {
flex: 1,
backgroundColor: '#4b5563',
borderRadius: 8,
alignItems: 'center',
paddingVertical: 12,
},
rightButton: {
flex: 1,
backgroundColor: '#2563eb',
borderRadius: 8,
alignItems: 'center',
paddingVertical: 12,
},
buttonText: {
color: '#ffffff',
fontWeight: '600',
},
errorText: {
color: '#dc2626',
marginTop: 4,
},
});
export default LoginPage;
+100
View File
@@ -0,0 +1,100 @@
import React, { useState } from 'react';
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import useManagePrivateSubtaskViewModel from '../ViewModel/useManagePrivateSubtaskViewModel';
const ManagePrivateSubtaskPage = () => {
const navigation = useNavigation();
const {
businessOrSchoolName,
error,
setBusinessOrSchoolName,
createNewPrivateGame,
joinAdminForPrivate,
} = useManagePrivateSubtaskViewModel();
const [submitting, setSubmitting] = useState(false);
const handleCreate = async () => {
setSubmitting(true);
const group = await createNewPrivateGame();
setSubmitting(false);
if (!group) {
return;
}
navigation.navigate('admin-hub');
};
const handleJoinAdmin = async () => {
setSubmitting(true);
const response = await joinAdminForPrivate();
setSubmitting(false);
if (!response) {
return;
}
navigation.navigate('admin-hub');
};
return (
<View style={styles.container}>
<Text style={styles.title}>Manage Private Game</Text>
<TextInput
style={styles.input}
placeholder="Business/School Name"
value={businessOrSchoolName}
onChangeText={setBusinessOrSchoolName}
/>
<Pressable style={styles.primaryButton} onPress={handleCreate}>
<Text style={styles.buttonText}>{submitting ? 'Please wait...' : 'Create New Private Game'}</Text>
</Pressable>
<Pressable style={styles.secondaryButton} onPress={handleJoinAdmin}>
<Text style={styles.buttonText}>Join as Admin for Private Game</Text>
</Pressable>
{!!error && <Text style={styles.errorText}>{error}</Text>}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
gap: 12,
padding: 20,
backgroundColor: '#ffffff',
},
title: {
fontSize: 26,
fontWeight: '700',
color: '#111827',
marginBottom: 8,
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
},
primaryButton: {
backgroundColor: '#2563eb',
borderRadius: 8,
alignItems: 'center',
paddingVertical: 12,
},
secondaryButton: {
backgroundColor: '#4b5563',
borderRadius: 8,
alignItems: 'center',
paddingVertical: 12,
},
buttonText: {
color: '#ffffff',
fontWeight: '600',
},
errorText: {
color: '#dc2626',
},
});
export default ManagePrivateSubtaskPage;
+118
View File
@@ -0,0 +1,118 @@
import React from 'react';
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
import useSubgroupAdminViewModel from '../ViewModel/useSubgroupAdminViewModel';
import AdminFooterNavbar from '../components/AdminFooterNavbar';
const ManageSubgroupsPage = () => {
const {
subgroups,
subgroupName,
cacheTriggerMeters,
error,
setSubgroupName,
setCacheTriggerMeters,
addSubgroup,
updateCacheForSubgroup,
} = useSubgroupAdminViewModel();
return (
<View style={styles.wrapper}>
<View style={styles.container}>
<Text style={styles.title}>Manage Subgroups</Text>
{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>
{!!error && <Text style={styles.errorText}>{error}</Text>}
</View>
<AdminFooterNavbar />
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
flex: 1,
backgroundColor: '#ffffff',
},
container: {
flex: 1,
gap: 10,
padding: 20,
},
title: {
fontSize: 24,
fontWeight: '700',
color: '#111827',
},
row: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
padding: 10,
gap: 8,
},
rowText: {
color: '#111827',
fontSize: 13,
},
inlineButton: {
alignSelf: 'flex-start',
backgroundColor: '#2563eb',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 6,
},
inlineButtonText: {
color: '#ffffff',
fontWeight: '600',
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
},
button: {
backgroundColor: '#2563eb',
borderRadius: 8,
alignItems: 'center',
paddingVertical: 12,
},
buttonText: {
color: '#ffffff',
fontWeight: '600',
},
errorText: {
color: '#dc2626',
},
});
export default ManageSubgroupsPage;
+44
View File
@@ -0,0 +1,44 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import MemberFooterNavbar from '../components/MemberFooterNavbar';
import useMapViewModel from '../ViewModel/useMapViewModel';
const MapPage = () => {
const { points } = useMapViewModel();
return (
<View style={styles.wrapper}>
<View style={styles.container}>
<Text style={styles.title}>Map Page</Text>
<Text style={styles.body}>Map and hunt markers appear here.</Text>
<Text style={styles.body}>Points loaded: {points.length}</Text>
</View>
<MemberFooterNavbar visible />
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
flex: 1,
backgroundColor: '#ffffff',
},
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
gap: 6,
},
title: {
fontSize: 26,
fontWeight: '700',
color: '#111827',
},
body: {
color: '#4b5563',
},
});
export default MapPage;
+132 -19
View File
@@ -1,31 +1,144 @@
import React, { useState } from 'react';
import { View, Text, TextInput, Button, Picker } from 'react-native';
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import useAuthViewModel from '../ViewModel/useAuthViewModel';
const RegisterPage = () => {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [userType, setUserType] = useState('Individual');
const navigation = useNavigation();
const {
username,
email,
password,
confirmPassword,
accountType,
error,
setUsername,
setEmail,
setPassword,
setConfirmPassword,
setAccountType,
register,
} = useAuthViewModel();
const [submitting, setSubmitting] = useState(false);
const handleRegister = () => {
// Registration logic here
const handleRegister = async () => {
setSubmitting(true);
const user = await register();
setSubmitting(false);
if (!user) {
return;
}
navigation.navigate(user.SGid === 0 ? 'admin-hub' : 'game-type');
};
return (
<View>
<Text>Register</Text>
<TextInput placeholder="Username" value={username} onChangeText={setUsername} />
<TextInput placeholder="Email" value={email} onChangeText={setEmail} />
<TextInput placeholder="Password" value={password} onChangeText={setPassword} secureTextEntry />
<TextInput placeholder="Confirm Password" value={confirmPassword} onChangeText={setConfirmPassword} secureTextEntry />
<Picker selectedValue={userType} onValueChange={(itemValue) => setUserType(itemValue)}>
<Picker.Item label="Individual" value="Individual" />
<Picker.Item label="Business/School" value="Business/School" />
</Picker>
<Button title="Register" onPress={handleRegister} />
<View style={styles.container}>
<Text style={styles.title}>Register</Text>
<TextInput style={styles.input} placeholder="Username" value={username} onChangeText={setUsername} />
<TextInput
style={styles.input}
placeholder="Email"
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
/>
<TextInput style={styles.input} placeholder="Password" value={password} onChangeText={setPassword} secureTextEntry />
<TextInput
style={styles.input}
placeholder="Confirm Password"
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
/>
<View style={styles.typeRow}>
<Pressable
style={[styles.typeButton, accountType === 'Individual' && styles.typeButtonActive]}
onPress={() => setAccountType('Individual')}
>
<Text style={[styles.typeText, accountType === 'Individual' && styles.typeTextActive]}>Individual</Text>
</Pressable>
<Pressable
style={[styles.typeButton, accountType === 'Business/School' && styles.typeButtonActive]}
onPress={() => setAccountType('Business/School')}
>
<Text style={[styles.typeText, accountType === 'Business/School' && styles.typeTextActive]}>Business/School</Text>
</Pressable>
</View>
<Pressable style={styles.submitButton} onPress={handleRegister}>
<Text style={styles.submitText}>{submitting ? 'Creating...' : 'Create Account'}</Text>
</Pressable>
<Pressable onPress={() => navigation.navigate('login')}>
<Text style={styles.backText}>Back to Login</Text>
</Pressable>
{!!error && <Text style={styles.errorText}>{error}</Text>}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
justifyContent: 'center',
gap: 12,
backgroundColor: '#ffffff',
},
title: {
fontSize: 28,
fontWeight: '700',
color: '#111827',
marginBottom: 8,
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
},
typeRow: {
flexDirection: 'row',
gap: 10,
},
typeButton: {
flex: 1,
borderWidth: 1,
borderColor: '#cbd5e1',
borderRadius: 8,
alignItems: 'center',
paddingVertical: 10,
},
typeButtonActive: {
backgroundColor: '#2563eb',
borderColor: '#2563eb',
},
typeText: {
color: '#1f2937',
fontWeight: '600',
},
typeTextActive: {
color: '#ffffff',
},
submitButton: {
backgroundColor: '#2563eb',
borderRadius: 8,
alignItems: 'center',
paddingVertical: 12,
},
submitText: {
color: '#ffffff',
fontWeight: '600',
},
backText: {
textAlign: 'center',
color: '#2563eb',
fontWeight: '600',
},
errorText: {
textAlign: 'center',
color: '#dc2626',
},
});
export default RegisterPage;
+44
View File
@@ -0,0 +1,44 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import MemberFooterNavbar from '../components/MemberFooterNavbar';
import useTeamViewModel from '../ViewModel/useTeamViewModel';
const TeamPage = () => {
const { team } = useTeamViewModel();
return (
<View style={styles.wrapper}>
<View style={styles.container}>
<Text style={styles.title}>Team Page</Text>
<Text style={styles.body}>Team details and members appear here.</Text>
<Text style={styles.body}>{team?.team?.TeamName || 'No team joined yet'}</Text>
</View>
<MemberFooterNavbar visible />
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
flex: 1,
backgroundColor: '#ffffff',
},
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
gap: 6,
},
title: {
fontSize: 26,
fontWeight: '700',
color: '#111827',
},
body: {
color: '#4b5563',
},
});
export default TeamPage;
+29
View File
@@ -0,0 +1,29 @@
import {
approveAdminAccess,
createPrivateGame,
requestAdminAccess,
updateGroupSettings,
} from '../API/API';
import { getSession, setSessionGroup } from '../Model/SessionStore';
const useAdminViewModel = () => {
const createGame = async (payload) => {
const { currentUid } = getSession();
const group = await createPrivateGame({ ...payload, userId: currentUid });
setSessionGroup(group.Gid, 0);
return group;
};
const requestAdmin = async (payload) => requestAdminAccess(payload);
const approveAdmin = async (payload) => approveAdminAccess(payload);
const setTeamsEnabled = async (gid, teamsEnabled) => updateGroupSettings({ gid, teamsEnabled });
return {
createGame,
requestAdmin,
approveAdmin,
setTeamsEnabled,
};
};
export default useAdminViewModel;
+71
View File
@@ -0,0 +1,71 @@
import { useMemo, useState } from 'react';
import { loginUser, registerUser } from '../API/API';
import { setSessionUser } from '../Model/SessionStore';
const useAuthViewModel = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [username, setUsername] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [accountType, setAccountType] = useState('Individual');
const [error, setError] = useState('');
const [currentUser, setCurrentUser] = useState(null);
const canRegister = useMemo(
() => Boolean(username && email && password && confirmPassword),
[username, email, password, confirmPassword],
);
const login = async () => {
setError('');
try {
const user = await loginUser(email.trim(), password);
setCurrentUser(user);
setSessionUser(user);
return user;
} catch (e) {
setError(e.message);
return null;
}
};
const register = async () => {
setError('');
try {
const user = await registerUser({
username: username.trim(),
email: email.trim().toLowerCase(),
password,
confirmPassword,
accountType,
});
setCurrentUser(user);
setSessionUser(user);
return user;
} catch (e) {
setError(e.message);
return null;
}
};
return {
email,
password,
username,
confirmPassword,
accountType,
error,
currentUser,
canRegister,
setEmail,
setPassword,
setUsername,
setConfirmPassword,
setAccountType,
login,
register,
};
};
export default useAuthViewModel;
+23
View File
@@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';
import { getGameTypes } from '../API/API';
const useGameTypeViewModel = () => {
const [gameTypes, setGameTypes] = useState([]);
useEffect(() => {
let mounted = true;
getGameTypes().then((types) => {
if (mounted) {
setGameTypes(types);
}
});
return () => {
mounted = false;
};
}, []);
return { gameTypes };
};
export default useGameTypeViewModel;
+51
View File
@@ -0,0 +1,51 @@
import { useState } from 'react';
import { createTeam, getLobby, joinTeam } from '../API/API';
import { getSession, setSessionTeam } from '../Model/SessionStore';
const useLobbyViewModel = () => {
const [teamCode, setTeamCode] = useState('');
const [error, setError] = useState('');
const loadLobby = async () => {
const { currentGid } = getSession();
return getLobby(currentGid);
};
const createLobbyTeam = async ({ teamName }) => {
setError('');
try {
const { currentUid, currentGid } = getSession();
const team = await createTeam({ userId: currentUid, gid: currentGid, teamName });
setSessionTeam(team.Tid);
return team;
} catch (e) {
setError(e.message);
return null;
}
};
const joinLobbyTeam = async () => {
setError('');
try {
const { currentUid } = getSession();
const team = await joinTeam({ userId: currentUid, teamCode: teamCode.trim().toUpperCase() });
setSessionTeam(team.Tid);
return team;
} catch (e) {
setError(e.message);
return null;
}
};
return {
teamCode,
error,
setTeamCode,
loadLobby,
createLobbyTeam,
joinLobbyTeam,
};
};
export default useLobbyViewModel;
@@ -0,0 +1,56 @@
import { useState } from 'react';
import { createPrivateGame, joinAsAdmin } from '../API/API';
import { getSession, setSessionGroup } from '../Model/SessionStore';
const useManagePrivateSubtaskViewModel = () => {
const [businessOrSchoolName, setBusinessOrSchoolName] = useState('');
const [error, setError] = useState('');
const createNewPrivateGame = async () => {
setError('');
const { currentUid } = getSession();
try {
const group = await createPrivateGame({
userId: currentUid,
groupName: businessOrSchoolName.trim() || 'New Private Game',
businessOrSchoolName: businessOrSchoolName.trim() || 'Unnamed Business',
isBusiness: true,
});
setSessionGroup(group.Gid, 0);
return group;
} catch (e) {
setError(e.message);
return null;
}
};
const joinAdminForPrivate = async () => {
setError('');
const { currentUid } = getSession();
try {
const response = await joinAsAdmin({
userId: currentUid,
businessOrSchoolName: businessOrSchoolName.trim(),
});
const gid = response?.Gid ?? null;
if (gid !== null) {
setSessionGroup(gid, 0);
}
return response;
} catch (e) {
setError(e.message);
return null;
}
};
return {
businessOrSchoolName,
error,
setBusinessOrSchoolName,
createNewPrivateGame,
joinAdminForPrivate,
};
};
export default useManagePrivateSubtaskViewModel;
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from 'react';
import { getMapPoints } from '../API/API';
import { getSession } from '../Model/SessionStore';
const useMapViewModel = (groupId = null) => {
const [points, setPoints] = useState([]);
const { currentGid, currentSGid } = getSession();
const resolvedGid = groupId ?? currentGid;
useEffect(() => {
if (!resolvedGid) {
setPoints([]);
return;
}
let active = true;
getMapPoints(resolvedGid, currentSGid).then((payload) => {
if (active) {
setPoints(payload);
}
});
return () => {
active = false;
};
}, [resolvedGid, currentSGid]);
return { points };
};
export default useMapViewModel;
+31
View File
@@ -0,0 +1,31 @@
import { useState } from 'react';
import { joinPrivateGame } from '../API/API';
import { getSession, setSessionGroup } from '../Model/SessionStore';
const usePrivateGameViewModel = () => {
const [joinCode, setJoinCode] = useState('');
const [error, setError] = useState('');
const joinWithCode = async () => {
setError('');
try {
const { currentUid } = getSession();
const group = await joinPrivateGame({ userId: currentUid, joinCode: joinCode.trim().toUpperCase() });
setSessionGroup(group.Gid);
return group;
} catch (e) {
setError(e.message);
return null;
}
};
return {
joinCode,
error,
setJoinCode,
joinWithCode,
};
};
export default usePrivateGameViewModel;
@@ -0,0 +1,66 @@
import { useCallback, useEffect, useState } from 'react';
import { createSubgroup, getSubgroups, 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 { currentGid } = getSession();
const loadSubgroups = useCallback(async () => {
if (!currentGid) {
return;
}
const rows = await getSubgroups(currentGid);
setSubgroups(rows);
}, [currentGid]);
useEffect(() => {
loadSubgroups();
}, [loadSubgroups]);
const addSubgroup = async () => {
setError('');
try {
await createSubgroup({
gid: currentGid,
subgroupName: subgroupName.trim(),
cacheTriggerMeters: Number(cacheTriggerMeters),
});
setSubgroupName('');
await loadSubgroups();
return true;
} catch (e) {
setError(e.message);
return false;
}
};
const updateCacheForSubgroup = async (sgid, triggerMeters) => {
setError('');
try {
await updateSubgroupCache({ gid: currentGid, sgid, triggerMeters });
await loadSubgroups();
return true;
} catch (e) {
setError(e.message);
return false;
}
};
return {
subgroups,
subgroupName,
cacheTriggerMeters,
error,
setSubgroupName,
setCacheTriggerMeters,
addSubgroup,
updateCacheForSubgroup,
};
};
export default useSubgroupAdminViewModel;
+33
View File
@@ -0,0 +1,33 @@
import { useEffect, useState } from 'react';
import { getTeam } from '../API/API';
import { getSession } from '../Model/SessionStore';
const useTeamViewModel = (teamId = null) => {
const [team, setTeam] = useState(null);
const resolvedTeamId = teamId ?? getSession().currentTGid;
useEffect(() => {
if (!resolvedTeamId) {
setTeam(null);
return;
}
let active = true;
getTeam(resolvedTeamId).then((payload) => {
if (active) {
setTeam(payload);
}
}).catch(() => {
if (active) {
setTeam(null);
}
});
return () => {
active = false;
};
}, [resolvedTeamId]);
return { team };
};
export default useTeamViewModel;
+15 -1
View File
@@ -1,5 +1,19 @@
import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
return (
<Stack initialRouteName="index">
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="login" options={{ title: "Login" }} />
<Stack.Screen name="register" options={{ title: "Register" }} />
<Stack.Screen name="game-type" options={{ title: "Select Game Type" }} />
<Stack.Screen name="join-private-game" options={{ title: "Join Private Game" }} />
<Stack.Screen name="manage-private-subtask" options={{ title: "Manage Private Game" }} />
<Stack.Screen name="game-lobby" options={{ title: "Game Lobby" }} />
<Stack.Screen name="team" options={{ title: "Team" }} />
<Stack.Screen name="map" options={{ title: "Map" }} />
<Stack.Screen name="admin-hub" options={{ title: "Admin Hub" }} />
<Stack.Screen name="manage-subgroups" options={{ title: "Manage Subgroups" }} />
</Stack>
);
}
+4
View File
@@ -0,0 +1,4 @@
import AdminHubPage from './Screens/AdminHubPage';
export default AdminHubPage;
+45
View File
@@ -0,0 +1,45 @@
import React from 'react';
import { View, Pressable, Text, StyleSheet } from 'react-native';
import { useNavigation } from '@react-navigation/native';
const AdminFooterNavbar = () => {
const navigation = useNavigation();
return (
<View style={styles.container}>
<Pressable style={styles.button} onPress={() => navigation.navigate('admin-hub')}>
<Text style={styles.text}>Admin Hub</Text>
</Pressable>
<Pressable style={styles.button} onPress={() => navigation.navigate('game-lobby')}>
<Text style={styles.text}>Manage Game</Text>
</Pressable>
<Pressable style={styles.button} onPress={() => navigation.navigate('team')}>
<Text style={styles.text}>Manage Team</Text>
</Pressable>
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'space-between',
borderTopWidth: 1,
borderColor: '#d4d4d8',
paddingHorizontal: 16,
paddingVertical: 10,
backgroundColor: '#ffffff',
},
button: {
flex: 1,
alignItems: 'center',
},
text: {
fontSize: 14,
fontWeight: '600',
color: '#111827',
},
});
export default AdminFooterNavbar;
+49
View File
@@ -0,0 +1,49 @@
import React from 'react';
import { View, Pressable, Text, StyleSheet } from 'react-native';
import { useNavigation } from '@react-navigation/native';
const MemberFooterNavbar = ({ visible = false }) => {
const navigation = useNavigation();
if (!visible) {
return null;
}
return (
<View style={styles.container}>
<Pressable style={styles.button} onPress={() => navigation.navigate('game-lobby')}>
<Text style={styles.text}>Lobby</Text>
</Pressable>
<Pressable style={styles.button} onPress={() => navigation.navigate('team')}>
<Text style={styles.text}>Team</Text>
</Pressable>
<Pressable style={styles.button} onPress={() => navigation.navigate('map')}>
<Text style={styles.text}>Map</Text>
</Pressable>
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'space-between',
borderTopWidth: 1,
borderColor: '#d4d4d8',
paddingHorizontal: 16,
paddingVertical: 10,
backgroundColor: '#ffffff',
},
button: {
flex: 1,
alignItems: 'center',
},
text: {
fontSize: 14,
fontWeight: '600',
color: '#111827',
},
});
export default MemberFooterNavbar;
+4
View File
@@ -0,0 +1,4 @@
import GameLobbyPage from './Screens/GameLobbyPage';
export default GameLobbyPage;
+4
View File
@@ -0,0 +1,4 @@
import GameTypePage from './Screens/GameTypePage';
export default GameTypePage;
+2 -12
View File
@@ -1,15 +1,5 @@
import { Text, View } from "react-native";
import { Redirect } from "expo-router";
export default function Index() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Text>Cat testing</Text>
</View>
);
return <Redirect href="/login" />;
}
+4
View File
@@ -0,0 +1,4 @@
import JoinPrivateGamePage from './Screens/JoinPrivateGamePage';
export default JoinPrivateGamePage;
+4
View File
@@ -0,0 +1,4 @@
import LoginPage from './Screens/LoginPage';
export default LoginPage;
+4
View File
@@ -0,0 +1,4 @@
import ManagePrivateSubtaskPage from './Screens/ManagePrivateSubtaskPage';
export default ManagePrivateSubtaskPage;
+4
View File
@@ -0,0 +1,4 @@
import ManageSubgroupsPage from './Screens/ManageSubgroupsPage';
export default ManageSubgroupsPage;
+4
View File
@@ -0,0 +1,4 @@
import MapPage from './Screens/MapPage';
export default MapPage;
+4
View File
@@ -0,0 +1,4 @@
import RegisterPage from './Screens/RegisterPage';
export default RegisterPage;
+4
View File
@@ -0,0 +1,4 @@
import TeamPage from './Screens/TeamPage';
export default TeamPage;