From 64940bced3492877ebda83d3392104dbcf30d6d5 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 31 May 2024 19:45:58 -0500 Subject: [PATCH 001/198] version bump --- app.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app.json b/app.json index fce8c7f3..d8531cef 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "TAMU SHPE", "slug": "TAMU-SHPE", - "version": "0.6.4", + "version": "0.6.5", "owner": "tamu-shpe", "orientation": "portrait", "icon": "./assets/icon.png", @@ -56,7 +56,7 @@ }, "ios": { "bundleIdentifier": "com.tamu.shpe", - "buildNumber": "1" + "buildNumber": "1" }, "extra": { "eas": { diff --git a/package.json b/package.json index b6e7fa1d..78165296 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shpe-app", - "version": "0.6.4", + "version": "0.6.5", "scripts": { "start": "npx expo start --dev-client", "test": "jest --coverage=true --verbose --bail --config ./jest.config.ts", From 989d95c079c73ecfd7584865c65518fe1198e210 Mon Sep 17 00:00:00 2001 From: LucientZ Date: Fri, 31 May 2024 21:49:06 -0500 Subject: [PATCH 002/198] Updated ts-node --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 78165296..24789f1e 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "react-native-svg-transformer": "^1.2.0", "sharp-cli": "^4.1.1", "tailwindcss": "^3.3.2", - "ts-node": "^10.9.1", + "ts-node": "^10.9.2", "typescript": "~5.3.3" }, "private": true diff --git a/yarn.lock b/yarn.lock index 21046980..24ddf8a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10747,10 +10747,10 @@ ts-jest@^29.1.1: semver "^7.5.3" yargs-parser "^21.0.1" -ts-node@^10.9.1: - version "10.9.1" - resolved "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz" - integrity sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw== +ts-node@^10.9.2: + version "10.9.2" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" + integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== dependencies: "@cspotcode/source-map-support" "^0.8.0" "@tsconfig/node10" "^1.0.7" From 6a5ad57bae80b091b0b30ac995c7e998345893ed Mon Sep 17 00:00:00 2001 From: LucientZ Date: Fri, 31 May 2024 22:09:19 -0500 Subject: [PATCH 003/198] Removed firebase utils testing temporarily --- src/api/__tests__/firebaseUtils.test.ts | 105 ------------------------ 1 file changed, 105 deletions(-) delete mode 100644 src/api/__tests__/firebaseUtils.test.ts diff --git a/src/api/__tests__/firebaseUtils.test.ts b/src/api/__tests__/firebaseUtils.test.ts deleted file mode 100644 index 8dd65646..00000000 --- a/src/api/__tests__/firebaseUtils.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { signInAnonymously } from "firebase/auth"; -import { db, auth, storage } from "../../config/firebaseConfig"; -import { PrivateUserInfo, PublicUserInfo } from "../../types/user"; -import { randomUint8Array, randomStr, randomStrRange } from "../../helpers/unitTestUtils" -import * as firebaseUtils from "../firebaseUtils"; -import { deleteDoc, doc } from "firebase/firestore"; -import { deleteObject, getDownloadURL, ref } from "firebase/storage"; - -// Creates an anonymous user account for testing. If this doesn't work, all other tests will fail. -beforeAll(async () => { - await signInAnonymously(auth); - expect(auth.currentUser).toBeTruthy(); -}); - -// Cleans up any data created in firebase -afterAll(async () => { - if (auth.currentUser) { - await deleteObject(ref(storage, `user-docs/${auth.currentUser.uid}/test-file`)); - await deleteDoc(doc(db, "users", auth.currentUser?.uid, "private", "privateInfo")); - await deleteDoc(doc(db, "users", auth.currentUser?.uid)); - await auth.currentUser?.delete(); - } -}); - - -describe("Verify user data can be created and modified in firestore", () => { - test("Test setting and getting publicUserInfo", async () => { - expect(auth.currentUser).toBeTruthy(); - const publicData: PublicUserInfo = { - displayName: randomStrRange(0, 80), - photoURL: randomStrRange(0, 100), - roles: { - reader: Math.random() < 0.5, - admin: Math.random() < 0.5, - officer: Math.random() < 0.5, - developer: Math.random() < 0.5, - }, - name: randomStrRange(0, 80), - bio: randomStrRange(0, 80), - major: randomStrRange(0, 80), - classYear: randomStrRange(0, 10), - committees: [randomStrRange(0, 80), randomStrRange(0, 80)], - pointsRank: Math.random() * 10000, - rankChange: "same", - isStudent: Math.random() < 0.5, - isEmailPublic: Math.random() < 0.5, - }; - await firebaseUtils.setPublicUserData(publicData); - - await firebaseUtils.getPublicUserData().then((firebaseData) => { - expect(firebaseData).toBeTruthy(); - expect(firebaseData).toMatchObject(publicData); - }); - }); - - test("Test setting and getting privateUserInfo", async () => { - expect(auth.currentUser).toBeTruthy(); - const privateData: PrivateUserInfo = { - completedAccountSetup: Math.random() < 0.5, - settings: { - darkMode: Math.random() < 0.5, - }, - expoPushTokens: [ - randomStr(50), - randomStr(50), - randomStr(50), - randomStr(50), - ], - email: randomStrRange(0, 80), - resumeURL: randomStrRange(0, 80), - }; - - await firebaseUtils.setPrivateUserData(privateData); - await firebaseUtils.getPrivateUserData().then((firebaseData) => { - expect(firebaseData).toBeTruthy(); - expect(firebaseData).toMatchObject(privateData); - }); - }); -}); - - -describe("Verify firestore cloud storage works", () => { - test("Upload bytes and verify bytes are uploaded correctly", async () => { - expect(auth.currentUser).toBeTruthy(); - var URL = ""; - const byteBuffer: ArrayBuffer = randomUint8Array().buffer; - const uploadTask = firebaseUtils.uploadFileToFirebase(byteBuffer, `user-docs/${auth.currentUser?.uid}/test-file`); - - await new Promise((resolve, reject) => { - uploadTask.on("state_changed", - () => { }, - (error) => { reject(error) }, - async () => { - URL = await getDownloadURL(uploadTask.snapshot.ref) - resolve(null); - }, - ); - }); - - await fetch(URL).then(async (res) => { - const resByteBuffer = await (await res.blob()).arrayBuffer(); - expect(resByteBuffer).toMatchObject(byteBuffer); - }); - }); -}); From 3e5d9363b625c63b38f1e77e52d7eb6c89094544 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 2 Jun 2024 17:27:16 -0500 Subject: [PATCH 004/198] refactor navigation --- src/navigation/AdminDashboardStack.tsx | 42 ---- ...volvementStack.tsx => CommitteesStack.tsx} | 10 +- src/navigation/HomeBottomTabs.tsx | 42 ++-- src/navigation/HomeDrawer.tsx | 212 ------------------ src/navigation/HomeStack.tsx | 34 +++ src/navigation/MainStack.tsx | 25 +-- src/navigation/MembersStack.tsx | 18 -- src/navigation/PublicProfileStack.tsx | 43 ++++ .../{involvement => Committees}/Committee.tsx | 0 .../CommitteeCard.tsx | 0 .../CommitteeTeamCard.tsx | 0 src/screens/Committees/Committees.tsx | 113 ++++++++++ .../CommitteesList.tsx | 0 .../MemberSHPE.tsx | 5 +- src/screens/PublicProfile.tsx | 8 +- src/screens/admin/CommitteeEditor.tsx | 2 +- src/screens/events/EventInfo.tsx | 2 +- src/screens/events/PersonalEventLog.tsx | 4 +- src/screens/events/UpdateEvent.tsx | 2 +- src/screens/home/Home.tsx | 38 +++- src/screens/home/Settings.tsx | 12 +- src/screens/involvement/Involvement.tsx | 56 ----- src/types/navigation.ts | 96 ++++---- 23 files changed, 324 insertions(+), 440 deletions(-) delete mode 100644 src/navigation/AdminDashboardStack.tsx rename src/navigation/{InvolvementStack.tsx => CommitteesStack.tsx} (68%) delete mode 100644 src/navigation/HomeDrawer.tsx delete mode 100644 src/navigation/MembersStack.tsx create mode 100644 src/navigation/PublicProfileStack.tsx rename src/screens/{involvement => Committees}/Committee.tsx (100%) rename src/screens/{involvement => Committees}/CommitteeCard.tsx (100%) rename src/screens/{involvement => Committees}/CommitteeTeamCard.tsx (100%) create mode 100644 src/screens/Committees/Committees.tsx rename src/screens/{involvement => Committees}/CommitteesList.tsx (100%) rename src/screens/{involvement => Committees}/MemberSHPE.tsx (99%) delete mode 100644 src/screens/involvement/Involvement.tsx diff --git a/src/navigation/AdminDashboardStack.tsx b/src/navigation/AdminDashboardStack.tsx deleted file mode 100644 index b0adacf2..00000000 --- a/src/navigation/AdminDashboardStack.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from "react"; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import { AdminDashboardParams } from '../types/navigation'; -import { HomeStack } from "./HomeStack"; -import RestrictionsEditor from "../screens/admin/RestrictionsEditor"; -import AdminDashboard from "../screens/admin/AdminDashboard"; -import MOTMEditor from "../screens/admin/MOTMEditor"; -import ResumeDownloader from "../screens/admin/ResumeDownloader"; -import MemberSHPEConfirm from "../screens/admin/MemberSHPEConfirm"; -import ResumeConfirm from "../screens/admin/ResumeConfirm"; -import PublicProfileScreen from "../screens/PublicProfile"; -import FeedbackEditor from "../screens/admin/FeedbackEditor"; -import ShirtConfirm from "../screens/admin/ShirtConfirm"; -import CommitteeConfirm from "../screens/admin/CommitteeConfirm"; -import LinkEditor from "../screens/admin/LinkEditor"; -import InstagramPoints from "../screens/admin/InstagramPoints"; - -const AdminDashboardStack = () => { - const Stack = createNativeStackNavigator(); - - return ( - - - - - - - - - - - - - - - - - - ); -} - -export default AdminDashboardStack \ No newline at end of file diff --git a/src/navigation/InvolvementStack.tsx b/src/navigation/CommitteesStack.tsx similarity index 68% rename from src/navigation/InvolvementStack.tsx rename to src/navigation/CommitteesStack.tsx index 4bd2f096..b48f6f06 100644 --- a/src/navigation/InvolvementStack.tsx +++ b/src/navigation/CommitteesStack.tsx @@ -1,17 +1,17 @@ import React from "react"; import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import { InvolvementStackParams } from '../types/navigation'; -import InvolvementScreen from "../screens/involvement/Involvement"; -import CommitteeScreen from "../screens/involvement/Committee"; +import { CommitteesStackParams } from '../types/navigation'; +import CommitteeScreen from "../screens/Committees/Committee"; import PublicProfileScreen from "../screens/PublicProfile"; import CommitteeEditor from "../screens/admin/CommitteeEditor"; import EventInfo from "../screens/events/EventInfo"; +import CommitteesScreen from "../screens/Committees/Committees"; const InvolvementStack = () => { - const Stack = createNativeStackNavigator(); + const Stack = createNativeStackNavigator(); return ( - + diff --git a/src/navigation/HomeBottomTabs.tsx b/src/navigation/HomeBottomTabs.tsx index 99d5411a..9ceb0d2a 100644 --- a/src/navigation/HomeBottomTabs.tsx +++ b/src/navigation/HomeBottomTabs.tsx @@ -1,27 +1,41 @@ import React from 'react'; -import { View, Text } from 'react-native'; +import { View, Text, Image } from 'react-native'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { Octicons } from '@expo/vector-icons'; -import { MembersStack } from './MembersStack'; -import { InvolvementStack } from './InvolvementStack'; +import { PublicProfileStack } from './PublicProfileStack'; +import { InvolvementStack } from './CommitteesStack'; import { ResourcesStack } from './ResourcesStack'; -import HomeDrawer from './HomeDrawer'; import { EventsStack } from './EventsStack'; +import { HomeStack } from './HomeStack'; +import { Images } from '../../assets'; +import { auth } from '../config/firebaseConfig'; const TAB_ICON_CONFIG: Record = { Home: 'home', ResourcesStack: 'rows', Events: "calendar", - Involve: 'stack', - Members: 'person', + Committees: 'stack', + Profile: 'person', }; -const activeIconColor = 'maroon'; +const activeIconColor = 'black'; const inactiveIconColor = 'black'; const iconSize = 28; const generateTabIcon = (routeName: TabName, focused: boolean): JSX.Element => { + if (routeName == 'Profile') { + return ( + + + + ); + } + const iconName = TAB_ICON_CONFIG[routeName] || 'x-circle'; const iconColor = focused ? activeIconColor : inactiveIconColor; let tabName: string = routeName; @@ -31,11 +45,10 @@ const generateTabIcon = (routeName: TabName, focused: boolean): JSX.Element => { return ( - {focused ? tabName : ""} + {focused ? tabName : ""} - ) + ); }; - const HomeBottomTabs = () => { const BottomTabs = createBottomTabNavigator(); @@ -51,16 +64,17 @@ const HomeBottomTabs = () => { tabBarShowLabel: false, })} > - + - - + + ); }; + type OcticonIconName = React.ComponentProps['name']; -type TabName = 'Home' | 'ResourcesStack' | 'Involve' | 'Members' | 'Events'; +type TabName = 'Home' | 'ResourcesStack' | 'Committees' | 'Profile' | 'Events'; export default HomeBottomTabs; diff --git a/src/navigation/HomeDrawer.tsx b/src/navigation/HomeDrawer.tsx deleted file mode 100644 index 54f223c8..00000000 --- a/src/navigation/HomeDrawer.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import { Image, TouchableOpacity, View, Text } from 'react-native'; -import React, { useContext, useEffect, useState } from 'react'; -import { createDrawerNavigator, DrawerContentScrollView, DrawerItem, DrawerContentComponentProps } from '@react-navigation/drawer'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import FontAwesome from '@expo/vector-icons/FontAwesome'; -import { useNavigationState } from '@react-navigation/native'; -import { UserContext } from '../context/UserContext'; -import { auth } from '../config/firebaseConfig'; -import { getBadgeColor, isMemberVerified } from '../helpers/membership'; -import { HomeDrawerParams } from '../types/navigation'; -import { Images } from '../../assets'; -import TwitterSvg from '../components/TwitterSvg'; -import PublicProfileScreen from "../screens/PublicProfile"; -import PersonalEventLogScreen from '../screens/events/PersonalEventLog'; -import { HomeStack } from './HomeStack' - - -/** - * HomeDrawerContent - Component for rendering the drawer in the Home screen. - * @param {DrawerContentComponentProps} props - Props for the component. - */ -const HomeDrawerContent = (props: DrawerContentComponentProps) => { - const { userInfo, signOutUser } = useContext(UserContext)!; - const [isVerified, setIsVerified] = useState(false); - const { nationalExpiration, chapterExpiration, roles } = userInfo?.publicInfo!; - const isOfficer = roles ? roles.officer : false; - let badgeColor = getBadgeColor(isOfficer!, isVerified); - const hasPrivileges = (userInfo?.publicInfo?.roles?.admin?.valueOf() || userInfo?.publicInfo?.roles?.officer?.valueOf() || userInfo?.publicInfo?.roles?.developer?.valueOf()); - - useEffect(() => { - if (nationalExpiration?.toDate && chapterExpiration?.toDate) { - setIsVerified(isMemberVerified(nationalExpiration, chapterExpiration)); - } - }, [nationalExpiration, chapterExpiration]); - - const drawerItemLabelStyle = { - color: userInfo?.private?.privateInfo?.settings?.darkMode ? "#EEE" : "#000" - } - - const DrawerButton = ({ iconName, label, onPress, buttonClassName }: { iconName: FontAwesomeIconName, label: string, onPress: () => void, buttonClassName: string }) => ( - - - - - {label} - - ); - - return ( - - {/* Profile Header */} - - - props.navigation.navigate("PublicProfile", { uid: auth.currentUser?.uid! })} - > - - - - - {userInfo?.publicInfo?.displayName ?? "Name"} - {(isOfficer || isVerified) && } - - {`${userInfo?.publicInfo?.points?.toFixed(2) ?? 0} points`} - - - - - {/* Drawer Items */} - - { - props.navigation.navigate("PublicProfile", { uid: auth.currentUser?.uid }); - props.navigation.closeDrawer(); - }} - /> - - {hasPrivileges && - { - props.navigation.navigate("AdminDashboardStack"); - props.navigation.closeDrawer(); - }} - /> - } - { - props.navigation.navigate("SettingsScreen"); - props.navigation.closeDrawer(); - }} - /> - - signOutUser(true)} - /> - - - ); -}; - -const HomeDrawerHeader = ({ navigation }: { navigation: any }) => { - const insets = useSafeAreaInsets(); - - const currentRouteName = useNavigationState((state) => { - if (state && state.routes && state.index != null) { - const homeStack = state.routes.find(route => route.name === 'HomeStack'); - if (homeStack && homeStack.state && typeof homeStack.state.index === 'number') { - const activeRoute = homeStack.state.routes[homeStack.state.index]; - if (activeRoute) { - return activeRoute.name; - } - } - } - return ''; - }); - - // These are screen in home stack that should not have a header - if (currentRouteName === 'PublicProfile' || currentRouteName === 'EventInfo') { - return null; - } - return ( - - - - - - - navigation.openDrawer()} - > - - - - - ); -} - -const HomeDrawer = () => { - const Drawer = createDrawerNavigator(); - return ( - } - screenOptions={{ - drawerPosition: "right", - }} - > - ({ - headerShown: true, - header: () => - })} - /> - - - - ); -}; - - -type FontAwesomeIconName = React.ComponentProps['name']; - -export default HomeDrawer; diff --git a/src/navigation/HomeStack.tsx b/src/navigation/HomeStack.tsx index 07d4ac93..aa86f50e 100644 --- a/src/navigation/HomeStack.tsx +++ b/src/navigation/HomeStack.tsx @@ -4,6 +4,20 @@ import { HomeStackParams } from "../types/navigation" import PublicProfileScreen from "../screens/PublicProfile"; import Home from "../screens/home/Home" import EventInfo from "../screens/events/EventInfo"; +import AdminDashboard from "../screens/admin/AdminDashboard"; +import MOTMEditor from "../screens/admin/MOTMEditor"; +import ResumeDownloader from "../screens/admin/ResumeDownloader"; +import LinkEditor from "../screens/admin/LinkEditor"; +import RestrictionsEditor from "../screens/admin/RestrictionsEditor"; +import MemberSHPEConfirm from "../screens/admin/MemberSHPEConfirm"; +import ResumeConfirm from "../screens/admin/ResumeConfirm"; +import ShirtConfirm from "../screens/admin/ShirtConfirm"; +import CommitteeConfirm from "../screens/admin/CommitteeConfirm"; +import FeedbackEditor from "../screens/admin/FeedbackEditor"; +import InstagramPoints from "../screens/admin/InstagramPoints"; +import UpdateEvent from "../screens/events/UpdateEvent"; +import QRCodeManager from "../screens/events/QRCodeManager"; +import MemberSHPE from "../screens/Committees/MemberSHPE"; const HomeStack = () => { const Stack = createNativeStackNavigator(); @@ -11,7 +25,27 @@ const HomeStack = () => { + + + {/* Event Screens */} + + + + {/* Admin Dashboard Screens */} + + + + + + + + + + + + + ); }; diff --git a/src/navigation/MainStack.tsx b/src/navigation/MainStack.tsx index 232c3d68..16a1b884 100644 --- a/src/navigation/MainStack.tsx +++ b/src/navigation/MainStack.tsx @@ -3,11 +3,11 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { UserContext } from "../context/UserContext"; import { MainStackParams } from '../types/navigation'; import HomeBottomTabs from "./HomeBottomTabs"; -import AdminDashboardStack from "./AdminDashboardStack"; import EventVerification from "../screens/events/EventVerification"; import { SettingsScreen, ProfileSettingsScreen, DisplaySettingsScreen, AccountSettingsScreen, AboutSettingsScreen, FAQSettingsScreen, FeedBackSettingsScreen } from "../screens/home/Settings"; import PublicProfileScreen from "../screens/PublicProfile"; import QRCodeScanningScreen from "../screens/events/QRCodeScanningScreen"; +import Members from "../screens/Members"; const MainStack = () => { const Stack = createNativeStackNavigator(); @@ -23,32 +23,15 @@ const MainStack = () => { }} > - - {/* Settings Screens */} - - - - - - - - - + + + {/* Settings Screens */} ); diff --git a/src/navigation/MembersStack.tsx b/src/navigation/MembersStack.tsx deleted file mode 100644 index 28c2d479..00000000 --- a/src/navigation/MembersStack.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from "react"; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import PublicProfileScreen from "../screens/PublicProfile"; -import MembersScreen from "../screens/Members"; -import { MembersStackParams } from '../types/navigation'; -import { ProfileSettingsScreen, SettingsScreen } from "../screens/Settings"; - -const MembersStack = () => { - const Stack = createNativeStackNavigator(); - return ( - - - - - ); -}; - -export { MembersStack }; diff --git a/src/navigation/PublicProfileStack.tsx b/src/navigation/PublicProfileStack.tsx new file mode 100644 index 00000000..592d76e5 --- /dev/null +++ b/src/navigation/PublicProfileStack.tsx @@ -0,0 +1,43 @@ +import React, { useContext } from "react"; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import PublicProfileScreen from "../screens/PublicProfile"; +import { AboutSettingsScreen, AccountSettingsScreen, DisplaySettingsScreen, FAQSettingsScreen, FeedBackSettingsScreen, ProfileSettingsScreen, SettingsScreen } from "../screens/home/Settings"; +import { UserContext } from "../context/UserContext"; +import { PublicProfileStackParams } from "../types/navigation"; +import { auth } from "../config/firebaseConfig"; +import PersonalEventLog from "../screens/events/PersonalEventLog"; + +const PublicProfileStack = () => { + const Stack = createNativeStackNavigator(); + const { userInfo } = useContext(UserContext)!; + const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + + return ( + + + + + {/* Settings Screens */} + + + + + + + + + + + ); +}; + +export { PublicProfileStack }; diff --git a/src/screens/involvement/Committee.tsx b/src/screens/Committees/Committee.tsx similarity index 100% rename from src/screens/involvement/Committee.tsx rename to src/screens/Committees/Committee.tsx diff --git a/src/screens/involvement/CommitteeCard.tsx b/src/screens/Committees/CommitteeCard.tsx similarity index 100% rename from src/screens/involvement/CommitteeCard.tsx rename to src/screens/Committees/CommitteeCard.tsx diff --git a/src/screens/involvement/CommitteeTeamCard.tsx b/src/screens/Committees/CommitteeTeamCard.tsx similarity index 100% rename from src/screens/involvement/CommitteeTeamCard.tsx rename to src/screens/Committees/CommitteeTeamCard.tsx diff --git a/src/screens/Committees/Committees.tsx b/src/screens/Committees/Committees.tsx new file mode 100644 index 00000000..3578375a --- /dev/null +++ b/src/screens/Committees/Committees.tsx @@ -0,0 +1,113 @@ +import { View, ScrollView, Text, TouchableOpacity, ActivityIndicator, Image } from 'react-native' +import React, { useCallback, useContext, useEffect, useState } from 'react' +import { useFocusEffect } from '@react-navigation/core' +import { Octicons } from '@expo/vector-icons'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { UserContext } from '../../context/UserContext' +import { auth } from '../../config/firebaseConfig'; +import { getCommittees, getUser } from '../../api/firebaseUtils' +import { CommitteesListProps } from '../../types/navigation' +import { Committee } from "../../types/committees" +import CommitteeCard from './CommitteeCard' +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Images } from '../../../assets'; + + +const Committees: React.FC = ({ navigation }) => { + const [committees, setCommittees] = useState([]); + const [loading, setLoading] = useState(true); + const { userInfo, setUserInfo } = useContext(UserContext)!; + + const isSuperUser = userInfo?.publicInfo?.roles?.admin || userInfo?.publicInfo?.roles?.developer || userInfo?.publicInfo?.roles?.officer + + const fetchCommittees = async () => { + setLoading(true); + const response = await getCommittees(); + setCommittees(response); + setLoading(false); + } + + + const fetchUserData = async () => { + console.log("Fetching user data..."); + try { + const firebaseUser = await getUser(auth.currentUser?.uid!) + await AsyncStorage.setItem("@user", JSON.stringify(firebaseUser)); + setUserInfo(firebaseUser); + } catch (error) { + console.error("Error updating user:", error); + } + } + + useEffect(() => { + fetchCommittees(); + fetchUserData(); + }, []); + + // a refetch for officer for when they update committees + useFocusEffect( + useCallback(() => { + if (isSuperUser) { + fetchCommittees(); + } + return () => { }; + }, [isSuperUser]) + ); + + return ( + + + + {/* Header */} + + + + + + + + + {isSuperUser && ( + + navigation.navigate("CommitteeEditor", { committee: undefined })} + className='flex-row w-[90%] h-28 rounded-xl bg-[#D3D3D3]' + > + + + + + + + + + + Create a Committee + + + + + )} + + {loading && ( + + )} + + + + {!loading && committees.map((committee) => ( + + ))} + + + ) +} + +export default Committees; diff --git a/src/screens/involvement/CommitteesList.tsx b/src/screens/Committees/CommitteesList.tsx similarity index 100% rename from src/screens/involvement/CommitteesList.tsx rename to src/screens/Committees/CommitteesList.tsx diff --git a/src/screens/involvement/MemberSHPE.tsx b/src/screens/Committees/MemberSHPE.tsx similarity index 99% rename from src/screens/involvement/MemberSHPE.tsx rename to src/screens/Committees/MemberSHPE.tsx index aec9b56c..8205b421 100644 --- a/src/screens/involvement/MemberSHPE.tsx +++ b/src/screens/Committees/MemberSHPE.tsx @@ -14,6 +14,7 @@ import DismissibleModal from '../../components/DismissibleModal'; import { Pressable } from 'react-native'; import { LinkData } from '../../types/links'; import { fetchLink } from '../../api/firebaseUtils'; +import { SafeAreaView } from 'react-native-safe-area-context'; const linkIDs = ["6", "7"]; // ids reserved for TAMU and SHPE National links @@ -155,7 +156,7 @@ const MemberSHPE = () => { return ( {/* Not Verified Member */} - + {!isVerified && ( @@ -216,7 +217,7 @@ const MemberSHPE = () => { )} - + {/* TAMU Chapter Instructions */} diff --git a/src/screens/PublicProfile.tsx b/src/screens/PublicProfile.tsx index 352a7629..933f1066 100644 --- a/src/screens/PublicProfile.tsx +++ b/src/screens/PublicProfile.tsx @@ -12,7 +12,7 @@ import { auth } from '../config/firebaseConfig'; import { getCommittees, getPublicUserData, getUser, queryUserEventLogs, setUserRoles } from '../api/firebaseUtils'; import { getBadgeColor, isMemberVerified } from '../helpers/membership'; import { handleLinkPress } from '../helpers/links'; -import { HomeDrawerParams, MembersScreenRouteProp } from '../types/navigation'; +import { MembersScreenRouteProp, PublicProfileStackParams } from '../types/navigation'; import { PublicUserInfo, Roles } from '../types/user'; import { Committee } from '../types/committees'; import { Images } from '../../assets'; @@ -24,7 +24,7 @@ import { Timestamp } from 'firebase/firestore'; -const PublicProfileScreen = ({ navigation }: NativeStackScreenProps) => { +const PublicProfileScreen = ({ navigation }: NativeStackScreenProps) => { // Data related to public profile user const route = useRoute(); const { uid } = route.params; @@ -224,7 +224,7 @@ const PublicProfileScreen = ({ navigation }: NativeStackScreenProps {isCurrentUser && navigation.navigate("ProfileSettingsScreen")} + onPress={() => navigation.navigate("SettingsScreen")} className="rounded-md px-3 py-2" style={{ backgroundColor: 'rgba(0,0,0,0.3)' }} > @@ -307,7 +307,7 @@ const PublicProfileScreen = ({ navigation }: NativeStackScreenProps 0 && ( - Involvement + Committees {committees?.map((committeeName, index) => { const committeeData = committeesData.find(c => c.firebaseDocName === committeeName); diff --git a/src/screens/admin/CommitteeEditor.tsx b/src/screens/admin/CommitteeEditor.tsx index 784d0c35..d15369b0 100644 --- a/src/screens/admin/CommitteeEditor.tsx +++ b/src/screens/admin/CommitteeEditor.tsx @@ -9,7 +9,7 @@ import { PublicUserInfo } from '../../types/user'; import MembersList from '../../components/MembersList'; import CustomColorPicker from '../../components/CustomColorPicker'; import DismissibleModal from '../../components/DismissibleModal'; -import CommitteeTeamCard from '../involvement/CommitteeTeamCard'; +import CommitteeTeamCard from '../Committees/CommitteeTeamCard'; const CommitteeEditor = ({ navigation, route }: CommitteeEditorProps) => { const committeeData = route?.params?.committee; diff --git a/src/screens/events/EventInfo.tsx b/src/screens/events/EventInfo.tsx index 1bc7bcf1..3983c5a0 100644 --- a/src/screens/events/EventInfo.tsx +++ b/src/screens/events/EventInfo.tsx @@ -21,7 +21,7 @@ import { PublicUserInfo } from '../../types/user'; import { reverseFormattedFirebaseName } from '../../types/committees'; import MembersList from '../../components/MembersList'; -const EventInfo = ({ navigation }: EventProps) => { +const EventInfo = ({ navigation }: any) => { const route = useRoute(); const { eventId } = route.params; const { userInfo } = useContext(UserContext)!; diff --git a/src/screens/events/PersonalEventLog.tsx b/src/screens/events/PersonalEventLog.tsx index 4ffaebb0..18c7860e 100644 --- a/src/screens/events/PersonalEventLog.tsx +++ b/src/screens/events/PersonalEventLog.tsx @@ -8,9 +8,9 @@ import { UserContext } from '../../context/UserContext'; import { auth } from '../../config/firebaseConfig'; import { queryUserEventLogs } from '../../api/firebaseUtils'; import { UserEventData } from '../../types/events'; -import { HomeDrawerParams } from '../../types/navigation'; +import { PublicProfileStackParams } from '../../types/navigation'; -const PersonalEventLog = ({ navigation }: NativeStackScreenProps) => { +const PersonalEventLog = ({ navigation }: NativeStackScreenProps) => { const userContext = useContext(UserContext); const { userInfo } = userContext!; diff --git a/src/screens/events/UpdateEvent.tsx b/src/screens/events/UpdateEvent.tsx index e7f850a1..4e19ca2f 100644 --- a/src/screens/events/UpdateEvent.tsx +++ b/src/screens/events/UpdateEvent.tsx @@ -258,7 +258,7 @@ const UpdateEvent = ({ navigation }: EventProps) => { navigation.navigate("EventsScreen")} + onPress={() => navigation.goBack()} className="rounded-full w-10 h-10 justify-center items-center" style={{ backgroundColor: 'rgba(0,0,0,0.3)' }} > diff --git a/src/screens/home/Home.tsx b/src/screens/home/Home.tsx index 7f2ae55f..f13a88a5 100644 --- a/src/screens/home/Home.tsx +++ b/src/screens/home/Home.tsx @@ -1,5 +1,5 @@ -import { ScrollView, View } from 'react-native'; -import React, { useEffect } from 'react'; +import { Image, ScrollView, Text, TouchableOpacity, View } from 'react-native'; +import React, { useContext, useEffect } from 'react'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { StatusBar } from 'expo-status-bar'; import manageNotificationPermissions from '../../helpers/pushNotification'; @@ -7,6 +7,9 @@ import { HomeStackParams } from "../../types/navigation" import MOTMCard from '../../components/MOTMCard'; import FlickrPhotoGallery from '../../components/FlickrPhotoGallery'; import Ishpe from './Ishpe'; +import { Images } from '../../../assets'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { UserContext } from '../../context/UserContext'; /** * Renders the home screen of the application. @@ -16,6 +19,8 @@ import Ishpe from './Ishpe'; * @returns The rendered home screen. */ const Home = ({ navigation, route }: NativeStackScreenProps) => { + const { userInfo, setUserInfo } = useContext(UserContext)!; + const isSuperUser = userInfo?.publicInfo?.roles?.admin || userInfo?.publicInfo?.roles?.developer || userInfo?.publicInfo?.roles?.officer useEffect(() => { manageNotificationPermissions(); @@ -24,7 +29,36 @@ const Home = ({ navigation, route }: NativeStackScreenProps) => return ( + {/* Header */} + + + + + + + + + + + {isSuperUser && ( + navigation.navigate("AdminDashboard")} + > + Admin Dashboard + + )} + + navigation.navigate("MemberSHPE")} + > + MemberSHPE Screen + {/* */} diff --git a/src/screens/home/Settings.tsx b/src/screens/home/Settings.tsx index d0341e52..4c305413 100644 --- a/src/screens/home/Settings.tsx +++ b/src/screens/home/Settings.tsx @@ -14,7 +14,7 @@ import { getBlobFromURI, selectFile, selectImage, uploadFile } from '../../api/f import { CommonMimeTypes, validateDisplayName, validateFileBlob, validateName, validateTamuEmail } from '../../helpers/validation'; import { handleLinkPress } from '../../helpers/links'; import { getBadgeColor, isMemberVerified } from '../../helpers/membership'; -import { MainStackParams } from '../../types/navigation'; +import { MainStackParams, PublicProfileStackParams } from '../../types/navigation'; import { Committee } from '../../types/committees'; import { MAJORS, classYears } from '../../types/user'; import { Images } from '../../../assets'; @@ -30,8 +30,8 @@ import * as Clipboard from 'expo-clipboard'; /** * Settings entrance screen which has a search function and paths to every other settings screen */ -const SettingsScreen = ({ navigation }: NativeStackScreenProps) => { - const { userInfo } = useContext(UserContext)!; +const SettingsScreen = ({ navigation }: NativeStackScreenProps) => { + const { userInfo, signOutUser } = useContext(UserContext)!; const { name, roles, photoURL, chapterExpiration, nationalExpiration } = userInfo?.publicInfo ?? {}; const isOfficer = roles ? roles.officer : false; @@ -115,6 +115,12 @@ const SettingsScreen = ({ navigation }: NativeStackScreenProps) darkMode={darkMode} onPress={() => navigation.navigate("AboutSettingsScreen")} /> + + signOutUser(true)} + /> ) } diff --git a/src/screens/involvement/Involvement.tsx b/src/screens/involvement/Involvement.tsx deleted file mode 100644 index 6636e9b8..00000000 --- a/src/screens/involvement/Involvement.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React, { useState, ReactElement } from 'react'; -import { View, Text, TouchableOpacity } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { InvolvementScreenProps } from '../../types/navigation'; -import CommitteesList from './CommitteesList'; -import MemberSHPE from './MemberSHPE'; - - -const Involvement: React.FC = ({ navigation }) => { - const [currentTab, setCurrentTab] = useState("Committees"); - const tabComponents: Record = { - Committees: , - MemberSHPE: , - }; - - return ( - - - - {Object.entries(TABS).map(([key, label]) => ( - setCurrentTab(key as keyof typeof TABS)} - /> - ))} - - - - {/* Content */} - - {tabComponents[currentTab]} - - - ) -} - -const NavigationTab: React.FC = ({ label, isActive, onPress }) => ( - - {label} - -); - -interface NavigationTabProps { - label: string; - isActive: boolean; - onPress: () => void; -} - -const TABS = { - Committees: 'Committees', - MemberSHPE: 'MemberSHPE', -}; - -export default Involvement; diff --git a/src/types/navigation.ts b/src/types/navigation.ts index 77b6203e..3e76b9a3 100644 --- a/src/types/navigation.ts +++ b/src/types/navigation.ts @@ -11,24 +11,14 @@ import { SHPEEvent } from "./events"; // Stacks export type MainStackParams = { - HomeDrawer: HomeDrawerParams; HomeBottomTabs: undefined; - AdminDashboardStack: undefined; - SettingsScreen: undefined; - ProfileSettingsScreen: undefined; - DisplaySettingsScreen: undefined; - AccountSettingsScreen: undefined; - FeedbackSettingsScreen: undefined; - FAQSettingsScreen: undefined; - AboutSettingsScreen: undefined; QRCodeScanningScreen: undefined; EventVerificationScreen: { id: string; mode: "sign-in" | "sign-out"; }; - PublicProfile: { - uid: string; - }; + PublicProfile: { uid: string; }; + Members: MembersProps; }; export type AuthStackParams = { @@ -42,11 +32,20 @@ export type AuthStackParams = { GuestRecoveryAccount: undefined; }; -export type MembersStackParams = { - MembersScreen: undefined; - PublicProfile: { - uid: string; - } +export type PublicProfileStackParams = { + PublicProfile: { uid: string; } + SettingsScreen: undefined; + PersonalEventLogScreen: undefined; + + // Settings Screens + ProfileSettingsScreen: undefined; + DisplaySettingsScreen: undefined; + AccountSettingsScreen: undefined; + FeedbackSettingsScreen: undefined; + FAQSettingsScreen: undefined; + AboutSettingsScreen: undefined; + + }; export type ProfileSetupStackParams = { @@ -70,8 +69,8 @@ export type ResourcesStackParams = { } -export type InvolvementStackParams = { - InvolvementScreen: undefined; +export type CommitteesStackParams = { + CommitteesScreen: undefined; CommitteeScreen: { committee: Committee; }; @@ -101,13 +100,10 @@ export type EventsStackParams = { export type HomeStackParams = { Home: undefined; - PublicProfile: { - uid: string; - } - EventInfo: { eventId: string }; -} + PublicProfile: { uid: string; }; + MemberSHPE: undefined; -export type AdminDashboardParams = { + // Admin Dashboard Screens AdminDashboard: undefined; MOTMEditor: undefined; ResumeDownloader: undefined; @@ -118,26 +114,14 @@ export type AdminDashboardParams = { CommitteeConfirm: undefined; ResumeConfirm: undefined; ShirtConfirm: undefined; - Home: undefined; - PublicProfile: { - uid: string; - } InstagramPoints: undefined; -} -// Drawers -export type HomeDrawerParams = { - HomeStack: HomeStackParams; - Logout: undefined; - AdminDashboardStack: undefined; - PublicProfile: { - uid: string; - } - - ProfileSettingsScreen: undefined; - PersonalEventLogScreen: undefined; -}; + // Event Screens + EventInfo: { eventId: string }; + UpdateEvent: { event: SHPEEvent }; + QRCode: { event: SHPEEvent }; +} // Bottom Tabs export type HomeBottomTabParams = { @@ -172,7 +156,7 @@ export type TestBankProps = { export type MembersProps = { userData?: PublicUserInfo handleCardPress?: (uid: string) => string | void; - navigation?: NativeStackNavigationProp + navigation?: NativeStackNavigationProp officersList?: PublicUserInfo[] membersList?: PublicUserInfo[] loadMoreUsers?: () => void; @@ -190,7 +174,7 @@ export type MembersProps = { export type MemberListProps = { handleCardPress: (uid: string) => string | void; users: PublicUserInfo[]; - navigation?: NativeStackNavigationProp + navigation?: NativeStackNavigationProp } interface SelectedPublicUserInfo extends PublicUserInfo { @@ -222,7 +206,7 @@ export type EventProps = { export type CommitteeTeamCardProps = { userData: PublicUserInfo; - navigation?: NativeStackNavigationProp + navigation?: NativeStackNavigationProp } @@ -238,23 +222,23 @@ export type QRCodeProps = { } export type CommitteeEditorProps = { - route: RouteProp; - navigation: NativeStackNavigationProp; + route: RouteProp; + navigation: NativeStackNavigationProp; }; -export type SettingsProps = NativeStackScreenProps; +export type SettingsProps = NativeStackScreenProps; // routes prop for screens -export type SettingsScreenRouteProp = RouteProp; -export type MembersScreenRouteProp = RouteProp; -export type CommitteeScreenRouteProp = RouteProp; +export type SettingsScreenRouteProp = RouteProp; +export type MembersScreenRouteProp = RouteProp; +export type CommitteeScreenRouteProp = RouteProp; export type UpdateEventScreenRouteProp = RouteProp; export type SHPEEventScreenRouteProp = RouteProp; export type EventVerificationScreenRouteProp = RouteProp; export type QRCodeScreenRouteProp = RouteProp; -export type CommitteeEditorRouteProp = RouteProp; -export type CommitteeEditorNavigationProp = NativeStackNavigationProp; -export type CommitteeScreenProps = NativeStackScreenProps; -export type CommitteesListProps = { navigation: NativeStackNavigationProp; }; -export type InvolvementScreenProps = NativeStackScreenProps; +export type CommitteeEditorRouteProp = RouteProp; +export type CommitteeEditorNavigationProp = NativeStackNavigationProp; +export type CommitteeScreenProps = NativeStackScreenProps; +export type CommitteesListProps = { navigation: NativeStackNavigationProp; }; +export type CommitteesScreenScreenProps = NativeStackScreenProps; From 8d79834c3ae9c7f32067e98d2a1e46e9cd465df9 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 2 Jun 2024 17:37:16 -0500 Subject: [PATCH 005/198] add members screen to home screen and rmeove go back in public profile for current user --- src/navigation/HomeStack.tsx | 3 +++ src/navigation/MainStack.tsx | 14 ++------------ src/screens/Members.tsx | 4 ++-- src/screens/PublicProfile.tsx | 19 ++++++++++++------- src/screens/home/Home.tsx | 7 +++++++ src/types/navigation.ts | 7 +++---- 6 files changed, 29 insertions(+), 25 deletions(-) diff --git a/src/navigation/HomeStack.tsx b/src/navigation/HomeStack.tsx index aa86f50e..c508a5c4 100644 --- a/src/navigation/HomeStack.tsx +++ b/src/navigation/HomeStack.tsx @@ -18,6 +18,7 @@ import InstagramPoints from "../screens/admin/InstagramPoints"; import UpdateEvent from "../screens/events/UpdateEvent"; import QRCodeManager from "../screens/events/QRCodeManager"; import MemberSHPE from "../screens/Committees/MemberSHPE"; +import Members from "../screens/Members"; const HomeStack = () => { const Stack = createNativeStackNavigator(); @@ -26,6 +27,8 @@ const HomeStack = () => { + + {/* Event Screens */} diff --git a/src/navigation/MainStack.tsx b/src/navigation/MainStack.tsx index 16a1b884..29c030b6 100644 --- a/src/navigation/MainStack.tsx +++ b/src/navigation/MainStack.tsx @@ -17,22 +17,12 @@ const MainStack = () => { return ( {/* Main components */} - - + + - - - - - {/* Settings Screens */} - ); }; diff --git a/src/screens/Members.tsx b/src/screens/Members.tsx index 3a7efc57..3035276c 100644 --- a/src/screens/Members.tsx +++ b/src/screens/Members.tsx @@ -1,7 +1,6 @@ import { View, Text, ScrollView, TouchableOpacity, TextInput, ActivityIndicator, NativeScrollEvent } from 'react-native' import React, { useEffect, useRef, useState } from 'react' import { Octicons } from '@expo/vector-icons'; -import { MembersStackParams } from '../types/navigation' import MemberCard from '../components/MemberCard' import { MAJORS, PublicUserInfo, UserFilter, classYears } from '../types/user'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -9,8 +8,9 @@ import { getOfficers, getUserForMemberList } from '../api/firebaseUtils'; import { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; import CustomDropDownMenu, { CustomDropDownMethods } from '../components/CustomDropDown'; +import { HomeStackParams } from '../types/navigation'; -const Members = ({ navigation }: NativeStackScreenProps) => { +const Members = ({ navigation }: NativeStackScreenProps) => { const [showFilterMenu, setShowFilterMenu] = useState(false); const [filter, setFilter] = useState({ major: "", classYear: "", role: "" }); const [officers, setOfficers] = useState([]); diff --git a/src/screens/PublicProfile.tsx b/src/screens/PublicProfile.tsx index 933f1066..068fbc6f 100644 --- a/src/screens/PublicProfile.tsx +++ b/src/screens/PublicProfile.tsx @@ -214,13 +214,18 @@ const PublicProfileScreen = ({ navigation }: NativeStackScreenProps - navigation.goBack()} - className="rounded-full w-10 h-10 justify-center items-center" - style={{ backgroundColor: 'rgba(0,0,0,0.3)' }} - > - - + {!isCurrentUser ? + navigation.goBack()} + className="rounded-full w-10 h-10 justify-center items-center" + style={{ backgroundColor: 'rgba(0,0,0,0.3)' }} + > + + + : + + } + {isCurrentUser && ) => > MemberSHPE Screen + + navigation.navigate("Members")} + > + Member + {/* */} diff --git a/src/types/navigation.ts b/src/types/navigation.ts index 3e76b9a3..59ab67bf 100644 --- a/src/types/navigation.ts +++ b/src/types/navigation.ts @@ -9,16 +9,14 @@ import { Committee } from "./committees"; import { PublicUserInfo, UserFilter } from "./user"; import { SHPEEvent } from "./events"; -// Stacks + export type MainStackParams = { HomeBottomTabs: undefined; - QRCodeScanningScreen: undefined; EventVerificationScreen: { id: string; mode: "sign-in" | "sign-out"; }; - PublicProfile: { uid: string; }; - Members: MembersProps; + }; export type AuthStackParams = { @@ -102,6 +100,7 @@ export type HomeStackParams = { Home: undefined; PublicProfile: { uid: string; }; MemberSHPE: undefined; + Members: undefined; // Admin Dashboard Screens AdminDashboard: undefined; From 510f875a223ec640b16638bbcc66d21f99a30048 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 2 Jun 2024 18:31:07 -0500 Subject: [PATCH 006/198] clean navigation files --- src/components/MemberCardMultipleSelect.tsx | 11 +- src/components/MembersList.tsx | 9 +- src/navigation/CommitteesStack.tsx | 14 +- src/navigation/EventsStack.tsx | 7 + src/navigation/HomeBottomTabs.tsx | 4 +- src/navigation/HomeStack.tsx | 4 +- src/navigation/MainStack.tsx | 6 +- src/screens/Committees/Committee.tsx | 9 +- src/screens/Committees/CommitteeTeamCard.tsx | 9 +- src/screens/Committees/Committees.tsx | 5 +- src/screens/Committees/CommitteesList.tsx | 99 -------- src/screens/PublicProfile.tsx | 14 +- src/screens/admin/CommitteeEditor.tsx | 10 +- src/screens/events/EventInfo.tsx | 18 +- src/screens/events/EventVerification.tsx | 15 +- src/screens/events/QRCodeManager.tsx | 14 +- src/screens/home/Ishpe.tsx | 8 +- src/screens/home/Settings.tsx | 14 +- src/screens/resources/RankCard.tsx | 10 +- src/screens/resources/ResumeCard.tsx | 11 +- src/screens/resources/TestCard.tsx | 8 +- src/types/navigation.ts | 229 +++++-------------- 22 files changed, 209 insertions(+), 319 deletions(-) delete mode 100644 src/screens/Committees/CommitteesList.tsx diff --git a/src/components/MemberCardMultipleSelect.tsx b/src/components/MemberCardMultipleSelect.tsx index 586f436a..7e20897a 100644 --- a/src/components/MemberCardMultipleSelect.tsx +++ b/src/components/MemberCardMultipleSelect.tsx @@ -1,9 +1,9 @@ import { Image, Text, TouchableOpacity, View } from 'react-native' import React, { useEffect, useState } from 'react' -import { MemberCardMultipleSelectProp, MemberCardProp } from '../types/navigation' import { Images } from '../../assets' import TwitterSvg from './TwitterSvg' import { getBadgeColor, isMemberVerified } from '../helpers/membership' +import { PublicUserInfo } from '../types/user' const MemberCardMultipleSelect: React.FC = ({ userData, handleCardPress }) => { if (!userData) { return } @@ -52,4 +52,13 @@ const MemberCardMultipleSelect: React.FC = ({ user ) } +interface SelectedPublicUserInfo extends PublicUserInfo { + selected?: boolean; +} + +export type MemberCardMultipleSelectProp = { + handleCardPress?: (uid: string | void) => void; + userData?: SelectedPublicUserInfo; +} + export default MemberCardMultipleSelect \ No newline at end of file diff --git a/src/components/MembersList.tsx b/src/components/MembersList.tsx index 1c4d25c2..a0c863c9 100644 --- a/src/components/MembersList.tsx +++ b/src/components/MembersList.tsx @@ -1,10 +1,11 @@ import { View, Text, ScrollView, TouchableOpacity, TextInput } from 'react-native' import React, { useEffect, useRef, useState } from 'react' import { Octicons } from '@expo/vector-icons'; -import { MemberListProps } from '../types/navigation' +import { HomeStackParams } from '../types/navigation' import MemberCard from './MemberCard' import { MAJORS, PublicUserInfo, UserFilter, classYears } from '../types/user'; import CustomDropDownMenu, { CustomDropDownMethods } from './CustomDropDown'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; const MembersList: React.FC = ({ handleCardPress, users, navigation }) => { const [search, setSearch] = useState("") @@ -180,5 +181,11 @@ const ROLESDROPDOWN = [ { role: 'Lead', iso: 'lead' }, ]; +export type MemberListProps = { + handleCardPress: (uid: string) => string | void; + users: PublicUserInfo[]; + navigation?: NativeStackNavigationProp +} + export default MembersList \ No newline at end of file diff --git a/src/navigation/CommitteesStack.tsx b/src/navigation/CommitteesStack.tsx index b48f6f06..42e75b5a 100644 --- a/src/navigation/CommitteesStack.tsx +++ b/src/navigation/CommitteesStack.tsx @@ -6,18 +6,26 @@ import PublicProfileScreen from "../screens/PublicProfile"; import CommitteeEditor from "../screens/admin/CommitteeEditor"; import EventInfo from "../screens/events/EventInfo"; import CommitteesScreen from "../screens/Committees/Committees"; +import UpdateEvent from "../screens/events/UpdateEvent"; +import QRCodeManager from "../screens/events/QRCodeManager"; -const InvolvementStack = () => { +const CommitteesStack = () => { const Stack = createNativeStackNavigator(); return ( - + + + {/* Event Screens */} + + + + ); } -export { InvolvementStack } \ No newline at end of file +export { CommitteesStack } \ No newline at end of file diff --git a/src/navigation/EventsStack.tsx b/src/navigation/EventsStack.tsx index f748446f..76d92b9d 100644 --- a/src/navigation/EventsStack.tsx +++ b/src/navigation/EventsStack.tsx @@ -10,6 +10,9 @@ import SetGeneralEventDetails from "../screens/events/SetGeneralEventDetails"; import SetSpecificEventDetails from "../screens/events/SetSpecificEventDetails"; import FinalizeEvent from "../screens/events/FinalizeEvent"; import SetLocationEventDetails from "../screens/events/SetLocationEventDetails"; +import QRCodeScanningScreen from "../screens/events/QRCodeScanningScreen"; +import EventVerification from "../screens/events/EventVerification"; +import PublicProfileScreen from "../screens/PublicProfile"; const EventsStack = () => { const Stack = createNativeStackNavigator(); @@ -19,6 +22,7 @@ const EventsStack = () => { + {/* Event Creation Screens */} @@ -27,7 +31,10 @@ const EventsStack = () => { + + + ); }; diff --git a/src/navigation/HomeBottomTabs.tsx b/src/navigation/HomeBottomTabs.tsx index 9ceb0d2a..a8b2ed85 100644 --- a/src/navigation/HomeBottomTabs.tsx +++ b/src/navigation/HomeBottomTabs.tsx @@ -3,7 +3,7 @@ import { View, Text, Image } from 'react-native'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { Octicons } from '@expo/vector-icons'; import { PublicProfileStack } from './PublicProfileStack'; -import { InvolvementStack } from './CommitteesStack'; +import { CommitteesStack } from './CommitteesStack'; import { ResourcesStack } from './ResourcesStack'; import { EventsStack } from './EventsStack'; import { HomeStack } from './HomeStack'; @@ -67,7 +67,7 @@ const HomeBottomTabs = () => { - + diff --git a/src/navigation/HomeStack.tsx b/src/navigation/HomeStack.tsx index c508a5c4..2eb1f291 100644 --- a/src/navigation/HomeStack.tsx +++ b/src/navigation/HomeStack.tsx @@ -25,11 +25,9 @@ const HomeStack = () => { return ( - - {/* Event Screens */} @@ -49,6 +47,8 @@ const HomeStack = () => { + + ); }; diff --git a/src/navigation/MainStack.tsx b/src/navigation/MainStack.tsx index 29c030b6..84c98099 100644 --- a/src/navigation/MainStack.tsx +++ b/src/navigation/MainStack.tsx @@ -4,15 +4,10 @@ import { UserContext } from "../context/UserContext"; import { MainStackParams } from '../types/navigation'; import HomeBottomTabs from "./HomeBottomTabs"; import EventVerification from "../screens/events/EventVerification"; -import { SettingsScreen, ProfileSettingsScreen, DisplaySettingsScreen, AccountSettingsScreen, AboutSettingsScreen, FAQSettingsScreen, FeedBackSettingsScreen } from "../screens/home/Settings"; -import PublicProfileScreen from "../screens/PublicProfile"; import QRCodeScanningScreen from "../screens/events/QRCodeScanningScreen"; -import Members from "../screens/Members"; const MainStack = () => { const Stack = createNativeStackNavigator(); - const { userInfo } = useContext(UserContext)!; - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; return ( @@ -23,6 +18,7 @@ const MainStack = () => { + ); }; diff --git a/src/screens/Committees/Committee.tsx b/src/screens/Committees/Committee.tsx index a0219346..d17fb509 100644 --- a/src/screens/Committees/Committee.tsx +++ b/src/screens/Committees/Committee.tsx @@ -9,7 +9,6 @@ import { UserContext } from '../../context/UserContext'; import { getCommitteeEvents, getPublicUserData, setPublicUserData } from '../../api/firebaseUtils'; import { calculateHexLuminosity } from '../../helpers/colorUtils'; import { handleLinkPress } from '../../helpers/links'; -import { CommitteeScreenProps } from '../../types/navigation'; import { getLogoComponent } from '../../types/committees'; import { SHPEEvent } from '../../types/events'; import { PublicUserInfo } from '../../types/user'; @@ -18,6 +17,10 @@ import { auth, db } from '../../config/firebaseConfig'; import EventsList from '../../components/EventsList'; import MembersList from '../../components/MembersList'; import CommitteeTeamCard from './CommitteeTeamCard'; +import { RouteProp } from '@react-navigation/core'; +import { CommitteesStackParams } from '../../types/navigation'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; + const Committee: React.FC = ({ route, navigation }) => { const initialCommittee = route.params.committee; @@ -446,5 +449,9 @@ interface TeamMembersState { head: PublicUserInfo | null | undefined; } +export type CommitteeScreenProps = { + route: RouteProp; + navigation: NativeStackNavigationProp; +}; export default Committee \ No newline at end of file diff --git a/src/screens/Committees/CommitteeTeamCard.tsx b/src/screens/Committees/CommitteeTeamCard.tsx index e0114cc4..f9180bc1 100644 --- a/src/screens/Committees/CommitteeTeamCard.tsx +++ b/src/screens/Committees/CommitteeTeamCard.tsx @@ -1,9 +1,11 @@ import { Image, Text, TouchableOpacity, View } from 'react-native' import React, { useEffect, useState } from 'react' -import { CommitteeTeamCardProps } from '../../types/navigation' +import { CommitteesStackParams } from '../../types/navigation' import { getBadgeColor, isMemberVerified } from '../../helpers/membership' import { Images } from '../../../assets' import TwitterSvg from '../../components/TwitterSvg' +import { NativeStackNavigationProp } from '@react-navigation/native-stack' +import { PublicUserInfo } from '../../types/user' const CommitteeTeamCard: React.FC = ({ userData, navigation }) => { if (!userData || Object.keys(userData).length === 0) { @@ -53,4 +55,9 @@ const CommitteeTeamCard: React.FC = ({ userData, navigat ) } +export type CommitteeTeamCardProps = { + userData: PublicUserInfo; + navigation?: NativeStackNavigationProp +} + export default CommitteeTeamCard \ No newline at end of file diff --git a/src/screens/Committees/Committees.tsx b/src/screens/Committees/Committees.tsx index 3578375a..7f745cdc 100644 --- a/src/screens/Committees/Committees.tsx +++ b/src/screens/Committees/Committees.tsx @@ -6,14 +6,15 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { UserContext } from '../../context/UserContext' import { auth } from '../../config/firebaseConfig'; import { getCommittees, getUser } from '../../api/firebaseUtils' -import { CommitteesListProps } from '../../types/navigation' import { Committee } from "../../types/committees" import CommitteeCard from './CommitteeCard' import { SafeAreaView } from 'react-native-safe-area-context'; import { Images } from '../../../assets'; +import { CommitteesStackParams } from '../../types/navigation'; +import { NativeStackScreenProps } from '@react-navigation/native-stack'; -const Committees: React.FC = ({ navigation }) => { +const Committees = ({ navigation }: NativeStackScreenProps) => { const [committees, setCommittees] = useState([]); const [loading, setLoading] = useState(true); const { userInfo, setUserInfo } = useContext(UserContext)!; diff --git a/src/screens/Committees/CommitteesList.tsx b/src/screens/Committees/CommitteesList.tsx deleted file mode 100644 index 2e4fc0ac..00000000 --- a/src/screens/Committees/CommitteesList.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { View, ScrollView, Text, TouchableOpacity, ActivityIndicator } from 'react-native' -import React, { useCallback, useContext, useEffect, useState } from 'react' -import { useFocusEffect } from '@react-navigation/core' -import { Octicons } from '@expo/vector-icons'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { UserContext } from '../../context/UserContext' -import { auth } from '../../config/firebaseConfig'; -import { getCommittees, getUser } from '../../api/firebaseUtils' -import { CommitteesListProps } from '../../types/navigation' -import { Committee } from "../../types/committees" -import CommitteeCard from './CommitteeCard' - -const CommitteesList: React.FC = ({ navigation }) => { - const [committees, setCommittees] = useState([]); - const [loading, setLoading] = useState(true); - const { userInfo, setUserInfo } = useContext(UserContext)!; - - const isSuperUser = userInfo?.publicInfo?.roles?.admin || userInfo?.publicInfo?.roles?.developer || userInfo?.publicInfo?.roles?.officer - - const fetchCommittees = async () => { - setLoading(true); - const response = await getCommittees(); - setCommittees(response); - setLoading(false); - } - - - const fetchUserData = async () => { - console.log("Fetching user data..."); - try { - const firebaseUser = await getUser(auth.currentUser?.uid!) - await AsyncStorage.setItem("@user", JSON.stringify(firebaseUser)); - setUserInfo(firebaseUser); - } catch (error) { - console.error("Error updating user:", error); - } - } - - useEffect(() => { - fetchCommittees(); - fetchUserData(); - }, []); - - // a refetch for officer for when they update committees - useFocusEffect( - useCallback(() => { - if (isSuperUser) { - fetchCommittees(); - } - return () => { }; - }, [isSuperUser]) - ); - - return ( - - - {isSuperUser && ( - - navigation.navigate("CommitteeEditor", { committee: undefined })} - className='flex-row w-[90%] h-28 rounded-xl bg-[#D3D3D3]' - > - - - - - - - - - - Create a Committee - - - - - )} - - {loading && ( - - )} - - - - {!loading && committees.map((committee) => ( - - ))} - - - ) -} - - - -export default CommitteesList \ No newline at end of file diff --git a/src/screens/PublicProfile.tsx b/src/screens/PublicProfile.tsx index 068fbc6f..14a4d065 100644 --- a/src/screens/PublicProfile.tsx +++ b/src/screens/PublicProfile.tsx @@ -1,7 +1,7 @@ import { View, Text, ActivityIndicator, Image, Alert, TouchableOpacity, Pressable, TextInput, ScrollView, RefreshControl } from 'react-native'; import React, { useState, useEffect, useContext, useCallback } from 'react'; -import { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { useFocusEffect } from '@react-navigation/core'; +import { NativeStackNavigationProp, NativeStackScreenProps } from '@react-navigation/native-stack'; +import { RouteProp, useFocusEffect } from '@react-navigation/core'; import { useRoute } from '@react-navigation/native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Octicons, FontAwesome } from '@expo/vector-icons'; @@ -12,7 +12,7 @@ import { auth } from '../config/firebaseConfig'; import { getCommittees, getPublicUserData, getUser, queryUserEventLogs, setUserRoles } from '../api/firebaseUtils'; import { getBadgeColor, isMemberVerified } from '../helpers/membership'; import { handleLinkPress } from '../helpers/links'; -import { MembersScreenRouteProp, PublicProfileStackParams } from '../types/navigation'; +import { PublicProfileStackParams } from '../types/navigation'; import { PublicUserInfo, Roles } from '../types/user'; import { Committee } from '../types/committees'; import { Images } from '../../assets'; @@ -22,11 +22,13 @@ import DismissibleModal from '../components/DismissibleModal'; import { UserEventData } from '../types/events'; import { Timestamp } from 'firebase/firestore'; +export type PublicProfileScreenProps = { + route: RouteProp; + navigation: NativeStackNavigationProp; +}; - -const PublicProfileScreen = ({ navigation }: NativeStackScreenProps) => { +const PublicProfileScreen: React.FC = ({ route, navigation }) => { // Data related to public profile user - const route = useRoute(); const { uid } = route.params; const [publicUserData, setPublicUserData] = useState(); const { nationalExpiration, chapterExpiration, roles, photoURL, name, major, classYear, bio, points, resumeVerified, resumePublicURL, email, isStudent, committees, pointsRank, isEmailPublic } = publicUserData || {}; diff --git a/src/screens/admin/CommitteeEditor.tsx b/src/screens/admin/CommitteeEditor.tsx index d15369b0..f4f9028a 100644 --- a/src/screens/admin/CommitteeEditor.tsx +++ b/src/screens/admin/CommitteeEditor.tsx @@ -4,12 +4,14 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { Octicons, FontAwesome } from '@expo/vector-icons'; import { deleteCommittee, getLeads, getPublicUserData, getRepresentatives, getTeamMembers, resetCommittee, setCommitteeData } from '../../api/firebaseUtils'; import { Committee, committeeLogos, getLogoComponent } from '../../types/committees'; -import { CommitteeEditorProps } from '../../types/navigation'; import { PublicUserInfo } from '../../types/user'; import MembersList from '../../components/MembersList'; import CustomColorPicker from '../../components/CustomColorPicker'; import DismissibleModal from '../../components/DismissibleModal'; import CommitteeTeamCard from '../Committees/CommitteeTeamCard'; +import { CommitteesStackParams } from '../../types/navigation'; +import { RouteProp } from '@react-navigation/core'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; const CommitteeEditor = ({ navigation, route }: CommitteeEditorProps) => { const committeeData = route?.params?.committee; @@ -778,4 +780,10 @@ interface TeamMembersState { head: PublicUserInfo | null | undefined; } +export type CommitteeEditorProps = { + route: RouteProp; + navigation: NativeStackNavigationProp; +}; + + export default CommitteeEditor \ No newline at end of file diff --git a/src/screens/events/EventInfo.tsx b/src/screens/events/EventInfo.tsx index 3983c5a0..8a8072ff 100644 --- a/src/screens/events/EventInfo.tsx +++ b/src/screens/events/EventInfo.tsx @@ -1,13 +1,12 @@ import { View, Text, TouchableOpacity, ActivityIndicator, ScrollView, Image, Platform, Modal } from 'react-native' import React, { useCallback, useContext, useEffect, useState } from 'react' -import { useFocusEffect, useRoute } from '@react-navigation/core'; +import { RouteProp, useFocusEffect, useRoute } from '@react-navigation/core'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { Octicons } from '@expo/vector-icons'; import { auth } from "../../config/firebaseConfig"; import { getEvent, getAttendanceNumber, isUserSignedIn, getPublicUserData, getUsers, signInToEvent, signOutOfEvent } from '../../api/firebaseUtils'; import { UserContext } from '../../context/UserContext'; import { formatEventDate, formatTime } from '../../helpers/timeUtils'; -import { EventProps, SHPEEventScreenRouteProp } from '../../types/navigation' import { SHPEEvent, getStatusMessage } from '../../types/events'; import { Images } from '../../../assets'; import { StatusBar } from 'expo-status-bar'; @@ -20,10 +19,12 @@ import MemberCard from '../../components/MemberCard'; import { PublicUserInfo } from '../../types/user'; import { reverseFormattedFirebaseName } from '../../types/committees'; import MembersList from '../../components/MembersList'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { EventsStackParams } from '../../types/navigation'; -const EventInfo = ({ navigation }: any) => { - const route = useRoute(); - const { eventId } = route.params; + +const EventInfo: React.FC = ({ route, navigation }) => { + const { eventId } = route.params const { userInfo } = useContext(UserContext)!; const [event, setEvent] = useState(); const [creatorData, setCreatorData] = useState(null) @@ -352,4 +353,11 @@ const EventInfo = ({ navigation }: any) => { ) } + +export type EventScreenRouteProp = { + route: RouteProp; + navigation: NativeStackNavigationProp; +}; + + export default EventInfo \ No newline at end of file diff --git a/src/screens/events/EventVerification.tsx b/src/screens/events/EventVerification.tsx index c59cdc2a..41439479 100644 --- a/src/screens/events/EventVerification.tsx +++ b/src/screens/events/EventVerification.tsx @@ -1,7 +1,7 @@ import { View, Text } from 'react-native' import React, { useContext, useEffect, useState } from 'react'; -import { useRoute } from '@react-navigation/core'; -import { EventVerificationProps, EventVerificationScreenRouteProp } from '../../types/navigation' +import { RouteProp, useRoute } from '@react-navigation/core'; +import { MainStackParams } from '../../types/navigation' import { getEvent, signInToEvent, signOutOfEvent } from '../../api/firebaseUtils'; import { SafeAreaView } from 'react-native-safe-area-context'; import LottieView from "lottie-react-native"; @@ -10,9 +10,9 @@ import { ActivityIndicator } from "react-native"; import * as Haptics from 'expo-haptics'; import { StatusBar } from 'expo-status-bar'; import { UserContext } from '../../context/UserContext'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -const EventVerification = ({ navigation }: EventVerificationProps) => { - const route = useRoute(); +const EventVerification: React.FC = ({ route, navigation }) => { const { id, mode } = route.params; const { userInfo } = useContext(UserContext)!; const [logStatus, setLogStatus] = useState(); @@ -156,4 +156,11 @@ const EventVerification = ({ navigation }: EventVerificationProps) => { ); }; + +export type EventVerificationScreenRouteProp = { + route: RouteProp; + navigation: NativeStackNavigationProp; +}; + + export default EventVerification; \ No newline at end of file diff --git a/src/screens/events/QRCodeManager.tsx b/src/screens/events/QRCodeManager.tsx index 908aedc7..f13394e5 100644 --- a/src/screens/events/QRCodeManager.tsx +++ b/src/screens/events/QRCodeManager.tsx @@ -1,6 +1,6 @@ import { View, Text, Alert, ActivityIndicator, Button } from 'react-native'; import React, { useState, useRef } from 'react'; -import { useRoute } from '@react-navigation/core'; +import { RouteProp, useRoute } from '@react-navigation/core'; import QRCode from 'react-native-qrcode-svg'; import { SafeAreaView } from 'react-native-safe-area-context'; import { TouchableOpacity, GestureHandlerRootView } from 'react-native-gesture-handler'; @@ -8,12 +8,18 @@ import { Octicons } from '@expo/vector-icons'; import * as FileSystem from 'expo-file-system'; import * as Sharing from 'expo-sharing'; import DismissibleModal from '../../components/DismissibleModal'; -import { QRCodeScreenRouteProp, QRCodeProps } from '../../types/navigation'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { EventsStackParams } from '../../types/navigation'; + +export type QRCodeScreenRouteProp = { + route: RouteProp; + navigation: NativeStackNavigationProp; +}; + type QRCodeRef = { toDataURL: (callback: (data: string) => void) => void }; -const QRCodeManager = ({ navigation }: QRCodeProps) => { - const route = useRoute(); +const QRCodeManager: React.FC = ({ route, navigation }) => { const { event } = route.params; const [loading, setLoading] = useState(false); diff --git a/src/screens/home/Ishpe.tsx b/src/screens/home/Ishpe.tsx index bdbd56dc..5c7c76e9 100644 --- a/src/screens/home/Ishpe.tsx +++ b/src/screens/home/Ishpe.tsx @@ -3,17 +3,17 @@ import React, { useState, useEffect, useContext, useCallback } from 'react'; import { Octicons } from '@expo/vector-icons'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useFocusEffect } from '@react-navigation/core'; -import { auth } from "../../config/firebaseConfig"; import { UserContext } from '../../context/UserContext'; import { getCommitteeEvents, getCommittees, getInterestsEvent, getUpcomingEvents, setPublicUserData } from '../../api/firebaseUtils'; import { monthNames, MillisecondTimes } from '../../helpers/timeUtils'; import { EventType, SHPEEvent } from '../../types/events'; import { Committee } from '../../types/committees'; -import { IShpeProps } from '../../types/navigation'; import SHPELogo from '../../../assets/SHPE_black.svg'; import EventsList from '../../components/EventsList'; import DismissibleModal from '../../components/DismissibleModal'; import ProfileBadge from '../../components/ProfileBadge'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { HomeStackParams } from '../../types/navigation'; const Ishpe: React.FC = ({ navigation }) => { const { userInfo, setUserInfo } = useContext(UserContext)!; @@ -407,4 +407,8 @@ const formatWeekRange = (startDate: Date) => { type SHPEEventWithCommitteeData = SHPEEvent & { committeeData?: Committee }; +export type IShpeProps = { + navigation?: NativeStackNavigationProp +} + export default Ishpe; \ No newline at end of file diff --git a/src/screens/home/Settings.tsx b/src/screens/home/Settings.tsx index 4c305413..f8e1cf37 100644 --- a/src/screens/home/Settings.tsx +++ b/src/screens/home/Settings.tsx @@ -14,7 +14,7 @@ import { getBlobFromURI, selectFile, selectImage, uploadFile } from '../../api/f import { CommonMimeTypes, validateDisplayName, validateFileBlob, validateName, validateTamuEmail } from '../../helpers/validation'; import { handleLinkPress } from '../../helpers/links'; import { getBadgeColor, isMemberVerified } from '../../helpers/membership'; -import { MainStackParams, PublicProfileStackParams } from '../../types/navigation'; +import { PublicProfileStackParams } from '../../types/navigation'; import { Committee } from '../../types/committees'; import { MAJORS, classYears } from '../../types/user'; import { Images } from '../../../assets'; @@ -130,7 +130,7 @@ const SettingsScreen = ({ navigation }: NativeStackScreenProps) => { +const ProfileSettingsScreen = ({ navigation }: NativeStackScreenProps) => { const { userInfo, setUserInfo } = useContext(UserContext)!; const [loading, setLoading] = useState(false); const [image, setImage] = useState(null); @@ -641,7 +641,7 @@ const ProfileSettingsScreen = ({ navigation }: NativeStackScreenProps) => { +const DisplaySettingsScreen = ({ navigation }: NativeStackScreenProps) => { const { userInfo, setUserInfo } = useContext(UserContext)!; const [loading, setLoading] = useState(false); const [darkModeToggled, setDarkModeToggled] = useState(userInfo?.private?.privateInfo?.settings?.darkMode ?? false); @@ -700,7 +700,7 @@ const DisplaySettingsScreen = ({ navigation }: NativeStackScreenProps) => { +const AccountSettingsScreen = ({ navigation }: NativeStackScreenProps) => { const { userInfo, setUserInfo, signOutUser } = useContext(UserContext)!; const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; const deleteConfirmationText = "DELETECONFIRM"; @@ -838,7 +838,7 @@ const AccountSettingsScreen = ({ navigation }: NativeStackScreenProps) => { +const FeedBackSettingsScreen = ({ navigation }: NativeStackScreenProps) => { const [feedback, setFeedback] = useState(''); const { userInfo } = useContext(UserContext)!; @@ -879,7 +879,7 @@ const FeedBackSettingsScreen = ({ navigation }: NativeStackScreenProps) => { +const FAQSettingsScreen = ({ navigation }: NativeStackScreenProps) => { const [activeQuestion, setActiveQuestion] = useState(null); const { userInfo } = useContext(UserContext)!; @@ -961,7 +961,7 @@ const FAQSettingsScreen = ({ navigation }: NativeStackScreenProps) => { +const AboutSettingsScreen = ({ navigation }: NativeStackScreenProps) => { const pkg: any = require("../../../package.json"); const { userInfo } = useContext(UserContext)!; const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; diff --git a/src/screens/resources/RankCard.tsx b/src/screens/resources/RankCard.tsx index 0eb3868c..9e7312b7 100644 --- a/src/screens/resources/RankCard.tsx +++ b/src/screens/resources/RankCard.tsx @@ -2,8 +2,9 @@ import { View, Text, TouchableOpacity, Image } from 'react-native' import React from 'react' import { Octicons } from '@expo/vector-icons'; import { Images } from '../../../assets'; -import { PointsProps } from '../../types/navigation' -import { RankChange } from '../../types/user'; +import { PublicUserInfo, RankChange } from '../../types/user'; +import { ResourcesStackParams } from '../../types/navigation'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; /** * The RankCard component displays a user's rank information. @@ -57,4 +58,9 @@ const RankCard: React.FC = ({ userData, navigation }) => { ) } +export type PointsProps = { + userData: PublicUserInfo + navigation: NativeStackNavigationProp +} + export default RankCard \ No newline at end of file diff --git a/src/screens/resources/ResumeCard.tsx b/src/screens/resources/ResumeCard.tsx index d6444845..18952f00 100644 --- a/src/screens/resources/ResumeCard.tsx +++ b/src/screens/resources/ResumeCard.tsx @@ -7,10 +7,12 @@ import { deleteField, doc, updateDoc } from 'firebase/firestore'; import { httpsCallable } from 'firebase/functions'; import { getBadgeColor, isMemberVerified } from '../../helpers/membership'; import { handleLinkPress } from '../../helpers/links'; -import { ResumeProps } from '../../types/navigation' +import { ResourcesStackParams } from '../../types/navigation' import TwitterSvg from '../../components/TwitterSvg'; import { Images } from '../../../assets'; import DismissibleModal from '../../components/DismissibleModal'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { PublicUserInfo } from '../../types/user'; const ResumeCard: React.FC void }> = ({ resumeData, navigation, onResumeRemoved }) => { // Data related to user's resume @@ -139,4 +141,11 @@ const ResumeCard: React.FC void }> = ({ r ) } + +export type ResumeProps = { + resumeData: PublicUserInfo + navigation: NativeStackNavigationProp +} + + export default ResumeCard \ No newline at end of file diff --git a/src/screens/resources/TestCard.tsx b/src/screens/resources/TestCard.tsx index d80c851c..9089cad8 100644 --- a/src/screens/resources/TestCard.tsx +++ b/src/screens/resources/TestCard.tsx @@ -1,9 +1,11 @@ import { View, Text, TouchableOpacity } from 'react-native' import React from 'react' import { StatusBar } from 'expo-status-bar'; -import { TestBankProps } from '../../types/navigation' +import { ResourcesStackParams } from '../../types/navigation' import { SubjectCode, subjectIconMapping } from '../../types/testBank'; import { handleLinkPress } from '../../helpers/links'; +import { Test } from '../../types/googleSheetsTypes'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; const TestCard: React.FC = ({ testData }) => { @@ -78,6 +80,10 @@ const TestCard: React.FC = ({ testData }) => { ) } +export type TestBankProps = { + testData: Test; + navigation: NativeStackNavigationProp +} export default TestCard diff --git a/src/types/navigation.ts b/src/types/navigation.ts index 59ab67bf..81022944 100644 --- a/src/types/navigation.ts +++ b/src/types/navigation.ts @@ -1,24 +1,10 @@ -import { ImageSourcePropType } from "react-native"; -import { SetStateAction } from "react"; -import { NativeStackScreenProps, NativeStackNavigationProp } from "@react-navigation/native-stack"; +import { NativeStackNavigationProp } from "@react-navigation/native-stack"; import type { RouteProp } from '@react-navigation/native'; -import { DocumentData, QueryDocumentSnapshot } from "firebase/firestore"; -import { Test } from './googleSheetsTypes'; import { Committee } from "./committees"; -import { PublicUserInfo, UserFilter } from "./user"; +import { PublicUserInfo } from "./user"; import { SHPEEvent } from "./events"; - -export type MainStackParams = { - HomeBottomTabs: undefined; - EventVerificationScreen: { - id: string; - mode: "sign-in" | "sign-out"; - }; - -}; - export type AuthStackParams = { LoginScreen: undefined; RegisterScreen: undefined; @@ -30,22 +16,6 @@ export type AuthStackParams = { GuestRecoveryAccount: undefined; }; -export type PublicProfileStackParams = { - PublicProfile: { uid: string; } - SettingsScreen: undefined; - PersonalEventLogScreen: undefined; - - // Settings Screens - ProfileSettingsScreen: undefined; - DisplaySettingsScreen: undefined; - AccountSettingsScreen: undefined; - FeedbackSettingsScreen: undefined; - FAQSettingsScreen: undefined; - AboutSettingsScreen: undefined; - - -}; - export type ProfileSetupStackParams = { LoginScreen: undefined; SetupNameAndBio: undefined; @@ -56,29 +26,48 @@ export type ProfileSetupStackParams = { MainStack: undefined; } +export type MainStackParams = { + HomeBottomTabs: undefined; + EventVerificationScreen: { + id: string; + mode: "sign-in" | "sign-out"; + }; + QRCodeScanningScreen: undefined; +}; + +export type HomeStackParams = { + Home: undefined; + Members: undefined; + MemberSHPE: undefined; + + // Event Screens + EventInfo: { eventId: string }; + UpdateEvent: { event: SHPEEvent }; + QRCode: { event: SHPEEvent }; + + // Admin Dashboard Screens + AdminDashboard: undefined; + MOTMEditor: undefined; + ResumeDownloader: undefined; + LinkEditor: undefined; + RestrictionsEditor: undefined; + FeedbackEditor: undefined; + MemberSHPEConfirm: undefined; + CommitteeConfirm: undefined; + ResumeConfirm: undefined; + ShirtConfirm: undefined; + InstagramPoints: undefined; + + PublicProfile: { uid: string; }; +} + export type ResourcesStackParams = { Resources: undefined; PointsLeaderboard: undefined; TestBank: undefined; ResumeBank: undefined; - PublicProfile: { - uid: string; - }; -} - -export type CommitteesStackParams = { - CommitteesScreen: undefined; - CommitteeScreen: { - committee: Committee; - }; - PublicProfile: { - uid: string; - }; - CommitteeEditor: { - committee?: Committee; - }; - EventInfo: undefined; + PublicProfile: { uid: string; }; } export type EventsStackParams = { @@ -94,92 +83,41 @@ export type EventsStackParams = { SetSpecificEventDetails: { event: SHPEEvent }; setLocationEventDetails: { event: SHPEEvent }; FinalizeEvent: { event: SHPEEvent }; -} + EventVerificationScreen: { id: string; mode: "sign-in" | "sign-out"; }; -export type HomeStackParams = { - Home: undefined; PublicProfile: { uid: string; }; - MemberSHPE: undefined; - Members: undefined; +} - // Admin Dashboard Screens - AdminDashboard: undefined; - MOTMEditor: undefined; - ResumeDownloader: undefined; - LinkEditor: undefined; - RestrictionsEditor: undefined; - FeedbackEditor: undefined; - MemberSHPEConfirm: undefined; - CommitteeConfirm: undefined; - ResumeConfirm: undefined; - ShirtConfirm: undefined; - InstagramPoints: undefined; +export type CommitteesStackParams = { + CommitteesScreen: undefined; + CommitteeScreen: { committee: Committee; }; + CommitteeEditor: { committee?: Committee; }; // Event Screens EventInfo: { eventId: string }; UpdateEvent: { event: SHPEEvent }; QRCode: { event: SHPEEvent }; + PublicProfile: { uid: string; }; } -// Bottom Tabs -export type HomeBottomTabParams = { - Home: undefined; -}; - -export type ResourcesProps = { - items: { - title: string; - screen: keyof ResourcesStackParams; - image: ImageSourcePropType; - "bg-color": string; - "text-color": string; - }, - navigation: NativeStackNavigationProp -} - -export type PointsProps = { - userData: PublicUserInfo - navigation: NativeStackNavigationProp -} - -export type ResumeProps = { - resumeData: PublicUserInfo - navigation: NativeStackNavigationProp -} -export type TestBankProps = { - testData: Test; - navigation: NativeStackNavigationProp -} +export type PublicProfileStackParams = { + PublicProfile: { uid: string; } + PersonalEventLogScreen: undefined; -export type MembersProps = { - userData?: PublicUserInfo - handleCardPress?: (uid: string) => string | void; - navigation?: NativeStackNavigationProp - officersList?: PublicUserInfo[] - membersList?: PublicUserInfo[] - loadMoreUsers?: () => void; - hasMoreUser?: boolean; - setFilter?: React.Dispatch>; - filter?: UserFilter; - setLastUserSnapshot?: React.Dispatch | null>>; - canSearch?: boolean; - numLimit?: number | null; - setNumLimit?: React.Dispatch>; - loading?: boolean; - DEFAULT_NUM_LIMIT?: number | null; -} + // Settings Screens + SettingsScreen: undefined; + ProfileSettingsScreen: undefined; + DisplaySettingsScreen: undefined; + AccountSettingsScreen: undefined; + FeedbackSettingsScreen: undefined; + FAQSettingsScreen: undefined; + AboutSettingsScreen: undefined; +}; -export type MemberListProps = { - handleCardPress: (uid: string) => string | void; - users: PublicUserInfo[]; - navigation?: NativeStackNavigationProp -} -interface SelectedPublicUserInfo extends PublicUserInfo { - selected?: boolean; -} +// Component Props export type MemberCardProp = { handleCardPress?: (uid: string | void) => void; userData?: PublicUserInfo; @@ -187,57 +125,10 @@ export type MemberCardProp = { navigation?: NativeStackNavigationProp } - -export type MemberCardMultipleSelectProp = { - handleCardPress?: (uid: string | void) => void; - userData?: SelectedPublicUserInfo; -} - -export type IShpeProps = { - navigation?: NativeStackNavigationProp -} - export type EventProps = { event?: SHPEEvent; navigation: NativeStackNavigationProp } - -export type CommitteeTeamCardProps = { - userData: PublicUserInfo; - navigation?: NativeStackNavigationProp -} - - -export type EventVerificationProps = { - id?: string; - mode?: "sign-in" | "sign-out"; - navigation?: NativeStackNavigationProp; -} - -export type QRCodeProps = { - event?: SHPEEvent; - navigation: NativeStackNavigationProp -} - -export type CommitteeEditorProps = { - route: RouteProp; - navigation: NativeStackNavigationProp; -}; - - -export type SettingsProps = NativeStackScreenProps; - -// routes prop for screens -export type SettingsScreenRouteProp = RouteProp; -export type MembersScreenRouteProp = RouteProp; -export type CommitteeScreenRouteProp = RouteProp; -export type UpdateEventScreenRouteProp = RouteProp; -export type SHPEEventScreenRouteProp = RouteProp; -export type EventVerificationScreenRouteProp = RouteProp; -export type QRCodeScreenRouteProp = RouteProp; -export type CommitteeEditorRouteProp = RouteProp; -export type CommitteeEditorNavigationProp = NativeStackNavigationProp; -export type CommitteeScreenProps = NativeStackScreenProps; -export type CommitteesListProps = { navigation: NativeStackNavigationProp; }; -export type CommitteesScreenScreenProps = NativeStackScreenProps; +// Screen Props +export type UpdateEventScreenRouteProp = RouteProp; \ No newline at end of file From 239000f7c5e4d36b40f62be10744a67973f68a28 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 2 Jun 2024 19:21:48 -0500 Subject: [PATCH 007/198] rename publicprofilestack to userprofilestack, move bottom tab into main stack --- src/navigation/EventsStack.tsx | 4 +- src/navigation/HomeBottomTabs.tsx | 80 ----------------- src/navigation/HomeStack.tsx | 19 ++-- src/navigation/MainStack.tsx | 88 +++++++++++++++++-- src/navigation/ResourcesStack.tsx | 1 + ...cProfileStack.tsx => UserProfileStack.tsx} | 8 +- src/screens/PublicProfile.tsx | 6 +- src/screens/events/PersonalEventLog.tsx | 4 +- src/screens/home/Settings.tsx | 16 ++-- src/types/navigation.ts | 4 +- 10 files changed, 116 insertions(+), 114 deletions(-) delete mode 100644 src/navigation/HomeBottomTabs.tsx rename src/navigation/{PublicProfileStack.tsx => UserProfileStack.tsx} (91%) diff --git a/src/navigation/EventsStack.tsx b/src/navigation/EventsStack.tsx index 76d92b9d..ffc44daa 100644 --- a/src/navigation/EventsStack.tsx +++ b/src/navigation/EventsStack.tsx @@ -10,7 +10,6 @@ import SetGeneralEventDetails from "../screens/events/SetGeneralEventDetails"; import SetSpecificEventDetails from "../screens/events/SetSpecificEventDetails"; import FinalizeEvent from "../screens/events/FinalizeEvent"; import SetLocationEventDetails from "../screens/events/SetLocationEventDetails"; -import QRCodeScanningScreen from "../screens/events/QRCodeScanningScreen"; import EventVerification from "../screens/events/EventVerification"; import PublicProfileScreen from "../screens/PublicProfile"; @@ -19,10 +18,9 @@ const EventsStack = () => { return ( - + - {/* Event Creation Screens */} diff --git a/src/navigation/HomeBottomTabs.tsx b/src/navigation/HomeBottomTabs.tsx deleted file mode 100644 index a8b2ed85..00000000 --- a/src/navigation/HomeBottomTabs.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from 'react'; -import { View, Text, Image } from 'react-native'; -import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; -import { Octicons } from '@expo/vector-icons'; -import { PublicProfileStack } from './PublicProfileStack'; -import { CommitteesStack } from './CommitteesStack'; -import { ResourcesStack } from './ResourcesStack'; -import { EventsStack } from './EventsStack'; -import { HomeStack } from './HomeStack'; -import { Images } from '../../assets'; -import { auth } from '../config/firebaseConfig'; - -const TAB_ICON_CONFIG: Record = { - Home: 'home', - ResourcesStack: 'rows', - Events: "calendar", - Committees: 'stack', - Profile: 'person', -}; - -const activeIconColor = 'black'; -const inactiveIconColor = 'black'; -const iconSize = 28; - - -const generateTabIcon = (routeName: TabName, focused: boolean): JSX.Element => { - if (routeName == 'Profile') { - return ( - - - - ); - } - - const iconName = TAB_ICON_CONFIG[routeName] || 'x-circle'; - const iconColor = focused ? activeIconColor : inactiveIconColor; - let tabName: string = routeName; - if (tabName === 'ResourcesStack') { - tabName = 'Resources'; - } - return ( - - - {focused ? tabName : ""} - - ); -}; -const HomeBottomTabs = () => { - const BottomTabs = createBottomTabNavigator(); - - return ( - - ({ - tabBarIcon: ({ focused }) => generateTabIcon(route.name as TabName, focused), - headerShown: false, - tabBarActiveTintColor: 'maroon', - tabBarInactiveTintColor: 'black', - tabBarShowLabel: false, - })} - > - - - - - - - - ); -}; - -type OcticonIconName = React.ComponentProps['name']; -type TabName = 'Home' | 'ResourcesStack' | 'Committees' | 'Profile' | 'Events'; - -export default HomeBottomTabs; diff --git a/src/navigation/HomeStack.tsx b/src/navigation/HomeStack.tsx index 2eb1f291..c52568e3 100644 --- a/src/navigation/HomeStack.tsx +++ b/src/navigation/HomeStack.tsx @@ -23,15 +23,20 @@ import Members from "../screens/Members"; const HomeStack = () => { const Stack = createNativeStackNavigator(); return ( - - - - + + + + + + + {/* Event Screens */} - - - + + + + + {/* Admin Dashboard Screens */} diff --git a/src/navigation/MainStack.tsx b/src/navigation/MainStack.tsx index 84c98099..ea0307ad 100644 --- a/src/navigation/MainStack.tsx +++ b/src/navigation/MainStack.tsx @@ -2,9 +2,19 @@ import React, { useContext } from "react"; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { UserContext } from "../context/UserContext"; import { MainStackParams } from '../types/navigation'; -import HomeBottomTabs from "./HomeBottomTabs"; import EventVerification from "../screens/events/EventVerification"; import QRCodeScanningScreen from "../screens/events/QRCodeScanningScreen"; +import { HomeStack } from "./HomeStack"; +import { ResourcesStack } from "./ResourcesStack"; +import { EventsStack } from "./EventsStack"; +import { CommitteesStack } from "./CommitteesStack"; +import { UserProfileStack } from "./UserProfileStack"; +import { Octicons } from '@expo/vector-icons'; +import { Image, Text, View } from "react-native"; +import { auth } from "../config/firebaseConfig"; +import { Images } from "../../assets"; +import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; + const MainStack = () => { const Stack = createNativeStackNavigator(); @@ -12,10 +22,7 @@ const MainStack = () => { return ( {/* Main components */} - - - - + @@ -23,4 +30,75 @@ const MainStack = () => { ); }; +const HomeBottomTabs = () => { + const BottomTabs = createBottomTabNavigator(); + + const TAB_ICON_CONFIG: Record = { + Home: 'home', + ResourcesStack: 'rows', + Events: "calendar", + Committees: 'stack', + Profile: 'person', + }; + + const activeIconColor = 'black'; + const inactiveIconColor = 'black'; + const iconSize = 28; + + const generateTabIcon = (routeName: TabName, focused: boolean): JSX.Element => { + if (routeName == 'Profile') { + return ( + + + + ); + } + + const iconName = TAB_ICON_CONFIG[routeName] || 'x-circle'; + const iconColor = focused ? activeIconColor : inactiveIconColor; + let tabName: string = routeName; + if (tabName === 'ResourcesStack') { + tabName = 'Resources'; + } + return ( + + + {focused ? tabName : ""} + + ); + }; + + + return ( + + ({ + tabBarIcon: ({ focused }) => generateTabIcon(route.name as TabName, focused), + headerShown: false, + tabBarActiveTintColor: 'maroon', + tabBarInactiveTintColor: 'black', + tabBarShowLabel: false, + })} + > + + + + + + + + ); +}; + + + +type OcticonIconName = React.ComponentProps['name']; +type TabName = 'Home' | 'ResourcesStack' | 'Committees' | 'Profile' | 'Events'; + + export { MainStack }; diff --git a/src/navigation/ResourcesStack.tsx b/src/navigation/ResourcesStack.tsx index 07b0d7a8..8954d3a3 100644 --- a/src/navigation/ResourcesStack.tsx +++ b/src/navigation/ResourcesStack.tsx @@ -15,6 +15,7 @@ const ResourcesStack = () => { + ); diff --git a/src/navigation/PublicProfileStack.tsx b/src/navigation/UserProfileStack.tsx similarity index 91% rename from src/navigation/PublicProfileStack.tsx rename to src/navigation/UserProfileStack.tsx index 592d76e5..fcde19d1 100644 --- a/src/navigation/PublicProfileStack.tsx +++ b/src/navigation/UserProfileStack.tsx @@ -3,12 +3,12 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'; import PublicProfileScreen from "../screens/PublicProfile"; import { AboutSettingsScreen, AccountSettingsScreen, DisplaySettingsScreen, FAQSettingsScreen, FeedBackSettingsScreen, ProfileSettingsScreen, SettingsScreen } from "../screens/home/Settings"; import { UserContext } from "../context/UserContext"; -import { PublicProfileStackParams } from "../types/navigation"; +import { UserProfileStackParams } from "../types/navigation"; import { auth } from "../config/firebaseConfig"; import PersonalEventLog from "../screens/events/PersonalEventLog"; -const PublicProfileStack = () => { - const Stack = createNativeStackNavigator(); +const UserProfileStack = () => { + const Stack = createNativeStackNavigator(); const { userInfo } = useContext(UserContext)!; const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; @@ -40,4 +40,4 @@ const PublicProfileStack = () => { ); }; -export { PublicProfileStack }; +export { UserProfileStack }; diff --git a/src/screens/PublicProfile.tsx b/src/screens/PublicProfile.tsx index 14a4d065..897fb970 100644 --- a/src/screens/PublicProfile.tsx +++ b/src/screens/PublicProfile.tsx @@ -12,7 +12,7 @@ import { auth } from '../config/firebaseConfig'; import { getCommittees, getPublicUserData, getUser, queryUserEventLogs, setUserRoles } from '../api/firebaseUtils'; import { getBadgeColor, isMemberVerified } from '../helpers/membership'; import { handleLinkPress } from '../helpers/links'; -import { PublicProfileStackParams } from '../types/navigation'; +import { UserProfileStackParams } from '../types/navigation'; import { PublicUserInfo, Roles } from '../types/user'; import { Committee } from '../types/committees'; import { Images } from '../../assets'; @@ -23,8 +23,8 @@ import { UserEventData } from '../types/events'; import { Timestamp } from 'firebase/firestore'; export type PublicProfileScreenProps = { - route: RouteProp; - navigation: NativeStackNavigationProp; + route: RouteProp; + navigation: NativeStackNavigationProp; }; const PublicProfileScreen: React.FC = ({ route, navigation }) => { diff --git a/src/screens/events/PersonalEventLog.tsx b/src/screens/events/PersonalEventLog.tsx index 18c7860e..fce9d17d 100644 --- a/src/screens/events/PersonalEventLog.tsx +++ b/src/screens/events/PersonalEventLog.tsx @@ -8,9 +8,9 @@ import { UserContext } from '../../context/UserContext'; import { auth } from '../../config/firebaseConfig'; import { queryUserEventLogs } from '../../api/firebaseUtils'; import { UserEventData } from '../../types/events'; -import { PublicProfileStackParams } from '../../types/navigation'; +import { UserProfileStackParams } from '../../types/navigation'; -const PersonalEventLog = ({ navigation }: NativeStackScreenProps) => { +const PersonalEventLog = ({ navigation }: NativeStackScreenProps) => { const userContext = useContext(UserContext); const { userInfo } = userContext!; diff --git a/src/screens/home/Settings.tsx b/src/screens/home/Settings.tsx index f8e1cf37..f3ef2ffc 100644 --- a/src/screens/home/Settings.tsx +++ b/src/screens/home/Settings.tsx @@ -14,7 +14,7 @@ import { getBlobFromURI, selectFile, selectImage, uploadFile } from '../../api/f import { CommonMimeTypes, validateDisplayName, validateFileBlob, validateName, validateTamuEmail } from '../../helpers/validation'; import { handleLinkPress } from '../../helpers/links'; import { getBadgeColor, isMemberVerified } from '../../helpers/membership'; -import { PublicProfileStackParams } from '../../types/navigation'; +import { UserProfileStackParams } from '../../types/navigation'; import { Committee } from '../../types/committees'; import { MAJORS, classYears } from '../../types/user'; import { Images } from '../../../assets'; @@ -30,7 +30,7 @@ import * as Clipboard from 'expo-clipboard'; /** * Settings entrance screen which has a search function and paths to every other settings screen */ -const SettingsScreen = ({ navigation }: NativeStackScreenProps) => { +const SettingsScreen = ({ navigation }: NativeStackScreenProps) => { const { userInfo, signOutUser } = useContext(UserContext)!; const { name, roles, photoURL, chapterExpiration, nationalExpiration } = userInfo?.publicInfo ?? {}; @@ -130,7 +130,7 @@ const SettingsScreen = ({ navigation }: NativeStackScreenProps) => { +const ProfileSettingsScreen = ({ navigation }: NativeStackScreenProps) => { const { userInfo, setUserInfo } = useContext(UserContext)!; const [loading, setLoading] = useState(false); const [image, setImage] = useState(null); @@ -641,7 +641,7 @@ const ProfileSettingsScreen = ({ navigation }: NativeStackScreenProps) => { +const DisplaySettingsScreen = ({ navigation }: NativeStackScreenProps) => { const { userInfo, setUserInfo } = useContext(UserContext)!; const [loading, setLoading] = useState(false); const [darkModeToggled, setDarkModeToggled] = useState(userInfo?.private?.privateInfo?.settings?.darkMode ?? false); @@ -700,7 +700,7 @@ const DisplaySettingsScreen = ({ navigation }: NativeStackScreenProps) => { +const AccountSettingsScreen = ({ navigation }: NativeStackScreenProps) => { const { userInfo, setUserInfo, signOutUser } = useContext(UserContext)!; const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; const deleteConfirmationText = "DELETECONFIRM"; @@ -838,7 +838,7 @@ const AccountSettingsScreen = ({ navigation }: NativeStackScreenProps) => { +const FeedBackSettingsScreen = ({ navigation }: NativeStackScreenProps) => { const [feedback, setFeedback] = useState(''); const { userInfo } = useContext(UserContext)!; @@ -879,7 +879,7 @@ const FeedBackSettingsScreen = ({ navigation }: NativeStackScreenProps) => { +const FAQSettingsScreen = ({ navigation }: NativeStackScreenProps) => { const [activeQuestion, setActiveQuestion] = useState(null); const { userInfo } = useContext(UserContext)!; @@ -961,7 +961,7 @@ const FAQSettingsScreen = ({ navigation }: NativeStackScreenProps) => { +const AboutSettingsScreen = ({ navigation }: NativeStackScreenProps) => { const pkg: any = require("../../../package.json"); const { userInfo } = useContext(UserContext)!; const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; diff --git a/src/types/navigation.ts b/src/types/navigation.ts index 81022944..bfbaadd0 100644 --- a/src/types/navigation.ts +++ b/src/types/navigation.ts @@ -75,7 +75,6 @@ export type EventsStackParams = { UpdateEvent: { event: SHPEEvent }; EventInfo: { eventId: string }; QRCode: { event: SHPEEvent }; - QRCodeScanningScreen: undefined; // Events related to event creation CreateEvent: undefined; @@ -98,10 +97,11 @@ export type CommitteesStackParams = { UpdateEvent: { event: SHPEEvent }; QRCode: { event: SHPEEvent }; + PublicProfile: { uid: string; }; } -export type PublicProfileStackParams = { +export type UserProfileStackParams = { PublicProfile: { uid: string; } PersonalEventLogScreen: undefined; From 7a76e82a26d017c86110f137c09e4bfd04ce4b22 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 2 Jun 2024 20:35:46 -0500 Subject: [PATCH 008/198] fix navigation typing and clean up --- src/navigation/CommitteesStack.tsx | 2 +- src/navigation/HomeStack.tsx | 2 +- src/screens/Committees/Committee.tsx | 4 ++-- .../{admin => Committees}/CommitteeEditor.tsx | 4 ++-- src/screens/Committees/CommitteeTeamCard.tsx | 2 +- src/screens/admin/AdminDashboard.tsx | 4 ++-- src/screens/admin/CommitteeConfirm.tsx | 4 ++-- src/screens/admin/FeedbackEditor.tsx | 4 ++-- src/screens/admin/InstagramPoints.tsx | 4 ++-- src/screens/admin/LinkEditor.tsx | 4 ++-- src/screens/admin/MOTMEditor.tsx | 4 ++-- src/screens/admin/MemberSHPEConfirm.tsx | 4 ++-- src/screens/admin/RestrictionsEditor.tsx | 4 ++-- src/screens/admin/ResumeConfirm.tsx | 4 ++-- src/screens/admin/ResumeDownloader.tsx | 14 +++++++------- src/screens/admin/ShirtConfirm.tsx | 4 ++-- src/screens/events/EventInfo.tsx | 2 +- src/screens/events/EventVerification.tsx | 2 +- src/screens/events/QRCodeManager.tsx | 16 ++++++++-------- src/screens/home/Ishpe.tsx | 2 +- src/screens/{Committees => home}/MemberSHPE.tsx | 0 src/screens/resources/ResumeCard.tsx | 2 +- src/screens/resources/TestCard.tsx | 2 +- 23 files changed, 47 insertions(+), 47 deletions(-) rename src/screens/{admin => Committees}/CommitteeEditor.tsx (99%) rename src/screens/{Committees => home}/MemberSHPE.tsx (100%) diff --git a/src/navigation/CommitteesStack.tsx b/src/navigation/CommitteesStack.tsx index 42e75b5a..33beb192 100644 --- a/src/navigation/CommitteesStack.tsx +++ b/src/navigation/CommitteesStack.tsx @@ -3,7 +3,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { CommitteesStackParams } from '../types/navigation'; import CommitteeScreen from "../screens/Committees/Committee"; import PublicProfileScreen from "../screens/PublicProfile"; -import CommitteeEditor from "../screens/admin/CommitteeEditor"; +import CommitteeEditor from "../screens/Committees/CommitteeEditor"; import EventInfo from "../screens/events/EventInfo"; import CommitteesScreen from "../screens/Committees/Committees"; import UpdateEvent from "../screens/events/UpdateEvent"; diff --git a/src/navigation/HomeStack.tsx b/src/navigation/HomeStack.tsx index c52568e3..2c80eb98 100644 --- a/src/navigation/HomeStack.tsx +++ b/src/navigation/HomeStack.tsx @@ -17,7 +17,7 @@ import FeedbackEditor from "../screens/admin/FeedbackEditor"; import InstagramPoints from "../screens/admin/InstagramPoints"; import UpdateEvent from "../screens/events/UpdateEvent"; import QRCodeManager from "../screens/events/QRCodeManager"; -import MemberSHPE from "../screens/Committees/MemberSHPE"; +import MemberSHPE from "../screens/home/MemberSHPE"; import Members from "../screens/Members"; const HomeStack = () => { diff --git a/src/screens/Committees/Committee.tsx b/src/screens/Committees/Committee.tsx index d17fb509..2a2e0dca 100644 --- a/src/screens/Committees/Committee.tsx +++ b/src/screens/Committees/Committee.tsx @@ -22,7 +22,7 @@ import { CommitteesStackParams } from '../../types/navigation'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -const Committee: React.FC = ({ route, navigation }) => { +const Committee: React.FC = ({ route, navigation }) => { const initialCommittee = route.params.committee; const { name, color, logo, description, memberApplicationLink, representativeApplicationLink, leadApplicationLink, firebaseDocName, isOpen, memberCount } = initialCommittee; @@ -449,7 +449,7 @@ interface TeamMembersState { head: PublicUserInfo | null | undefined; } -export type CommitteeScreenProps = { +type CommitteeScreenRouteProps = { route: RouteProp; navigation: NativeStackNavigationProp; }; diff --git a/src/screens/admin/CommitteeEditor.tsx b/src/screens/Committees/CommitteeEditor.tsx similarity index 99% rename from src/screens/admin/CommitteeEditor.tsx rename to src/screens/Committees/CommitteeEditor.tsx index f4f9028a..ede6fd00 100644 --- a/src/screens/admin/CommitteeEditor.tsx +++ b/src/screens/Committees/CommitteeEditor.tsx @@ -8,7 +8,7 @@ import { PublicUserInfo } from '../../types/user'; import MembersList from '../../components/MembersList'; import CustomColorPicker from '../../components/CustomColorPicker'; import DismissibleModal from '../../components/DismissibleModal'; -import CommitteeTeamCard from '../Committees/CommitteeTeamCard'; +import CommitteeTeamCard from './CommitteeTeamCard'; import { CommitteesStackParams } from '../../types/navigation'; import { RouteProp } from '@react-navigation/core'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -780,7 +780,7 @@ interface TeamMembersState { head: PublicUserInfo | null | undefined; } -export type CommitteeEditorProps = { +type CommitteeEditorProps = { route: RouteProp; navigation: NativeStackNavigationProp; }; diff --git a/src/screens/Committees/CommitteeTeamCard.tsx b/src/screens/Committees/CommitteeTeamCard.tsx index f9180bc1..5d570420 100644 --- a/src/screens/Committees/CommitteeTeamCard.tsx +++ b/src/screens/Committees/CommitteeTeamCard.tsx @@ -55,7 +55,7 @@ const CommitteeTeamCard: React.FC = ({ userData, navigat ) } -export type CommitteeTeamCardProps = { +type CommitteeTeamCardProps = { userData: PublicUserInfo; navigation?: NativeStackNavigationProp } diff --git a/src/screens/admin/AdminDashboard.tsx b/src/screens/admin/AdminDashboard.tsx index 12652f3e..21d62234 100644 --- a/src/screens/admin/AdminDashboard.tsx +++ b/src/screens/admin/AdminDashboard.tsx @@ -4,11 +4,11 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Octicons } from '@expo/vector-icons'; import { httpsCallable, getFunctions } from 'firebase/functions'; -import { AdminDashboardParams } from '../../types/navigation'; +import { HomeStackParams } from '../../types/navigation'; const functions = getFunctions(); -const AdminDashboard = ({ navigation }: NativeStackScreenProps) => { +const AdminDashboard = ({ navigation }: NativeStackScreenProps) => { const committeeCountCheckOnCall = httpsCallable(functions, 'committeeCountCheckOnCall'); const updateAllUserPoints = httpsCallable(functions, 'updateAllUserPoints'); const updateCommitteeCount = httpsCallable(functions, 'updateCommitteeCount'); diff --git a/src/screens/admin/CommitteeConfirm.tsx b/src/screens/admin/CommitteeConfirm.tsx index c6c3982d..dc810c2c 100644 --- a/src/screens/admin/CommitteeConfirm.tsx +++ b/src/screens/admin/CommitteeConfirm.tsx @@ -2,7 +2,6 @@ import { View, Text, TouchableOpacity, ActivityIndicator, ScrollView } from 'rea import React, { useCallback, useEffect, useState } from 'react' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { Octicons } from '@expo/vector-icons'; -import { AdminDashboardParams } from '../../types/navigation'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; import DismissibleModal from '../../components/DismissibleModal'; import { Committee, getLogoComponent, reverseFormattedFirebaseName } from '../../types/committees'; @@ -14,8 +13,9 @@ import MembersList from '../../components/MembersList'; import MemberCard from '../../components/MemberCard'; import { httpsCallable } from 'firebase/functions'; import { calculateHexLuminosity } from '../../helpers'; +import { HomeStackParams } from '../../types/navigation'; -const CommitteeConfirm = ({ navigation }: NativeStackScreenProps) => { +const CommitteeConfirm = ({ navigation }: NativeStackScreenProps) => { const [committees, setCommittees] = useState([]); const [loading, setLoading] = useState(true); const [selectedCommittee, setSelectedCommittee] = useState(); diff --git a/src/screens/admin/FeedbackEditor.tsx b/src/screens/admin/FeedbackEditor.tsx index 626b9a3b..9ea34cbf 100644 --- a/src/screens/admin/FeedbackEditor.tsx +++ b/src/screens/admin/FeedbackEditor.tsx @@ -4,10 +4,10 @@ import { getAllFeedback, removeFeedback } from '../../api/firebaseUtils'; import { PublicUserInfo } from '../../types/user'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Octicons } from '@expo/vector-icons'; -import { AdminDashboardParams } from '../../types/navigation'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { HomeStackParams } from '../../types/navigation'; -const FeedbackEditor = ({ navigation }: NativeStackScreenProps) => { +const FeedbackEditor = ({ navigation }: NativeStackScreenProps) => { const [feedbacks, setFeedbacks] = useState([]); useEffect(() => { diff --git a/src/screens/admin/InstagramPoints.tsx b/src/screens/admin/InstagramPoints.tsx index a2e66bdb..65e3f271 100644 --- a/src/screens/admin/InstagramPoints.tsx +++ b/src/screens/admin/InstagramPoints.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react' import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Octicons } from '@expo/vector-icons'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { AdminDashboardParams } from '../../types/navigation'; +import { HomeStackParams } from '../../types/navigation'; import { fetchEventByName, getMembers } from '../../api/firebaseUtils'; import { PublicUserInfo } from '../../types/user'; import MemberCardMultipleSelect from '../../components/MemberCardMultipleSelect'; @@ -17,7 +17,7 @@ import { getFunctions, httpsCallable } from 'firebase/functions'; * The date should be set to the end of the school year. */ -const InstagramPoints = ({ navigation }: NativeStackScreenProps) => { +const InstagramPoints = ({ navigation }: NativeStackScreenProps) => { const [members, setMembers] = useState([]) const [event, setEvent] = useState(null) const [search, setSearch] = useState("") diff --git a/src/screens/admin/LinkEditor.tsx b/src/screens/admin/LinkEditor.tsx index 94857798..81dfc568 100644 --- a/src/screens/admin/LinkEditor.tsx +++ b/src/screens/admin/LinkEditor.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { View, Text, TextInput, Button, Image, TouchableOpacity, ScrollView, ActivityIndicator } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { AdminDashboardParams } from '../../types/navigation'; +import { HomeStackParams } from '../../types/navigation'; import { Octicons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import { updateLink, fetchLink } from '../../api/firebaseUtils'; @@ -16,7 +16,7 @@ const generateLinkIDs = (numberOfLinks: number): string[] => { const numberOfLinks = 7; -const LinkEditor = ({ navigation }: NativeStackScreenProps) => { +const LinkEditor = ({ navigation }: NativeStackScreenProps) => { const linkIDs = generateLinkIDs(numberOfLinks); const [links, setLinks] = useState( linkIDs.map(id => ({ diff --git a/src/screens/admin/MOTMEditor.tsx b/src/screens/admin/MOTMEditor.tsx index 89d595f2..58f8f12d 100644 --- a/src/screens/admin/MOTMEditor.tsx +++ b/src/screens/admin/MOTMEditor.tsx @@ -5,7 +5,7 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { Octicons } from '@expo/vector-icons'; import { getMOTM, getMembersExcludeOfficers, setMOTM } from '../../api/firebaseUtils'; import { httpsCallable, getFunctions } from 'firebase/functions'; -import { AdminDashboardParams } from '../../types/navigation'; +import { HomeStackParams } from '../../types/navigation'; import { PublicUserInfo } from '../../types/user'; import MembersList from '../../components/MembersList'; import DismissibleModal from '../../components/DismissibleModal'; @@ -17,7 +17,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler'; const functions = getFunctions(); -const MOTMEditor = ({ navigation }: NativeStackScreenProps) => { +const MOTMEditor = ({ navigation }: NativeStackScreenProps) => { const [members, setMembers] = useState([]) const [selectedMemberUID, setSelectedMemberUID] = useState(); const [selectedMember, setSelectedMember] = useState(); diff --git a/src/screens/admin/MemberSHPEConfirm.tsx b/src/screens/admin/MemberSHPEConfirm.tsx index fa9d2b7d..a57dc1a0 100644 --- a/src/screens/admin/MemberSHPEConfirm.tsx +++ b/src/screens/admin/MemberSHPEConfirm.tsx @@ -11,14 +11,14 @@ import { httpsCallable } from 'firebase/functions' import { handleLinkPress } from '../../helpers/links' import { formatExpirationDate } from '../../helpers/membership'; import { PublicUserInfo } from '../../types/user' -import { AdminDashboardParams } from '../../types/navigation'; +import { HomeStackParams } from '../../types/navigation'; import MemberCard from '../../components/MemberCard' import DismissibleModal from '../../components/DismissibleModal' import MembersList from '../../components/MembersList' import { UserContext } from '../../context/UserContext'; import { formatDate } from '../../helpers/timeUtils'; -const MemberSHPEConfirm = ({ navigation }: NativeStackScreenProps) => { +const MemberSHPEConfirm = ({ navigation }: NativeStackScreenProps) => { const { userInfo } = useContext(UserContext)!; const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; diff --git a/src/screens/admin/RestrictionsEditor.tsx b/src/screens/admin/RestrictionsEditor.tsx index 2c2fcd82..d62465db 100644 --- a/src/screens/admin/RestrictionsEditor.tsx +++ b/src/screens/admin/RestrictionsEditor.tsx @@ -2,7 +2,7 @@ import { View, Text, Image, TouchableOpacity, ScrollView, Modal } from 'react-na import React, { useEffect, useState } from 'react' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { Octicons } from '@expo/vector-icons'; -import { AdminDashboardParams } from '../../types/navigation'; +import { HomeStackParams } from '../../types/navigation'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; import MembersList from '../../components/MembersList'; import { PublicUserInfo } from '../../types/user'; @@ -12,7 +12,7 @@ import DismissibleModal from '../../components/DismissibleModal'; -const RestrictionsEditor = ({ navigation }: NativeStackScreenProps) => { +const RestrictionsEditor = ({ navigation }: NativeStackScreenProps) => { const [members, setMembers] = useState([]) const [watchList, setWatchList] = useState([]) const [blackList, setBlackList] = useState([]) diff --git a/src/screens/admin/ResumeConfirm.tsx b/src/screens/admin/ResumeConfirm.tsx index f2092769..edb943a4 100644 --- a/src/screens/admin/ResumeConfirm.tsx +++ b/src/screens/admin/ResumeConfirm.tsx @@ -9,12 +9,12 @@ import { deleteDoc, deleteField, doc, getDoc, updateDoc } from 'firebase/firesto import { httpsCallable } from 'firebase/functions' import { handleLinkPress } from '../../helpers/links' import { PublicUserInfo } from '../../types/user' -import { AdminDashboardParams } from '../../types/navigation'; +import { HomeStackParams } from '../../types/navigation'; import MemberCard from '../../components/MemberCard' import DismissibleModal from '../../components/DismissibleModal' import MembersList from '../../components/MembersList' -const ResumeConfirm = ({ navigation }: NativeStackScreenProps) => { +const ResumeConfirm = ({ navigation }: NativeStackScreenProps) => { const [members, setMembers] = useState([]); const [selectedMemberUID, setSelectedMemberUID] = useState(); const [selectedMember, setSelectedMember] = useState(); diff --git a/src/screens/admin/ResumeDownloader.tsx b/src/screens/admin/ResumeDownloader.tsx index 28da43c6..8e769827 100644 --- a/src/screens/admin/ResumeDownloader.tsx +++ b/src/screens/admin/ResumeDownloader.tsx @@ -9,14 +9,9 @@ import { db } from '../../config/firebaseConfig'; import { getFunctions, httpsCallable } from 'firebase/functions'; import { doc, onSnapshot } from 'firebase/firestore'; import { handleLinkPress } from '../../helpers/links'; -import { AdminDashboardParams } from '../../types/navigation'; +import { HomeStackParams } from '../../types/navigation'; -interface ResumeDownloadInfo { - url: string; - createdAt: Date; - expiresAt: Date; -} -const ResumeDownloader = ({ navigation }: NativeStackScreenProps) => { +const ResumeDownloader = ({ navigation }: NativeStackScreenProps) => { const [isGenerated, setIsGenerated] = useState(false); const [loadingShare, setLoadingShare] = useState(false); const [resumeDownloadInfo, setResumeDownloadInfo] = useState(); @@ -165,5 +160,10 @@ const ResumeDownloader = ({ navigation }: NativeStackScreenProps) => { +const ShirtConfirm = ({ navigation }: NativeStackScreenProps) => { const [pickedUpMembers, setPickedUpMembers] = useState([]); const [notPickedUpMembers, setNotPickedUpMembers] = useState([]); const [selectedMemberUID, setSelectedMemberUID] = useState(); diff --git a/src/screens/events/EventInfo.tsx b/src/screens/events/EventInfo.tsx index 8a8072ff..13faaeaa 100644 --- a/src/screens/events/EventInfo.tsx +++ b/src/screens/events/EventInfo.tsx @@ -354,7 +354,7 @@ const EventInfo: React.FC = ({ route, navigation }) => { ) } -export type EventScreenRouteProp = { +type EventScreenRouteProp = { route: RouteProp; navigation: NativeStackNavigationProp; }; diff --git a/src/screens/events/EventVerification.tsx b/src/screens/events/EventVerification.tsx index 41439479..61d34277 100644 --- a/src/screens/events/EventVerification.tsx +++ b/src/screens/events/EventVerification.tsx @@ -157,7 +157,7 @@ const EventVerification: React.FC = ({ route, }; -export type EventVerificationScreenRouteProp = { +type EventVerificationScreenRouteProp = { route: RouteProp; navigation: NativeStackNavigationProp; }; diff --git a/src/screens/events/QRCodeManager.tsx b/src/screens/events/QRCodeManager.tsx index f13394e5..f205f540 100644 --- a/src/screens/events/QRCodeManager.tsx +++ b/src/screens/events/QRCodeManager.tsx @@ -11,14 +11,6 @@ import DismissibleModal from '../../components/DismissibleModal'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { EventsStackParams } from '../../types/navigation'; -export type QRCodeScreenRouteProp = { - route: RouteProp; - navigation: NativeStackNavigationProp; -}; - - -type QRCodeRef = { toDataURL: (callback: (data: string) => void) => void }; - const QRCodeManager: React.FC = ({ route, navigation }) => { const { event } = route.params; const [loading, setLoading] = useState(false); @@ -155,4 +147,12 @@ const QRCodeManager: React.FC = ({ route, navigation }) = ); }; +type QRCodeScreenRouteProp = { + route: RouteProp; + navigation: NativeStackNavigationProp; +}; + + +type QRCodeRef = { toDataURL: (callback: (data: string) => void) => void }; + export default QRCodeManager; diff --git a/src/screens/home/Ishpe.tsx b/src/screens/home/Ishpe.tsx index 5c7c76e9..896ed2fe 100644 --- a/src/screens/home/Ishpe.tsx +++ b/src/screens/home/Ishpe.tsx @@ -407,7 +407,7 @@ const formatWeekRange = (startDate: Date) => { type SHPEEventWithCommitteeData = SHPEEvent & { committeeData?: Committee }; -export type IShpeProps = { +type IShpeProps = { navigation?: NativeStackNavigationProp } diff --git a/src/screens/Committees/MemberSHPE.tsx b/src/screens/home/MemberSHPE.tsx similarity index 100% rename from src/screens/Committees/MemberSHPE.tsx rename to src/screens/home/MemberSHPE.tsx diff --git a/src/screens/resources/ResumeCard.tsx b/src/screens/resources/ResumeCard.tsx index 18952f00..8d932039 100644 --- a/src/screens/resources/ResumeCard.tsx +++ b/src/screens/resources/ResumeCard.tsx @@ -142,7 +142,7 @@ const ResumeCard: React.FC void }> = ({ r } -export type ResumeProps = { +type ResumeProps = { resumeData: PublicUserInfo navigation: NativeStackNavigationProp } diff --git a/src/screens/resources/TestCard.tsx b/src/screens/resources/TestCard.tsx index 9089cad8..62639f17 100644 --- a/src/screens/resources/TestCard.tsx +++ b/src/screens/resources/TestCard.tsx @@ -80,7 +80,7 @@ const TestCard: React.FC = ({ testData }) => { ) } -export type TestBankProps = { +type TestBankProps = { testData: Test; navigation: NativeStackNavigationProp } From fb6b9b2247c89455af01a66dc7393f54e72588fd Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 2 Jun 2024 20:38:59 -0500 Subject: [PATCH 009/198] remove color for committee --- package.json | 1 - src/components/CustomColorPicker.tsx | 150 --------------------- src/screens/Committees/CommitteeEditor.tsx | 6 - 3 files changed, 157 deletions(-) delete mode 100644 src/components/CustomColorPicker.tsx diff --git a/package.json b/package.json index 24789f1e..fb15107f 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,6 @@ "react-native-svg": "15.2.0", "react-native-view-shot": "3.8.0", "react-test-renderer": "18.2.0", - "reanimated-color-picker": "^2.4.1", "ts-jest": "^29.1.1" }, "devDependencies": { diff --git a/src/components/CustomColorPicker.tsx b/src/components/CustomColorPicker.tsx deleted file mode 100644 index 01f02c9b..00000000 --- a/src/components/CustomColorPicker.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { StyleSheet, Text, View, TouchableOpacity, Modal, TextInput } from 'react-native'; -import ColorPicker, { Panel3, colorKit, SaturationSlider } from 'reanimated-color-picker'; -import { calculateHexLuminosity, validateHexColor } from '../helpers/colorUtils'; - -export default function CustomColorPicker({ onColorChosen, initialColor = "#500000" }: CustomColorPickerProps) { - const [showPicker, setShowPicker] = useState(false); - const [selectedColor, setSelectedColor] = useState(initialColor); - const [hexInput, setHexInput] = useState(initialColor); - - const onColorSelect = (color: any) => { - setSelectedColor(color.hex); - setHexInput(color.hex); - }; - - - const handleClose = () => { - setShowPicker(false); - onColorChosen(selectedColor); - }; - - useEffect(() => { - onColorChosen(selectedColor); - }, []); - - - const isColorLight = (colorHex: string) => { - const luminosity = calculateHexLuminosity(colorHex); - return luminosity > 155; - }; - return ( - - setShowPicker(true)}> - - - Select a Color - - - - - handleClose()} - > - - - Select a Color - { - setHexInput(text); - if (validateHexColor(text)) { - setSelectedColor(text); - } - }} - value={hexInput} - placeholder="Enter Hex Code" - autoCapitalize="characters" - maxLength={7} - /> - - - - - handleClose()} - > - - - Close - - - - - - - - ); -} - -type CustomColorPickerProps = { - onColorChosen: (color: string) => void; - initialColor?: string; -}; - -const styles = StyleSheet.create({ - hexInput: { - height: 40, - borderColor: 'gray', - borderWidth: 1, - marginVertical: 10, - paddingHorizontal: 10, - borderRadius: 5, - width: '100%', - textAlign: 'center', - }, - modalTitle: { - fontSize: 18, - fontWeight: 'bold', - textAlign: 'center', - marginBottom: 20, - }, - modalOverlay: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: 'rgba(0,0,0,0.5)', - }, - container: { - flex: 1, - }, - pickerPopup: { - width: 300, - backgroundColor: '#fff', - padding: 20, - borderRadius: 20, - elevation: 10, - }, - panelStyle: { - borderRadius: 16, - elevation: 5, - }, - sliderStyle: { - borderRadius: 20, - marginTop: 20, - elevation: 5, - }, - openButton: { - width: '100%', - borderRadius: 20, - paddingHorizontal: 40, - paddingVertical: 10, - marginTop: 10, - backgroundColor: '#fff', - elevation: 5, - }, - buttonText: { - color: '#707070', - fontWeight: 'bold', - textAlign: 'center', - }, -}); diff --git a/src/screens/Committees/CommitteeEditor.tsx b/src/screens/Committees/CommitteeEditor.tsx index ede6fd00..ea5a24b3 100644 --- a/src/screens/Committees/CommitteeEditor.tsx +++ b/src/screens/Committees/CommitteeEditor.tsx @@ -6,7 +6,6 @@ import { deleteCommittee, getLeads, getPublicUserData, getRepresentatives, getTe import { Committee, committeeLogos, getLogoComponent } from '../../types/committees'; import { PublicUserInfo } from '../../types/user'; import MembersList from '../../components/MembersList'; -import CustomColorPicker from '../../components/CustomColorPicker'; import DismissibleModal from '../../components/DismissibleModal'; import CommitteeTeamCard from './CommitteeTeamCard'; import { CommitteesStackParams } from '../../types/navigation'; @@ -358,11 +357,6 @@ const CommitteeEditor = ({ navigation, route }: CommitteeEditorProps) => { /> - {selectedLogoData && ( - - - - )} From e48f77b330478ddf2042774dff232268a42bee48 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 2 Jun 2024 21:48:21 -0500 Subject: [PATCH 010/198] remove splash screen, add photo gallery default image --- src/components/FlickrPhotoGallery.tsx | 86 +++++++++++++++++---------- src/navigation/index.tsx | 10 +--- src/screens/Splash.tsx | 37 ------------ 3 files changed, 58 insertions(+), 75 deletions(-) delete mode 100644 src/screens/Splash.tsx diff --git a/src/components/FlickrPhotoGallery.tsx b/src/components/FlickrPhotoGallery.tsx index 49efc484..99159ff0 100644 --- a/src/components/FlickrPhotoGallery.tsx +++ b/src/components/FlickrPhotoGallery.tsx @@ -1,38 +1,24 @@ import { LinearGradient } from 'expo-linear-gradient'; import React, { useEffect, useState, useRef, memo } from 'react'; import { Animated, Image, Dimensions, View, NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; +import { Images } from '../../assets'; const windowWidth = Dimensions.get('window').width; -const flickerApiKey = process.env.FLICKER_API_KEY; -const flickerUserId = process.env.FLICKER_USER_ID; -const flickrPhotoSetId = '72177720316068498'; - - -const FlickrPhotoItem = memo(({ item }: { item: FlickrPhoto }) => { - if (!item) return null; - - const photoUrl = `https://live.staticflickr.com/${item.server}/${item.id}_${item.secret}_w.jpg`; - return ( - - - - - - - ); -}); const FlickrPhotoGallery = () => { const [currentIndex, setCurrentIndex] = useState(1); - const [photos, setPhotos] = useState([]); + const [photos, setPhotos] = useState<(FlickrPhoto | null)[]>([]); + const [photosFetched, setPhotosFetched] = useState(false); // Add this flag const photoListRef = useRef(null); const scrollX = useRef(new Animated.Value(0)).current; const slideInterval = useRef(); + const flickerApiKey = process.env.FLICKER_API_KEY; + const flickerUserId = "143848472@N03"; + const flickrPhotoSetId = '72177720316068498'; + + const shufflePhotos = (array: FlickrPhoto[]) => { for (let i = array.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); @@ -42,16 +28,24 @@ const FlickrPhotoGallery = () => { }; const fetchPhotos = async () => { + try { const url = `https://www.flickr.com/services/rest/?method=flickr.photosets.getPhotos&api_key=${flickerApiKey}&user_id=${flickerUserId}&photoset_id=${flickrPhotoSetId}&format=json&nojsoncallback=1`; const response = await fetch(url); const json = await response.json(); - const shuffledPhotos = shufflePhotos(json.photoset.photo); - setPhotos([shuffledPhotos[shuffledPhotos.length - 1], ...shuffledPhotos, shuffledPhotos[0]]); + if (json && json.photoset && json.photoset.photo.length > 0) { + const shuffledPhotos = shufflePhotos(json.photoset.photo); + setPhotos([shuffledPhotos[shuffledPhotos.length - 1], ...shuffledPhotos, shuffledPhotos[0]]); + setPhotosFetched(true); // Set the flag to true + } else { + setPhotos([null]); // Add a null item to trigger the default image + setPhotosFetched(false); // Ensure flag is false if no photos + } } catch (error) { - console.error(error); + setPhotos([null]); // Add a null item to trigger the default image + setPhotosFetched(false); // Ensure flag is false if error } }; @@ -67,7 +61,9 @@ const FlickrPhotoGallery = () => { setCurrentIndex(prevIndex => { let nextIndex = prevIndex + 1; if (nextIndex >= photos.length) { - photoListRef.current?.scrollToIndex({ animated: false, index: 1 }); + if (photos.length > 2) { + photoListRef.current?.scrollToIndex({ animated: false, index: 1 }); + } nextIndex = 1; } else { photoListRef.current?.scrollToIndex({ animated: true, index: nextIndex }); @@ -98,7 +94,7 @@ const FlickrPhotoGallery = () => { }; useEffect(() => { - if (photos.length > 0) { + if (photos.length > 2) { photoListRef.current?.scrollToIndex({ animated: false, index: 1 }); resetAndStartAutoSlide(); } @@ -111,15 +107,45 @@ const FlickrPhotoGallery = () => { index, }); - const renderItem = ({ item }: { item: FlickrPhoto }) => ; + const FlickrPhotoItem = memo(({ item }: { item: FlickrPhoto | null }) => { + const photoUrl = item ? `https://live.staticflickr.com/${item.server}/${item.id}_${item.secret}_w.jpg` : null; + return ( + + + + + + + ); + }); - if (!photos.length) return null; + const renderItem = ({ item }: { item: FlickrPhoto | null }) => ; + + if (!photosFetched) { + return ( + + + + + + + ); + } return ( `${item.id} - ${index}`} + keyExtractor={(item, index) => `${item ? item.id : 'default'} - ${index}`} horizontal pagingEnabled showsHorizontalScrollIndicator={false} diff --git a/src/navigation/index.tsx b/src/navigation/index.tsx index 2bd1ebf0..ad7c222a 100644 --- a/src/navigation/index.tsx +++ b/src/navigation/index.tsx @@ -5,22 +5,21 @@ import { Timestamp } from 'firebase/firestore'; import { UserContext } from '../context/UserContext'; import { AuthStack } from './AuthStack'; import { MainStack } from './MainStack'; -import Splash from '../screens/Splash'; import { Images } from '../../assets'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { setPrivateUserData } from '../api/firebaseUtils'; import { getFunctions, httpsCallable } from 'firebase/functions'; import { auth } from '../config/firebaseConfig'; + /** * Renders the root navigator for the application. - * It determines whether to show the splash screen, authentication stack, or main stack + * It determines whether to show the authentication stack or main stack * based on the user's authentication status and profile setup. * * @returns The rendered root navigator. */ const RootNavigator = () => { const { userInfo, setUserInfo, userLoading, signOutUser } = useContext(UserContext)!; - const [splashLoading, setSplashLoading] = useState(true); /** * OLD IMPLEMENTATION - checkDataExpiration that is originally created to update a user data if it is expired @@ -103,11 +102,6 @@ const RootNavigator = () => { handleBannedUser(); }, [auth.currentUser?.uid]) - - if (splashLoading) { - return ; - } - if (userLoading) { return ; } diff --git a/src/screens/Splash.tsx b/src/screens/Splash.tsx deleted file mode 100644 index 1f6d87de..00000000 --- a/src/screens/Splash.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React, { useEffect, useRef } from 'react' -import LottieView from "lottie-react-native"; -import { View } from 'react-native'; -import { StatusBar } from 'expo-status-bar'; - -const Splash = ({ setIsLoading }: SplashProps) => { - // IOS bugged autoplay on lottie, so we need to use a ref to play the animation - const animationRef = useRef(null); - - useEffect(() => { - const timeoutId = setTimeout(() => animationRef.current?.play()); - - return () => { - clearTimeout(timeoutId); - animationRef.current?.reset(); - } - }, []); - - return ( - - - setIsLoading(false)} - /> - - ) -} - -interface SplashProps { - setIsLoading: React.Dispatch> -} - -export default Splash \ No newline at end of file From e79fa6e0a72b1b75bdc867b86cea79eef6834871 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 2 Jun 2024 21:54:10 -0500 Subject: [PATCH 011/198] increase slide time --- src/components/FlickrPhotoGallery.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/FlickrPhotoGallery.tsx b/src/components/FlickrPhotoGallery.tsx index 99159ff0..8b2f6b94 100644 --- a/src/components/FlickrPhotoGallery.tsx +++ b/src/components/FlickrPhotoGallery.tsx @@ -70,7 +70,7 @@ const FlickrPhotoGallery = () => { } return nextIndex; }); - }, 3000); // Change slide every 3 seconds + }, 7000); // Change slide every 7 seconds }; const onScrollEnd = (e: NativeSyntheticEvent) => { From c66434336ea9d1def49d15763e0c14a0c0786282 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 3 Jun 2024 01:55:03 -0500 Subject: [PATCH 012/198] remove unused files --- src/components/Paginator.tsx | 42 ------------------------------------ src/types/slides.ts | 7 ------ 2 files changed, 49 deletions(-) delete mode 100644 src/components/Paginator.tsx delete mode 100644 src/types/slides.ts diff --git a/src/components/Paginator.tsx b/src/components/Paginator.tsx deleted file mode 100644 index 43917207..00000000 --- a/src/components/Paginator.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { View, Animated, useWindowDimensions } from 'react-native' -import React from 'react' -import { Slide } from '../types/slides' - -/** - * This component displays a horizontal row of dots, corresponding to each slide in the slideshow. - * The dot corresponding to the currently-viewed slide has increased opacity, making it appear "active". - * - * @param data An array of slides to be displayed. - * @param scrollX An animated value representing the horizontal scroll position of the slide list. - * @returns Returns the rendered Paginator component. - */ -const Paginator: React.FC = ({ data, scrollX }) => { - const { width } = useWindowDimensions(); - return ( - - {data.map((_: Slide, i: number) => { - const inputRange = [(i - 1) * width, i * width, (i + 1) * width]; - const opacity = scrollX.interpolate({ - inputRange, - outputRange: [0.3, 1, 0.3], - extrapolate: "clamp", - }) - - return ( - - ); - })} - - ) -} - -interface PaginatorProps { - data: Slide[]; - scrollX: Animated.Value; -} - -export default Paginator \ No newline at end of file diff --git a/src/types/slides.ts b/src/types/slides.ts deleted file mode 100644 index 9d742aa3..00000000 --- a/src/types/slides.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface Slide { - url: string; - createdAt: string; - id: string; - fireStoreLocation: string; -} - From c74eca58453ab810d1388f197c78c144ba9e26b0 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 3 Jun 2024 02:13:24 -0500 Subject: [PATCH 013/198] rearrange files --- src/navigation/CommitteesStack.tsx | 8 +- src/navigation/EventsStack.tsx | 2 +- src/navigation/HomeStack.tsx | 6 +- src/navigation/ResourcesStack.tsx | 2 +- src/navigation/UserProfileStack.tsx | 6 +- .../PersonalEventLog.tsx | 0 .../{ => UserProfile}/PublicProfile.tsx | 26 +- .../{home => UserProfile}/Settings.tsx | 0 src/screens/committees/Committee.tsx | 457 ++++++++++ src/screens/committees/CommitteeCard.tsx | 90 ++ src/screens/committees/CommitteeEditor.tsx | 783 ++++++++++++++++++ src/screens/committees/CommitteeTeamCard.tsx | 63 ++ src/screens/committees/Committees.tsx | 114 +++ src/screens/home/Home.tsx | 8 +- src/screens/{ => home}/Members.tsx | 10 +- .../{resources => home}/OfficeHours.tsx | 0 .../{resources => home}/OfficeSignIn.tsx | 0 src/screens/resources/Resources.tsx | 8 +- 18 files changed, 1543 insertions(+), 40 deletions(-) rename src/screens/{events => UserProfile}/PersonalEventLog.tsx (100%) rename src/screens/{ => UserProfile}/PublicProfile.tsx (96%) rename src/screens/{home => UserProfile}/Settings.tsx (100%) create mode 100644 src/screens/committees/Committee.tsx create mode 100644 src/screens/committees/CommitteeCard.tsx create mode 100644 src/screens/committees/CommitteeEditor.tsx create mode 100644 src/screens/committees/CommitteeTeamCard.tsx create mode 100644 src/screens/committees/Committees.tsx rename src/screens/{ => home}/Members.tsx (97%) rename src/screens/{resources => home}/OfficeHours.tsx (100%) rename src/screens/{resources => home}/OfficeSignIn.tsx (100%) diff --git a/src/navigation/CommitteesStack.tsx b/src/navigation/CommitteesStack.tsx index 33beb192..2362fc65 100644 --- a/src/navigation/CommitteesStack.tsx +++ b/src/navigation/CommitteesStack.tsx @@ -1,11 +1,11 @@ import React from "react"; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { CommitteesStackParams } from '../types/navigation'; -import CommitteeScreen from "../screens/Committees/Committee"; -import PublicProfileScreen from "../screens/PublicProfile"; -import CommitteeEditor from "../screens/Committees/CommitteeEditor"; +import CommitteeScreen from "../screens/committees/Committee"; +import PublicProfileScreen from "../screens/UserProfile/PublicProfile"; +import CommitteeEditor from "../screens/committees/CommitteeEditor"; import EventInfo from "../screens/events/EventInfo"; -import CommitteesScreen from "../screens/Committees/Committees"; +import CommitteesScreen from "../screens/committees/Committees"; import UpdateEvent from "../screens/events/UpdateEvent"; import QRCodeManager from "../screens/events/QRCodeManager"; diff --git a/src/navigation/EventsStack.tsx b/src/navigation/EventsStack.tsx index ffc44daa..2f69b0f6 100644 --- a/src/navigation/EventsStack.tsx +++ b/src/navigation/EventsStack.tsx @@ -11,7 +11,7 @@ import SetSpecificEventDetails from "../screens/events/SetSpecificEventDetails"; import FinalizeEvent from "../screens/events/FinalizeEvent"; import SetLocationEventDetails from "../screens/events/SetLocationEventDetails"; import EventVerification from "../screens/events/EventVerification"; -import PublicProfileScreen from "../screens/PublicProfile"; +import PublicProfileScreen from "../screens/UserProfile/PublicProfile"; const EventsStack = () => { const Stack = createNativeStackNavigator(); diff --git a/src/navigation/HomeStack.tsx b/src/navigation/HomeStack.tsx index 2c80eb98..eab529be 100644 --- a/src/navigation/HomeStack.tsx +++ b/src/navigation/HomeStack.tsx @@ -1,7 +1,7 @@ import React from "react"; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { HomeStackParams } from "../types/navigation" -import PublicProfileScreen from "../screens/PublicProfile"; +import PublicProfileScreen from "../screens/UserProfile/PublicProfile"; import Home from "../screens/home/Home" import EventInfo from "../screens/events/EventInfo"; import AdminDashboard from "../screens/admin/AdminDashboard"; @@ -18,12 +18,12 @@ import InstagramPoints from "../screens/admin/InstagramPoints"; import UpdateEvent from "../screens/events/UpdateEvent"; import QRCodeManager from "../screens/events/QRCodeManager"; import MemberSHPE from "../screens/home/MemberSHPE"; -import Members from "../screens/Members"; +import Members from "../screens/home/Members"; const HomeStack = () => { const Stack = createNativeStackNavigator(); return ( - + diff --git a/src/navigation/ResourcesStack.tsx b/src/navigation/ResourcesStack.tsx index 8954d3a3..7a75e1a1 100644 --- a/src/navigation/ResourcesStack.tsx +++ b/src/navigation/ResourcesStack.tsx @@ -4,7 +4,7 @@ import Resources from "../screens/resources/Resources"; import PointsLeaderboard from "../screens/resources/PointsLeaderboard"; import TestBank from "../screens/resources/TestBank"; import ResumeBank from "../screens/resources/ResumeBank"; -import PublicProfileScreen from "../screens/PublicProfile"; +import PublicProfileScreen from "../screens/UserProfile/PublicProfile"; import { ResourcesStackParams } from '../types/navigation'; const ResourcesStack = () => { diff --git a/src/navigation/UserProfileStack.tsx b/src/navigation/UserProfileStack.tsx index fcde19d1..2afe937d 100644 --- a/src/navigation/UserProfileStack.tsx +++ b/src/navigation/UserProfileStack.tsx @@ -1,11 +1,11 @@ import React, { useContext } from "react"; import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import PublicProfileScreen from "../screens/PublicProfile"; -import { AboutSettingsScreen, AccountSettingsScreen, DisplaySettingsScreen, FAQSettingsScreen, FeedBackSettingsScreen, ProfileSettingsScreen, SettingsScreen } from "../screens/home/Settings"; +import PublicProfileScreen from "../screens/UserProfile/PublicProfile"; +import { AboutSettingsScreen, AccountSettingsScreen, DisplaySettingsScreen, FAQSettingsScreen, FeedBackSettingsScreen, ProfileSettingsScreen, SettingsScreen } from "../screens/UserProfile/Settings"; import { UserContext } from "../context/UserContext"; import { UserProfileStackParams } from "../types/navigation"; import { auth } from "../config/firebaseConfig"; -import PersonalEventLog from "../screens/events/PersonalEventLog"; +import PersonalEventLog from "../screens/UserProfile/PersonalEventLog"; const UserProfileStack = () => { const Stack = createNativeStackNavigator(); diff --git a/src/screens/events/PersonalEventLog.tsx b/src/screens/UserProfile/PersonalEventLog.tsx similarity index 100% rename from src/screens/events/PersonalEventLog.tsx rename to src/screens/UserProfile/PersonalEventLog.tsx diff --git a/src/screens/PublicProfile.tsx b/src/screens/UserProfile/PublicProfile.tsx similarity index 96% rename from src/screens/PublicProfile.tsx rename to src/screens/UserProfile/PublicProfile.tsx index 897fb970..4046a153 100644 --- a/src/screens/PublicProfile.tsx +++ b/src/screens/UserProfile/PublicProfile.tsx @@ -7,19 +7,19 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { Octicons, FontAwesome } from '@expo/vector-icons'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { StatusBar } from 'expo-status-bar'; -import { UserContext } from '../context/UserContext'; -import { auth } from '../config/firebaseConfig'; -import { getCommittees, getPublicUserData, getUser, queryUserEventLogs, setUserRoles } from '../api/firebaseUtils'; -import { getBadgeColor, isMemberVerified } from '../helpers/membership'; -import { handleLinkPress } from '../helpers/links'; -import { UserProfileStackParams } from '../types/navigation'; -import { PublicUserInfo, Roles } from '../types/user'; -import { Committee } from '../types/committees'; -import { Images } from '../../assets'; -import TwitterSvg from '../components/TwitterSvg'; -import ProfileBadge from '../components/ProfileBadge'; -import DismissibleModal from '../components/DismissibleModal'; -import { UserEventData } from '../types/events'; +import { UserContext } from '../../context/UserContext'; +import { auth } from '../../config/firebaseConfig'; +import { getCommittees, getPublicUserData, getUser, queryUserEventLogs, setUserRoles } from '../../api/firebaseUtils'; +import { getBadgeColor, isMemberVerified } from '../../helpers/membership'; +import { handleLinkPress } from '../../helpers/links'; +import { UserProfileStackParams } from '../../types/navigation'; +import { PublicUserInfo, Roles } from '../../types/user'; +import { Committee } from '../../types/committees'; +import { Images } from '../../../assets'; +import TwitterSvg from '../../components/TwitterSvg'; +import ProfileBadge from '../../components/ProfileBadge'; +import DismissibleModal from '../../components/DismissibleModal'; +import { UserEventData } from '../../types/events'; import { Timestamp } from 'firebase/firestore'; export type PublicProfileScreenProps = { diff --git a/src/screens/home/Settings.tsx b/src/screens/UserProfile/Settings.tsx similarity index 100% rename from src/screens/home/Settings.tsx rename to src/screens/UserProfile/Settings.tsx diff --git a/src/screens/committees/Committee.tsx b/src/screens/committees/Committee.tsx new file mode 100644 index 00000000..2a2e0dca --- /dev/null +++ b/src/screens/committees/Committee.tsx @@ -0,0 +1,457 @@ +import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator } from 'react-native' +import React, { useCallback, useContext, useEffect, useState } from 'react' +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { collection, deleteDoc, doc, getDoc, getDocs, setDoc } from 'firebase/firestore'; +import { Octicons } from '@expo/vector-icons'; +import { StatusBar } from 'expo-status-bar'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { UserContext } from '../../context/UserContext'; +import { getCommitteeEvents, getPublicUserData, setPublicUserData } from '../../api/firebaseUtils'; +import { calculateHexLuminosity } from '../../helpers/colorUtils'; +import { handleLinkPress } from '../../helpers/links'; +import { getLogoComponent } from '../../types/committees'; +import { SHPEEvent } from '../../types/events'; +import { PublicUserInfo } from '../../types/user'; +import DismissibleModal from '../../components/DismissibleModal'; +import { auth, db } from '../../config/firebaseConfig'; +import EventsList from '../../components/EventsList'; +import MembersList from '../../components/MembersList'; +import CommitteeTeamCard from './CommitteeTeamCard'; +import { RouteProp } from '@react-navigation/core'; +import { CommitteesStackParams } from '../../types/navigation'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; + + +const Committee: React.FC = ({ route, navigation }) => { + const initialCommittee = route.params.committee; + + const { name, color, logo, description, memberApplicationLink, representativeApplicationLink, leadApplicationLink, firebaseDocName, isOpen, memberCount } = initialCommittee; + const [events, setEvents] = useState([]); + const { LogoComponent, height, width } = getLogoComponent(logo); + const luminosity = calculateHexLuminosity(color!); + const isLightColor = luminosity < 155; + + const { userInfo, setUserInfo } = useContext(UserContext)!; + const [isInCommittee, setIsInCommittee] = useState(); + const [confirmVisible, setConfirmVisible] = useState(false); + const [loading, setLoading] = useState(true); + const [loadingMembers, setLoadingMembers] = useState(false); + const [loadingCountChange, setLoadingCountChange] = useState(false) + const [isRequesting, setIsRequesting] = useState(false); + const [members, setMembers] = useState([]); + const [forceUpdate, setForceUpdate] = useState(0); + const [membersListVisible, setMembersListVisible] = useState(false); + const insets = useSafeAreaInsets(); + + const [localTeamMembers, setLocalTeamMembers] = useState({ + leads: [], + representatives: [], + head: null, + }); + + useEffect(() => { + const fetchEvents = async () => { + setLoading(true); + const response = await getCommitteeEvents([firebaseDocName!]); + setEvents(response); + setLoading(false); + } + + const fetchUserData = async () => { + if (initialCommittee) { + const { head, representatives, leads } = initialCommittee; + const newTeamMembers: TeamMembersState = { leads: [], representatives: [], head: null }; + + if (head) { + const headData = await getPublicUserData(head); + if (headData) { + headData.uid = head; + newTeamMembers.head = headData; + } + } + + if (representatives && representatives.length > 0) { + newTeamMembers.representatives = await Promise.all( + representatives.map(async (uid) => { + const repData = await getPublicUserData(uid); + if (repData) { + repData.uid = uid; + } + return repData; + }) + ); + } + + if (leads && leads.length > 0) { + newTeamMembers.leads = await Promise.all( + leads.map(async (uid) => { + const leadData = await getPublicUserData(uid); + if (leadData) { + leadData.uid = uid; + } + return leadData; + }) + ); + } + + setLocalTeamMembers(newTeamMembers); + } + }; + + fetchEvents(); + fetchUserData(); + }, []) + + useEffect(() => { + const committeeExists = userInfo?.publicInfo?.committees?.includes(firebaseDocName!); + setIsInCommittee(committeeExists); + }, [userInfo]); + + useEffect(() => { + const checkRequestStatus = async () => { + if (auth.currentUser && !isInCommittee) { + const requestRef = doc(db, `committeeVerification/${firebaseDocName}/requests/${auth.currentUser.uid}`); + const requestSnapshot = await getDoc(requestRef); + setIsRequesting(requestSnapshot.exists()); + } + }; + + checkRequestStatus(); + }, [auth.currentUser, isInCommittee, firebaseDocName, db]); + + const fetchCommitteeMembers = async (committeeFirebaseDocName: string) => { + // Force update only happens once + if (forceUpdate == 1) { + return; + } + setLoadingMembers(true); + + const allUsersSnapshot = await await getDocs(collection(db, 'users')); + const committeeMembers: PublicUserInfo[] = []; + + for (const userDoc of allUsersSnapshot.docs) { + const userData = userDoc.data(); + if (userData.committees && userData.committees.includes(committeeFirebaseDocName)) { + committeeMembers.push({ ...userData, uid: userDoc.id }); + } + } + + setMembers(committeeMembers); + setLoadingMembers(false); + setForceUpdate(1); + }; + + const submitCommitteeRequest = useCallback(async () => { + if (auth.currentUser) { + await setDoc(doc(db, `committeeVerification/${firebaseDocName}/requests/${auth.currentUser.uid}`), { + uploadDate: new Date().toISOString(), + }, { merge: true }); + } + }, [userInfo]); + + const removeCommitteeRequest = useCallback(async () => { + if (auth.currentUser) { + const requestDocRef = doc(db, `committeeVerification/${firebaseDocName}/requests/${auth.currentUser.uid}`); + await deleteDoc(requestDocRef); + } + }, [userInfo]); + + const handleJoinLeave = async () => { + setLoadingCountChange(true); + if (isInCommittee) { + let updatedCommittees = [...userInfo?.publicInfo?.committees || []]; + updatedCommittees = updatedCommittees.filter(c => c !== firebaseDocName); + + try { + await setPublicUserData({ committees: updatedCommittees }); + + const updatedUserInfo = { + ...userInfo, + publicInfo: { + ...userInfo?.publicInfo, + committees: updatedCommittees + } + }; + + try { + await AsyncStorage.setItem("@user", JSON.stringify(updatedUserInfo)); + setUserInfo(updatedUserInfo); + } catch (error) { + console.error("Error updating user info:", error); + } + + } catch (err) { + console.error(err); + } + } else { + if (isOpen) { + let updatedCommittees = [...userInfo?.publicInfo?.committees || []]; + updatedCommittees.push(firebaseDocName!); + + try { + await setPublicUserData({ committees: updatedCommittees }); + + const updatedUserInfo = { + ...userInfo, + publicInfo: { + ...userInfo?.publicInfo, + committees: updatedCommittees + } + }; + + try { + await AsyncStorage.setItem("@user", JSON.stringify(updatedUserInfo)); + setUserInfo(updatedUserInfo); + } catch (error) { + console.error("Error updating user info:", error); + } + + } catch (err) { + console.error(err); + } + } else { + submitCommitteeRequest(); + setIsRequesting(true); + } + } + setLoadingCountChange(false); + } + + return ( + + + {/* Header */} + + + + {name} + { + setMembersListVisible(true); + fetchCommitteeMembers(firebaseDocName!); + }} + > + {memberCount} Members + + + navigation.goBack()}> + + + + + + {/* Content */} + + + {/* Logo and Join/Leave Button */} + + + + + { + if (!userInfo?.publicInfo?.isStudent) { + alert("You must be a student to join a committee.") + return; + } + + if (isRequesting) { + removeCommitteeRequest(); + setIsRequesting(false) + } else { + // Join or Leave confirmation + setConfirmVisible(!confirmVisible) + } + }} + disabled={loadingCountChange} + > + + {loadingCountChange ? ( + + ) : ( + + {isInCommittee ? "Leave" : isRequesting ? "Cancel\nRequest" : "Join"} + + )} + + + + {/* Name and Application Buttons */} + + + {name} + {isOpen ? "(Open)" : "(Closed)"} + + + {memberApplicationLink && ( + handleLinkPress(memberApplicationLink!)} + > + Member Application + + )} + {representativeApplicationLink && ( + + handleLinkPress(representativeApplicationLink!)} + > + Representative Application + + )} + {leadApplicationLink && ( + + handleLinkPress(leadApplicationLink!)} + > + Lead Application + + )} + + + + + {/* About */} + + About + {description} + + + {/* Upcoming Events */} + + Upcoming Events + + + + {/* Team List */} + + Meet the Team + + + Head + + + {localTeamMembers.representatives && localTeamMembers.representatives.length > 0 && ( + <> + Representatives + {localTeamMembers.representatives.map((representative, index) => ( + + + + ))} + + )} + {localTeamMembers.leads && localTeamMembers.leads.length > 0 && ( + <> + Leads + {localTeamMembers.leads.map((lead, index) => ( + + + + ))} + + )} + + + + + + + + + + + {name} + + + { + setMembersListVisible(false); + }}> + + + + + + {loadingMembers && ( + + + + )} + + + { + navigation.navigate('PublicProfile', { uid }) + setMembersListVisible(false); + }} + users={members} + /> + + + + + + + + + + + {isInCommittee ? "Are you sure you want leave?" : "Are you sure you want to join?"} + + { + setConfirmVisible(false); + handleJoinLeave(); + }} + > + {isInCommittee ? "Leave" : "Join"} + + + { setConfirmVisible(false) }} > + Cancel + + + + + + + ) +} + +interface TeamMembersState { + leads: (PublicUserInfo | undefined)[]; + representatives: (PublicUserInfo | undefined)[]; + head: PublicUserInfo | null | undefined; +} + +type CommitteeScreenRouteProps = { + route: RouteProp; + navigation: NativeStackNavigationProp; +}; + +export default Committee \ No newline at end of file diff --git a/src/screens/committees/CommitteeCard.tsx b/src/screens/committees/CommitteeCard.tsx new file mode 100644 index 00000000..c3a2b666 --- /dev/null +++ b/src/screens/committees/CommitteeCard.tsx @@ -0,0 +1,90 @@ +import { View, Text, Image, TouchableOpacity } from 'react-native'; +import React, { useContext, useEffect, useState } from 'react' +import { UserContext } from '../../context/UserContext'; +import { calculateHexLuminosity } from '../../helpers/colorUtils'; +import { Committee, getLogoComponent } from "../../types/committees"; +import { Images } from "../../../assets" +import { PublicUserInfo } from '../../types/user'; +import { getPublicUserData } from '../../api/firebaseUtils'; + +const CommitteeCard: React.FC = ({ committee, handleCardPress, navigation }) => { + const { name, color, logo, head, memberCount } = committee; + const { userInfo } = useContext(UserContext)!; + const isSuperUser = userInfo?.publicInfo?.roles?.admin || userInfo?.publicInfo?.roles?.developer || userInfo?.publicInfo?.roles?.officer + const { LogoComponent, height, width } = getLogoComponent(logo); + + const isTextLight = (colorHex: string) => { + const luminosity = calculateHexLuminosity(colorHex); + return luminosity < 155; + }; + + const [localHead, setLocalHead] = useState(null); + + useEffect(() => { + const fetchHeadData = async () => { + if (head) { + const headData = await getPublicUserData(head); + setLocalHead(headData || null); + } + } + fetchHeadData(); + }, []) + + return ( + + { + if (navigation) { + navigation.navigate("CommitteeScreen", { committee }) + } + if (handleCardPress) { + handleCardPress(committee?.firebaseDocName!) + } + }} + className='flex-row w-[90%] h-28 rounded-xl' + style={{ backgroundColor: color }} + > + + + + + + {localHead && ( + + + + )} + + + + + {name} + + + {memberCount} Members + + + + + {isSuperUser && ( + { navigation.navigate("CommitteeEditor", { committee }) }} + className='absolute right-10 bg-pale-blue rounded-lg px-5 py-1 -top-3' + > + Edit + + )} + + ); +}; + + +interface CommitteeCardProps { + committee: Committee + navigation?: any + canEdit?: boolean + handleCardPress?: (uid: string) => string | void; +} + + +export default CommitteeCard; \ No newline at end of file diff --git a/src/screens/committees/CommitteeEditor.tsx b/src/screens/committees/CommitteeEditor.tsx new file mode 100644 index 00000000..ea5a24b3 --- /dev/null +++ b/src/screens/committees/CommitteeEditor.tsx @@ -0,0 +1,783 @@ +import { View, Text, TextInput, TouchableOpacity, ScrollView, Modal, Pressable, Switch, FlatList } from 'react-native' +import React, { useEffect, useState } from 'react' +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Octicons, FontAwesome } from '@expo/vector-icons'; +import { deleteCommittee, getLeads, getPublicUserData, getRepresentatives, getTeamMembers, resetCommittee, setCommitteeData } from '../../api/firebaseUtils'; +import { Committee, committeeLogos, getLogoComponent } from '../../types/committees'; +import { PublicUserInfo } from '../../types/user'; +import MembersList from '../../components/MembersList'; +import DismissibleModal from '../../components/DismissibleModal'; +import CommitteeTeamCard from './CommitteeTeamCard'; +import { CommitteesStackParams } from '../../types/navigation'; +import { RouteProp } from '@react-navigation/core'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +const CommitteeEditor = ({ navigation, route }: CommitteeEditorProps) => { + const committeeData = route?.params?.committee; + const [localCommitteeData, setLocalCommitteeData] = useState(committeeData || { + leads: [], + representatives: [], + memberCount: 0, + memberApplicationLink: '', + representativeApplicationLink: '', + leadApplicationLink: '', + isOpen: false + }); + + const [localTeamMembers, setLocalTeamMembers] = useState({ + leads: [], + representatives: [], + head: null, + }); + + const [logoSelectModal, setLogoSelectModal] = useState(false); + const [selectedLogoData, setSelectedLogoData] = useState<{ + LogoComponent: React.ElementType; + width: number; + height: number; + } | null>(null); + const [teamMembers, setTeamMembers] = useState([]) + const [representatives, setRepresentatives] = useState([]) + const [leads, setLeads] = useState([]) + const [headModalVisible, setHeadModalVisible] = useState(false); + const [leadsModalVisible, setLeadsModalVisible] = useState(false); + const [repsModalVisible, setRepsModalVisible] = useState(false); + const [resetModalVisible, setResetModalVisible] = useState(false); + const [deleteModalVisible, setDeleteModalVisible] = useState(false); + const [isMemberLinkActive, setIsMemberLinkActive] = useState(!!committeeData?.memberApplicationLink); + const [isRepLinkActive, setIsRepLinkActive] = useState(!!committeeData?.representativeApplicationLink); + const [isLeadLinkActive, setIsLeadLinkActive] = useState(!!committeeData?.leadApplicationLink); + const [isOpen, setIsOpen] = useState(!!committeeData?.isOpen); + + const insets = useSafeAreaInsets(); + + useEffect(() => { + const fetchUserData = async () => { + if (committeeData) { + const { head, representatives, leads } = committeeData; + const newTeamMembers: TeamMembersState = { leads: [], representatives: [], head: null }; + + if (head) { + newTeamMembers.head = await getPublicUserData(head); + } + + if (representatives && representatives.length > 0) { + newTeamMembers.representatives = await Promise.all( + representatives.map(async (uid) => await getPublicUserData(uid)) + ); + } + + if (leads && leads.length > 0) { + newTeamMembers.leads = await Promise.all( + leads.map(async (uid) => await getPublicUserData(uid)) + ); + } + setLocalTeamMembers(newTeamMembers); + } + }; + + fetchUserData(); + }, [committeeData]); + + useEffect(() => { + const fetchTeamUsers = async () => { + const fetchTeamMembers = await getTeamMembers(); + const fetchRepresentatives = await getRepresentatives(); + const fetchLeads = await getLeads(); + if (fetchTeamMembers) { + setTeamMembers(fetchTeamMembers) + } + if (fetchRepresentatives) { + setRepresentatives(fetchRepresentatives) + } + if (fetchLeads) { + setLeads(fetchLeads) + } + } + + fetchTeamUsers(); + }, []) + + const setHeadUserData = (uid: string,) => { + const headInfo = teamMembers.find(member => member.uid === uid); + if (headInfo) { + setLocalTeamMembers({ + ...localTeamMembers, + head: headInfo + }); + } + + setLocalCommitteeData({ + ...localCommitteeData, + head: uid + }); + + }; + + const setLeadUserData = (uid: string) => { + const leadInfo = leads.find(lead => lead.uid === uid); + if (leadInfo) { + setLocalTeamMembers(prevTeamMembers => ({ + ...prevTeamMembers, + leads: [...(prevTeamMembers?.leads || []), leadInfo] + })); + } + + setLocalCommitteeData(prevCommitteeData => ({ + ...prevCommitteeData, + leads: [...(prevCommitteeData?.leads || []), uid] + })); + + + }; + + const setRepresentativeUserData = (uid: string) => { + const repInfo = representatives.find(rep => rep.uid === uid); + if (repInfo) { + setLocalTeamMembers(prevTeamMembers => ({ + ...prevTeamMembers, + representatives: [...(prevTeamMembers?.representatives || []), repInfo] + })); + } + + setLocalCommitteeData(prevCommitteeData => ({ + ...prevCommitteeData, + representatives: [...(prevCommitteeData?.representatives || []), uid] + })); + }; + + const addLead = (uid: string) => { + const currentUIDList = localCommitteeData?.leads || []; + if (currentUIDList.includes(uid)) { + return; + } + + setLeadUserData(uid); + }; + + const addRepresentative = (uid: string) => { + const currentUIDList = localCommitteeData?.representatives || []; + if (currentUIDList.includes(uid)) { // Check if the UID already exists in the list + return; + } + + setRepresentativeUserData(uid); + }; + + const removeHead = () => { + setLocalCommitteeData(prevCommitteeData => ({ + ...prevCommitteeData, + head: undefined + })); + + setLocalTeamMembers(prevTeamMembers => ({ + ...prevTeamMembers, + head: null + })); + + } + + + + const removeLead = (uid: string) => { + setLocalCommitteeData(prevCommitteeData => ({ + ...prevCommitteeData, + leads: prevCommitteeData?.leads?.filter(existingUID => existingUID !== uid) || [] + })); + + setLocalTeamMembers(prevTeamMembers => ({ + ...prevTeamMembers, + leads: prevTeamMembers?.leads?.filter(lead => lead?.uid !== uid) || [] + })); + }; + + const removeRepresentative = (uid: string) => { + setLocalCommitteeData(prevCommitteeData => ({ + ...prevCommitteeData, + representatives: prevCommitteeData?.representatives?.filter(existingUID => existingUID !== uid) || [] + })); + + setLocalTeamMembers(prevTeamMembers => ({ + ...prevTeamMembers, + representatives: prevTeamMembers?.representatives?.filter(representative => representative?.uid !== uid) || [] + })); + }; + + + const handleColorChosen = (color: string) => { + setLocalCommitteeData({ + ...localCommitteeData, + color: color + }); + }; + + const handleResetCommittee = async () => { + if (localCommitteeData.firebaseDocName) { + await resetCommittee(localCommitteeData.firebaseDocName); + } + }; + + const handleDeleteCommittee = async () => { + if (localCommitteeData.firebaseDocName) { + await deleteCommittee(localCommitteeData.firebaseDocName); + } + }; + + // Update the selected logo component whenever localCommitteeData.logo changes + useEffect(() => { + if (localCommitteeData.logo) { + const logoData = getLogoComponent(localCommitteeData.logo); + setSelectedLogoData(logoData); + } + }, [localCommitteeData.logo]); + + const BubbleToggle = ({ isActive, onToggle, label }: { + isActive: boolean, + onToggle: () => void, + label: string + }) => { + return ( + + + + + {label} + + ); + }; + + const renderItem = ({ item }: { item: any }) => { + const [name, logoData] = item; + return ( + { + setLocalCommitteeData({ ...localCommitteeData, logo: name }); + setLogoSelectModal(false); + }} + > + + + ); + }; + + return ( + + {/* Header */} + + + Committee + + navigation.goBack()} className='p-2'> + + + + + + {/* Logo, Name, and Color Selection */} + + {selectedLogoData && (() => { + const { LogoComponent, width, height } = selectedLogoData; + return ( + + + setLogoSelectModal(true)} + > + + + { + setLocalCommitteeData({ ...localCommitteeData, logo: undefined }) + setSelectedLogoData(null) + }} + > + + + + ); + })()} + + {!selectedLogoData && ( + setLogoSelectModal(true)} + > + + + + UPLOAD + + + + + + + )} + + + { + const trimmedText = text.trim(); + const formattedFirebaseName = trimmedText.toLowerCase().replace(/\s+/g, '-'); + setLocalCommitteeData({ + ...localCommitteeData, + name: text, + firebaseDocName: formattedFirebaseName + }); + }} + value={localCommitteeData?.name} + editable={!committeeData} + selectTextOnFocus={!committeeData} + placeholder='Select a committee name' + /> + + + + Open Committee + { + setIsOpen(previousState => !previousState) + setLocalCommitteeData({ ...localCommitteeData, isOpen: !isOpen }) + }} + value={isOpen} + /> + + + + + + {/* Team Selection */} + + Choose your team + + + Head + {!localCommitteeData.head && ( + setHeadModalVisible(true)} + > + + + )} + + {localTeamMembers.head && ( + + + + { removeHead() }} + > + + + + )} + + + + + Representative + setRepsModalVisible(true)} + > + + + + {localTeamMembers.representatives?.map((representative, index) => ( + + + + { removeRepresentative(representative?.uid!) }} + > + + + + ))} + + + + + Leads + setLeadsModalVisible(true)} + > + + + + {localTeamMembers.leads?.map((lead, index) => ( + + + + { removeLead(lead?.uid!) }} + > + + + + ))} + + + + {/* Description Form */} + + Description + { + if (text.length <= 250) { + setLocalCommitteeData({ ...localCommitteeData, description: text }) + } + }} + placeholder="Add a description" + multiline={true} + style={{ textAlignVertical: 'top' }} + /> + + + {/* Application */} + + Applications + { setIsMemberLinkActive(!isMemberLinkActive) }} + label="Members Application Link" + /> + {isMemberLinkActive && ( + setLocalCommitteeData({ ...localCommitteeData, memberApplicationLink: text })} + placeholder="Add member application link" + /> + )} + + {/* Representatives Application Link */} + { setIsRepLinkActive(!isRepLinkActive) }} + label="Representatives Application Link" + /> + {isRepLinkActive && ( + setLocalCommitteeData({ ...localCommitteeData, representativeApplicationLink: text })} + placeholder="Add representative application link" + /> + )} + + {/* Leads Application Link */} + { setIsLeadLinkActive(!isLeadLinkActive) }} + label="Leads Application Link" + /> + {isLeadLinkActive && ( + setLocalCommitteeData({ ...localCommitteeData, leadApplicationLink: text })} + placeholder="Add lead application link" + /> + )} + + + + + { + const updatedCommitteeData = { + ...localCommitteeData, + memberApplicationLink: isMemberLinkActive ? localCommitteeData.memberApplicationLink : '', + representativeApplicationLink: isRepLinkActive ? localCommitteeData.representativeApplicationLink : '', + leadApplicationLink: isLeadLinkActive ? localCommitteeData.leadApplicationLink : '' + }; + + await setCommitteeData(updatedCommitteeData); + navigation.goBack(); + }} + > + {committeeData ? "Update Committee " : "Create Committee"} + + + {committeeData && ( + + + + { setResetModalVisible(true) }} + > + Reset + + + + { setDeleteModalVisible(true) }} + > + Delete + + + + + )} + + + + + { + setHeadModalVisible(false); + }} + > + + + + + Select a Head + + setHeadModalVisible(false)} + > + + + + + + + { + setHeadModalVisible(false) + setHeadUserData(uid) + }} + users={teamMembers} + /> + + + + + { + setLeadsModalVisible(false); + }} + > + + + + + Select a Lead + + setLeadsModalVisible(false)} + > + + + + + + + { + addLead(uid) + setLeadsModalVisible(false) + }} + users={leads} + /> + + + + + { + setRepsModalVisible(false); + }} + > + + + + + Select a Rep + + setRepsModalVisible(false)} + > + + + + + + + + { + addRepresentative(uid) + setRepsModalVisible(false) + }} + users={representatives} + /> + + + + + + + + + Select a Logo + + + setLogoSelectModal(false)}> + + + + + + item[0]} + numColumns={3} + contentContainerStyle={{ padding: 10 }} + style={{ backgroundColor: 'gray', borderRadius: 10 }} + /> + + + + + + + + + Reset Committee + + + + + + { + await handleResetCommittee() + setResetModalVisible(false) + navigation.goBack() + }} + > + Reset + + + setResetModalVisible(false)} + > + Cancel + + + + + + + + + + + Delete Committee + + + + + { + await handleDeleteCommittee() + setDeleteModalVisible(false) + navigation.goBack() + }} + > + Delete + + + setDeleteModalVisible(false)} + > + Cancel + + + + + + ) +} + +interface TeamMembersState { + leads: (PublicUserInfo | undefined)[]; + representatives: (PublicUserInfo | undefined)[]; + head: PublicUserInfo | null | undefined; +} + +type CommitteeEditorProps = { + route: RouteProp; + navigation: NativeStackNavigationProp; +}; + + +export default CommitteeEditor \ No newline at end of file diff --git a/src/screens/committees/CommitteeTeamCard.tsx b/src/screens/committees/CommitteeTeamCard.tsx new file mode 100644 index 00000000..5d570420 --- /dev/null +++ b/src/screens/committees/CommitteeTeamCard.tsx @@ -0,0 +1,63 @@ +import { Image, Text, TouchableOpacity, View } from 'react-native' +import React, { useEffect, useState } from 'react' +import { CommitteesStackParams } from '../../types/navigation' +import { getBadgeColor, isMemberVerified } from '../../helpers/membership' +import { Images } from '../../../assets' +import TwitterSvg from '../../components/TwitterSvg' +import { NativeStackNavigationProp } from '@react-navigation/native-stack' +import { PublicUserInfo } from '../../types/user' + +const CommitteeTeamCard: React.FC = ({ userData, navigation }) => { + if (!userData || Object.keys(userData).length === 0) { + return null; + } + + const { name, roles, uid, photoURL, chapterExpiration, nationalExpiration, email, isEmailPublic } = userData + const isOfficer = roles ? roles.officer : false; + const [isVerified, setIsVerified] = useState(false); + + let badgeColor = getBadgeColor(isOfficer!, isVerified); + + useEffect(() => { + if (nationalExpiration && chapterExpiration) { + setIsVerified(isMemberVerified(nationalExpiration, chapterExpiration)); + } + }, [nationalExpiration, chapterExpiration]) + + const handleCardPress = (uid: string): string | void => { + navigation!.navigate("PublicProfile", { uid }); + }; + + return ( + (navigation && handleCardPress(uid!))} + activeOpacity={!!handleCardPress && 1 || 0.6} + > + + + + + + {name} + {(isOfficer || isVerified) && } + + {(isEmailPublic && email && email.trim() !== "") && ( + {email} + )} + + + + + ) +} + +type CommitteeTeamCardProps = { + userData: PublicUserInfo; + navigation?: NativeStackNavigationProp +} + +export default CommitteeTeamCard \ No newline at end of file diff --git a/src/screens/committees/Committees.tsx b/src/screens/committees/Committees.tsx new file mode 100644 index 00000000..7f745cdc --- /dev/null +++ b/src/screens/committees/Committees.tsx @@ -0,0 +1,114 @@ +import { View, ScrollView, Text, TouchableOpacity, ActivityIndicator, Image } from 'react-native' +import React, { useCallback, useContext, useEffect, useState } from 'react' +import { useFocusEffect } from '@react-navigation/core' +import { Octicons } from '@expo/vector-icons'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { UserContext } from '../../context/UserContext' +import { auth } from '../../config/firebaseConfig'; +import { getCommittees, getUser } from '../../api/firebaseUtils' +import { Committee } from "../../types/committees" +import CommitteeCard from './CommitteeCard' +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Images } from '../../../assets'; +import { CommitteesStackParams } from '../../types/navigation'; +import { NativeStackScreenProps } from '@react-navigation/native-stack'; + + +const Committees = ({ navigation }: NativeStackScreenProps) => { + const [committees, setCommittees] = useState([]); + const [loading, setLoading] = useState(true); + const { userInfo, setUserInfo } = useContext(UserContext)!; + + const isSuperUser = userInfo?.publicInfo?.roles?.admin || userInfo?.publicInfo?.roles?.developer || userInfo?.publicInfo?.roles?.officer + + const fetchCommittees = async () => { + setLoading(true); + const response = await getCommittees(); + setCommittees(response); + setLoading(false); + } + + + const fetchUserData = async () => { + console.log("Fetching user data..."); + try { + const firebaseUser = await getUser(auth.currentUser?.uid!) + await AsyncStorage.setItem("@user", JSON.stringify(firebaseUser)); + setUserInfo(firebaseUser); + } catch (error) { + console.error("Error updating user:", error); + } + } + + useEffect(() => { + fetchCommittees(); + fetchUserData(); + }, []); + + // a refetch for officer for when they update committees + useFocusEffect( + useCallback(() => { + if (isSuperUser) { + fetchCommittees(); + } + return () => { }; + }, [isSuperUser]) + ); + + return ( + + + + {/* Header */} + + + + + + + + + {isSuperUser && ( + + navigation.navigate("CommitteeEditor", { committee: undefined })} + className='flex-row w-[90%] h-28 rounded-xl bg-[#D3D3D3]' + > + + + + + + + + + + Create a Committee + + + + + )} + + {loading && ( + + )} + + + + {!loading && committees.map((committee) => ( + + ))} + + + ) +} + +export default Committees; diff --git a/src/screens/home/Home.tsx b/src/screens/home/Home.tsx index 93300085..c851714b 100644 --- a/src/screens/home/Home.tsx +++ b/src/screens/home/Home.tsx @@ -10,6 +10,8 @@ import Ishpe from './Ishpe'; import { Images } from '../../../assets'; import { SafeAreaView } from 'react-native-safe-area-context'; import { UserContext } from '../../context/UserContext'; +import OfficeHours from './OfficeHours'; +import OfficeSignIn from './OfficeSignIn'; /** * Renders the home screen of the application. @@ -67,11 +69,11 @@ const Home = ({ navigation, route }: NativeStackScreenProps) => Member - - {/* */} - + + {userInfo?.publicInfo?.roles?.officer && } + ); diff --git a/src/screens/Members.tsx b/src/screens/home/Members.tsx similarity index 97% rename from src/screens/Members.tsx rename to src/screens/home/Members.tsx index 3035276c..a7d36320 100644 --- a/src/screens/Members.tsx +++ b/src/screens/home/Members.tsx @@ -1,14 +1,14 @@ import { View, Text, ScrollView, TouchableOpacity, TextInput, ActivityIndicator, NativeScrollEvent } from 'react-native' import React, { useEffect, useRef, useState } from 'react' import { Octicons } from '@expo/vector-icons'; -import MemberCard from '../components/MemberCard' -import { MAJORS, PublicUserInfo, UserFilter, classYears } from '../types/user'; +import MemberCard from '../../components/MemberCard' +import { MAJORS, PublicUserInfo, UserFilter, classYears } from '../../types/user'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { getOfficers, getUserForMemberList } from '../api/firebaseUtils'; +import { getOfficers, getUserForMemberList } from '../../api/firebaseUtils'; import { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; -import CustomDropDownMenu, { CustomDropDownMethods } from '../components/CustomDropDown'; -import { HomeStackParams } from '../types/navigation'; +import CustomDropDownMenu, { CustomDropDownMethods } from '../../components/CustomDropDown'; +import { HomeStackParams } from '../../types/navigation'; const Members = ({ navigation }: NativeStackScreenProps) => { const [showFilterMenu, setShowFilterMenu] = useState(false); diff --git a/src/screens/resources/OfficeHours.tsx b/src/screens/home/OfficeHours.tsx similarity index 100% rename from src/screens/resources/OfficeHours.tsx rename to src/screens/home/OfficeHours.tsx diff --git a/src/screens/resources/OfficeSignIn.tsx b/src/screens/home/OfficeSignIn.tsx similarity index 100% rename from src/screens/resources/OfficeSignIn.tsx rename to src/screens/home/OfficeSignIn.tsx diff --git a/src/screens/resources/Resources.tsx b/src/screens/resources/Resources.tsx index a5ecfb6c..df8e76e5 100644 --- a/src/screens/resources/Resources.tsx +++ b/src/screens/resources/Resources.tsx @@ -11,15 +11,11 @@ import { Images } from '../../../assets'; import LeaderBoardIcon from '../../../assets/ranking-star-solid.svg'; import ResumeIcon from '../../../assets/resume-icon.svg'; import ExamIcon from '../../../assets/exam-icon.svg'; -import OfficeHours from './OfficeHours'; import { UserContext } from '../../context/UserContext'; -import OfficeSignIn from './OfficeSignIn'; import { LinkData } from '../../types/links'; - const linkIDs = ["1", "2", "3", "4", "5"]; // First 5 links are reserved for social media links - const Resources = ({ navigation }: { navigation: NativeStackNavigationProp }) => { const { userInfo, setUserInfo } = useContext(UserContext)!; const [links, setLinks] = useState([]); @@ -124,8 +120,6 @@ const Resources = ({ navigation }: { navigation: NativeStackNavigationProp - {userInfo?.publicInfo?.roles?.officer && } - {/* Resources */} - + From 2074b23591ccd41543a17506090c5bf85ca94b2e Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 4 Jun 2024 01:07:15 -0500 Subject: [PATCH 014/198] fix userprofile cap --- src/navigation/CommitteesStack.tsx | 2 +- src/navigation/EventsStack.tsx | 2 +- src/navigation/HomeStack.tsx | 2 +- src/navigation/ResourcesStack.tsx | 2 +- src/navigation/UserProfileStack.tsx | 6 +- src/screens/userProfile/PersonalEventLog.tsx | 82 ++ src/screens/userProfile/PublicProfile.tsx | 494 +++++++++ src/screens/userProfile/Settings.tsx | 993 +++++++++++++++++++ 8 files changed, 1576 insertions(+), 7 deletions(-) create mode 100644 src/screens/userProfile/PersonalEventLog.tsx create mode 100644 src/screens/userProfile/PublicProfile.tsx create mode 100644 src/screens/userProfile/Settings.tsx diff --git a/src/navigation/CommitteesStack.tsx b/src/navigation/CommitteesStack.tsx index 2362fc65..8e158f84 100644 --- a/src/navigation/CommitteesStack.tsx +++ b/src/navigation/CommitteesStack.tsx @@ -2,7 +2,7 @@ import React from "react"; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { CommitteesStackParams } from '../types/navigation'; import CommitteeScreen from "../screens/committees/Committee"; -import PublicProfileScreen from "../screens/UserProfile/PublicProfile"; +import PublicProfileScreen from "../screens/userProfile/PublicProfile"; import CommitteeEditor from "../screens/committees/CommitteeEditor"; import EventInfo from "../screens/events/EventInfo"; import CommitteesScreen from "../screens/committees/Committees"; diff --git a/src/navigation/EventsStack.tsx b/src/navigation/EventsStack.tsx index 2f69b0f6..76a46293 100644 --- a/src/navigation/EventsStack.tsx +++ b/src/navigation/EventsStack.tsx @@ -11,7 +11,7 @@ import SetSpecificEventDetails from "../screens/events/SetSpecificEventDetails"; import FinalizeEvent from "../screens/events/FinalizeEvent"; import SetLocationEventDetails from "../screens/events/SetLocationEventDetails"; import EventVerification from "../screens/events/EventVerification"; -import PublicProfileScreen from "../screens/UserProfile/PublicProfile"; +import PublicProfileScreen from "../screens/userProfile/PublicProfile"; const EventsStack = () => { const Stack = createNativeStackNavigator(); diff --git a/src/navigation/HomeStack.tsx b/src/navigation/HomeStack.tsx index eab529be..cb459713 100644 --- a/src/navigation/HomeStack.tsx +++ b/src/navigation/HomeStack.tsx @@ -1,7 +1,7 @@ import React from "react"; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { HomeStackParams } from "../types/navigation" -import PublicProfileScreen from "../screens/UserProfile/PublicProfile"; +import PublicProfileScreen from "../screens/userProfile/PublicProfile"; import Home from "../screens/home/Home" import EventInfo from "../screens/events/EventInfo"; import AdminDashboard from "../screens/admin/AdminDashboard"; diff --git a/src/navigation/ResourcesStack.tsx b/src/navigation/ResourcesStack.tsx index 7a75e1a1..086ac294 100644 --- a/src/navigation/ResourcesStack.tsx +++ b/src/navigation/ResourcesStack.tsx @@ -4,7 +4,7 @@ import Resources from "../screens/resources/Resources"; import PointsLeaderboard from "../screens/resources/PointsLeaderboard"; import TestBank from "../screens/resources/TestBank"; import ResumeBank from "../screens/resources/ResumeBank"; -import PublicProfileScreen from "../screens/UserProfile/PublicProfile"; +import PublicProfileScreen from "../screens/userProfile/PublicProfile"; import { ResourcesStackParams } from '../types/navigation'; const ResourcesStack = () => { diff --git a/src/navigation/UserProfileStack.tsx b/src/navigation/UserProfileStack.tsx index 2afe937d..6d511021 100644 --- a/src/navigation/UserProfileStack.tsx +++ b/src/navigation/UserProfileStack.tsx @@ -1,11 +1,11 @@ import React, { useContext } from "react"; import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import PublicProfileScreen from "../screens/UserProfile/PublicProfile"; -import { AboutSettingsScreen, AccountSettingsScreen, DisplaySettingsScreen, FAQSettingsScreen, FeedBackSettingsScreen, ProfileSettingsScreen, SettingsScreen } from "../screens/UserProfile/Settings"; +import PublicProfileScreen from "../screens/userProfile/PublicProfile"; +import { AboutSettingsScreen, AccountSettingsScreen, DisplaySettingsScreen, FAQSettingsScreen, FeedBackSettingsScreen, ProfileSettingsScreen, SettingsScreen } from "../screens/userProfile/Settings"; import { UserContext } from "../context/UserContext"; import { UserProfileStackParams } from "../types/navigation"; import { auth } from "../config/firebaseConfig"; -import PersonalEventLog from "../screens/UserProfile/PersonalEventLog"; +import PersonalEventLog from "../screens/userProfile/PersonalEventLog"; const UserProfileStack = () => { const Stack = createNativeStackNavigator(); diff --git a/src/screens/userProfile/PersonalEventLog.tsx b/src/screens/userProfile/PersonalEventLog.tsx new file mode 100644 index 00000000..fce9d17d --- /dev/null +++ b/src/screens/userProfile/PersonalEventLog.tsx @@ -0,0 +1,82 @@ +import { View, TouchableOpacity, ActivityIndicator, Text } from 'react-native' +import React, { useContext, useEffect, useState } from 'react' +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Octicons } from '@expo/vector-icons'; +import { Timestamp } from 'firebase/firestore'; +import { UserContext } from '../../context/UserContext'; +import { auth } from '../../config/firebaseConfig'; +import { queryUserEventLogs } from '../../api/firebaseUtils'; +import { UserEventData } from '../../types/events'; +import { UserProfileStackParams } from '../../types/navigation'; + +const PersonalEventLog = ({ navigation }: NativeStackScreenProps) => { + const userContext = useContext(UserContext); + const { userInfo } = userContext!; + + const [events, setEvents] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchUserEventLogs = async () => { + if (auth.currentUser?.uid) { + try { + const data = await queryUserEventLogs(auth.currentUser?.uid); + setEvents(data); + } catch (error) { + console.error('Error fetching user event logs:', error); + } finally { + setIsLoading(false); + } + } + }; + + fetchUserEventLogs(); + }, []); + + if (isLoading) { + return ; + } + + + return ( + + + + navigation.goBack()} + className="rounded-full w-10 h-10 justify-center items-center" + style={{ backgroundColor: 'rgba(0,0,0,0.3)' }} + > + + + + + + + {events.map(({ eventData, eventLog }, index) => ( + + {eventData?.name} + Start Time: {formatTimestamp(eventData?.startTime)} + End Time: {formatTimestamp(eventData?.endTime)} + Total Points Earned: {eventLog?.points} + Sign-In Time: {formatTimestamp(eventLog?.signInTime)} + + {eventLog?.signOutTime && ( + Sign-Out Time: {formatTimestamp(eventLog?.signOutTime)} + )} + + ))} + + + + + + ) +} + +const formatTimestamp = (timestamp: Timestamp | null | undefined) => { + return timestamp ? new Date(timestamp.toDate()).toLocaleString() : 'N/A'; +}; + +export default PersonalEventLog \ No newline at end of file diff --git a/src/screens/userProfile/PublicProfile.tsx b/src/screens/userProfile/PublicProfile.tsx new file mode 100644 index 00000000..4046a153 --- /dev/null +++ b/src/screens/userProfile/PublicProfile.tsx @@ -0,0 +1,494 @@ +import { View, Text, ActivityIndicator, Image, Alert, TouchableOpacity, Pressable, TextInput, ScrollView, RefreshControl } from 'react-native'; +import React, { useState, useEffect, useContext, useCallback } from 'react'; +import { NativeStackNavigationProp, NativeStackScreenProps } from '@react-navigation/native-stack'; +import { RouteProp, useFocusEffect } from '@react-navigation/core'; +import { useRoute } from '@react-navigation/native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Octicons, FontAwesome } from '@expo/vector-icons'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { StatusBar } from 'expo-status-bar'; +import { UserContext } from '../../context/UserContext'; +import { auth } from '../../config/firebaseConfig'; +import { getCommittees, getPublicUserData, getUser, queryUserEventLogs, setUserRoles } from '../../api/firebaseUtils'; +import { getBadgeColor, isMemberVerified } from '../../helpers/membership'; +import { handleLinkPress } from '../../helpers/links'; +import { UserProfileStackParams } from '../../types/navigation'; +import { PublicUserInfo, Roles } from '../../types/user'; +import { Committee } from '../../types/committees'; +import { Images } from '../../../assets'; +import TwitterSvg from '../../components/TwitterSvg'; +import ProfileBadge from '../../components/ProfileBadge'; +import DismissibleModal from '../../components/DismissibleModal'; +import { UserEventData } from '../../types/events'; +import { Timestamp } from 'firebase/firestore'; + +export type PublicProfileScreenProps = { + route: RouteProp; + navigation: NativeStackNavigationProp; +}; + +const PublicProfileScreen: React.FC = ({ route, navigation }) => { + // Data related to public profile user + const { uid } = route.params; + const [publicUserData, setPublicUserData] = useState(); + const { nationalExpiration, chapterExpiration, roles, photoURL, name, major, classYear, bio, points, resumeVerified, resumePublicURL, email, isStudent, committees, pointsRank, isEmailPublic } = publicUserData || {}; + const [committeesData, setCommitteesData] = useState([]); + const [events, setEvents] = useState([]); + const [modifiedRoles, setModifiedRoles] = useState(undefined); + const [isVerified, setIsVerified] = useState(false); + const isOfficer = roles ? roles.officer : false; + const badgeColor = getBadgeColor(isOfficer!, isVerified); + const isCurrentUser = uid === auth.currentUser?.uid; + + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [updatingRoles, setUpdatingRoles] = useState(false); + const [showRoleModal, setShowRoleModal] = useState(false); + const [showEventsLogModal, setEventsLogModal] = useState(false); + + // Data related to currently authenticated user + const { userInfo, setUserInfo } = useContext(UserContext)!; + const isSuperUser = userInfo?.publicInfo?.roles?.admin || userInfo?.publicInfo?.roles?.developer || userInfo?.publicInfo?.roles?.officer + + const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + + + const fetchUserData = async () => { + try { + const firebaseUser = await getUser(auth.currentUser?.uid!) + await AsyncStorage.setItem("@user", JSON.stringify(firebaseUser)); + setUserInfo(firebaseUser); + } catch (error) { + console.error("Error updating user:", error); + } finally { + setRefreshing(false); + } + } + + const onRefresh = useCallback(async () => { + if (isCurrentUser) { + setRefreshing(true); + fetchUserData(); + } + }, [uid]); + + useFocusEffect( + useCallback(() => { + const fetchPublicUserData = async () => { + await getPublicUserData(uid) + .then((res) => { + setPublicUserData(res); + setModifiedRoles(res?.roles); + }) + .catch((error) => console.error("Failed to fetch public user data:", error)) + .finally(() => { + setLoading(false); + }); + }; + + + if (isCurrentUser) { + fetchUserData(); + } + fetchPublicUserData(); + + return () => { }; + }, [auth]) + ); + + // used to get committee color for badges + useEffect(() => { + const fetchCommitteeData = async () => { + const response = await getCommittees(); + setCommitteesData(response); + } + + const fetchUserEventLogs = async () => { + if (auth.currentUser?.uid) { + try { + const data = await queryUserEventLogs(auth.currentUser?.uid); + setEvents(data); + } catch (error) { + console.error('Error fetching user event logs:', error); + } + } + }; + + fetchCommitteeData(); + fetchUserEventLogs(); + }, []) + + useEffect(() => { + if (nationalExpiration && chapterExpiration) { + setIsVerified(isMemberVerified(nationalExpiration, chapterExpiration)); + } + }, [nationalExpiration, chapterExpiration]) + + const RoleItem = ({ roleName, isActive, onToggle, darkMode }: { + roleName: string, + isActive: boolean, + onToggle: () => void, + darkMode: boolean + }) => { + return ( + + + {roleName} + + ); + }; + + if (loading) { + return ( + + + + ); + } + + if (!uid || uid === "") { + return ( + + + + + + + + + + navigation.goBack()} + className="rounded-full w-10 h-10 justify-center items-center" + style={{ backgroundColor: 'rgba(0,0,0,0.3)' }} + > + + + + + + + + + + No User Found + + + + + + + ) + } + + return ( + + } + bounces={isCurrentUser ? true : false} + > + + {/* Profile Header */} + + + + + + + {!isCurrentUser ? + navigation.goBack()} + className="rounded-full w-10 h-10 justify-center items-center" + style={{ backgroundColor: 'rgba(0,0,0,0.3)' }} + > + + + : + + } + + + {isCurrentUser && + navigation.navigate("SettingsScreen")} + className="rounded-md px-3 py-2" + style={{ backgroundColor: 'rgba(0,0,0,0.3)' }} + > + Edit + + } + + + + + + + + + {name ?? "Name"} + {(isOfficer || isVerified) && } + + + + + + {`${major} ${"'" + classYear?.substring(2)}`} + {points !== undefined && ` • ${points.toFixed(2)} pts`} + {points !== undefined && pointsRank && ` • rank ${pointsRank}`} + + + {isSuperUser && + + + setShowRoleModal(true)} + className="rounded-md px-3 py-2" + style={{ backgroundColor: 'rgba(0,0,0,0.3)', marginRight: 10 }} + > + Edit Role + + + + } + + + + + {/* Profile Body */} + + + + {roles?.customTitle ? roles.customTitle : + (isVerified ? "Member" : + (isStudent ? "Student" : "Guest")) + } + + + {bio} + + {(isEmailPublic && email && email.trim() !== "") && ( + (handleLinkPress('mailto:' + email))} + > + + Email + + )} + + {resumeVerified && + handleLinkPress(resumePublicURL!)} + > + + Resume + + } + + + {committees && committees.length > 0 && ( + + Committees + + {committees?.map((committeeName, index) => { + const committeeData = committeesData.find(c => c.firebaseDocName === committeeName); + + return ( + + ); + })} + + + )} + + {isCurrentUser && ( + + navigation.navigate("PersonalEventLogScreen")} + className="rounded-md mt-8" + > + Personal Event Logs + + + + {events.map(({ eventData, eventLog }, index) => ( + + {eventData?.name} + Start Time: {formatTimestamp(eventData?.startTime)} + End Time: {formatTimestamp(eventData?.endTime)} + Total Points Earned: {eventLog?.points} + + + ))} + + + )} + + + + + {/* Role Modal */} + + + {/* Title */} + + + User Permissions + + + {/* Position Custom Title */} + + Enter a custom title + This is only used on profile screen + { + setModifiedRoles({ + ...modifiedRoles, + customTitle: text || "" + }) + }} + placeholder='Enter title' + value={modifiedRoles?.customTitle} + /> + + Select user role + + + {/* Position Selection */} + + setModifiedRoles({ ...modifiedRoles, admin: !modifiedRoles?.admin })} + darkMode={darkMode || false} + /> + setModifiedRoles({ ...modifiedRoles, developer: !modifiedRoles?.developer })} + darkMode={darkMode || false} + + /> + setModifiedRoles({ ...modifiedRoles, officer: !modifiedRoles?.officer })} + darkMode={darkMode || false} + /> + setModifiedRoles({ ...modifiedRoles, secretary: !modifiedRoles?.secretary })} + darkMode={darkMode || false} + /> + setModifiedRoles({ ...modifiedRoles, representative: !modifiedRoles?.representative })} + darkMode={darkMode || false} + /> + setModifiedRoles({ ...modifiedRoles, lead: !modifiedRoles?.lead })} + darkMode={darkMode || false} + /> + + + {/* Action Buttons */} + + { + + // checks if has role but no custom title + if ((modifiedRoles?.admin || modifiedRoles?.developer || modifiedRoles?.officer || modifiedRoles?.secretary || modifiedRoles?.representative || modifiedRoles?.lead) && !modifiedRoles?.customTitle && !modifiedRoles?.customTitle?.length) { + Alert.alert("Missing Title", "You must enter a title "); + return; + } + + + // Checks if has custom title but no role + if (!modifiedRoles?.admin && !modifiedRoles?.developer && !modifiedRoles?.officer && !modifiedRoles?.secretary && !modifiedRoles?.representative && !modifiedRoles?.lead && modifiedRoles?.customTitle) { + Alert.alert("Missing Role", "If a custom title is entered, you must select a role."); + return; + } + + setUpdatingRoles(true); + if (modifiedRoles) + await setUserRoles(uid, modifiedRoles) + .then(() => { + Alert.alert("Permissions Updated", "This user's roles have been updated successfully!") + }) + .catch((err) => { + console.error(err); + Alert.alert("An Issue Occured", "A server issue has occured. Please try again. If this keeps occurring, please contact a developer"); + }); + + setUpdatingRoles(false); + setShowRoleModal(false); + }} + className="bg-pale-blue rounded-lg justify-center items-center px-4 py-1" + > + Done + + + + { + setModifiedRoles(roles) + setShowRoleModal(false) + }} > + Cancel + + + {updatingRoles && } + + + + ) +} + +const formatTimestamp = (timestamp: Timestamp | null | undefined) => { + return timestamp ? new Date(timestamp.toDate()).toLocaleString() : 'N/A'; +}; + +export default PublicProfileScreen; \ No newline at end of file diff --git a/src/screens/userProfile/Settings.tsx b/src/screens/userProfile/Settings.tsx new file mode 100644 index 00000000..f3ef2ffc --- /dev/null +++ b/src/screens/userProfile/Settings.tsx @@ -0,0 +1,993 @@ +import { View, Text, Image, ScrollView, TextInput, TouchableHighlight, TouchableOpacity, ActivityIndicator, KeyboardAvoidingView, Platform, Alert, Pressable, Animated } from 'react-native'; +import React, { useContext, useEffect, useRef, useState } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { MaterialCommunityIcons } from '@expo/vector-icons' +import { StatusBar } from 'expo-status-bar'; +import * as ImagePicker from "expo-image-picker"; +import { Octicons, FontAwesome } from '@expo/vector-icons'; +import { UserContext } from '../../context/UserContext'; +import { auth } from '../../config/firebaseConfig'; +import { sendPasswordResetEmail, updateProfile } from 'firebase/auth'; +import { setPublicUserData, setPrivateUserData, getUser, getCommittees, submitFeedback, isUsernameUnique, deleteAccount } from '../../api/firebaseUtils'; +import { getBlobFromURI, selectFile, selectImage, uploadFile } from '../../api/fileSelection'; +import { CommonMimeTypes, validateDisplayName, validateFileBlob, validateName, validateTamuEmail } from '../../helpers/validation'; +import { handleLinkPress } from '../../helpers/links'; +import { getBadgeColor, isMemberVerified } from '../../helpers/membership'; +import { UserProfileStackParams } from '../../types/navigation'; +import { Committee } from '../../types/committees'; +import { MAJORS, classYears } from '../../types/user'; +import { Images } from '../../../assets'; +import DownloadIcon from '../../../assets/arrow-down-solid.svg'; +import UploadFileIcon from '../../../assets/file-arrow-up-solid-black.svg'; +import { SettingsSectionTitle, SettingsButton, SettingsToggleButton, SettingsListItem, SettingsSaveButton, SettingsModal } from "../../components/SettingsComponents" +import CustomDropDown from '../../components/CustomDropDown'; +import TwitterSvg from '../../components/TwitterSvg'; +import { Circle, Svg } from 'react-native-svg'; +import DismissibleModal from '../../components/DismissibleModal'; +import * as Clipboard from 'expo-clipboard'; + +/** + * Settings entrance screen which has a search function and paths to every other settings screen + */ +const SettingsScreen = ({ navigation }: NativeStackScreenProps) => { + const { userInfo, signOutUser } = useContext(UserContext)!; + + const { name, roles, photoURL, chapterExpiration, nationalExpiration } = userInfo?.publicInfo ?? {}; + const isOfficer = roles ? roles.officer : false; + + const [isVerified, setIsVerified] = useState(false); + let badgeColor = getBadgeColor(isOfficer!, isVerified); + + useEffect(() => { + if (nationalExpiration && chapterExpiration) { + setIsVerified(isMemberVerified(nationalExpiration, chapterExpiration)); + } + }, [nationalExpiration, chapterExpiration]) + + const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + + return ( + + + + + navigation.navigate("ProfileSettingsScreen")} + > + + + + + + {name} + {(isOfficer || isVerified) && } + + + Edit Profile + + + + + + + navigation.navigate("DisplaySettingsScreen")} + /> + navigation.navigate("AccountSettingsScreen")} + /> + navigation.navigate("FeedbackSettingsScreen")} + /> + navigation.navigate("FAQSettingsScreen")} + /> + navigation.navigate("AboutSettingsScreen")} + /> + + signOutUser(true)} + /> + + ) +} + + +/** + * Screen where a user can edit a majority of their public info. This includes thing like their profile picture, name, display name, committees, etc... + * These changes are synced in firebase. + */ +const ProfileSettingsScreen = ({ navigation }: NativeStackScreenProps) => { + const { userInfo, setUserInfo } = useContext(UserContext)!; + const [loading, setLoading] = useState(false); + const [image, setImage] = useState(null); + const [showSaveButton, setShowSaveButton] = useState(false); + + const defaultVals = { + photoURL: "", + displayName: "DISPLAY NAME", + name: "NAME", + bio: "Write a short bio...", + major: "MAJOR", + classYear: "CLASS YEAR", + committees: [], + } + + //Hooks used to save state of modified fields before user hits "save" + const [photoURL, setPhotoURL] = useState(userInfo?.publicInfo?.photoURL); + const [resumeURL, setResumeURL] = useState(userInfo?.private?.privateInfo?.resumeURL); + const [displayName, setDisplayName] = useState(userInfo?.publicInfo?.displayName); + const [name, setName] = useState(userInfo?.publicInfo?.name); + const [bio, setBio] = useState(userInfo?.publicInfo?.bio); + const [major, setMajor] = useState(userInfo?.publicInfo?.major); + const [classYear, setClassYear] = useState(userInfo?.publicInfo?.classYear); + const [openDropdown, setOpenDropdown] = useState(null); + const [committeesData, setCommitteesData] = useState([]); + const [committees, setCommittees] = useState(userInfo?.publicInfo?.committees || []); + const [prevCommittees, setPrevCommittees] = useState(userInfo?.publicInfo?.committees || []); + + + // Modal options + const [showNamesModal, setShowNamesModal] = useState(false); + const [showBioModal, setShowBioModal] = useState(false); + const [showAcademicInfoModal, setShowAcademicInfoModal] = useState(false); + const [showResumeModal, setShowResumeModal] = useState(false); + + const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + + useEffect(() => { + const fetchCommitteeData = async () => { + const response = await getCommittees(); + setCommitteesData(response); + } + fetchCommitteeData(); + }, []) + + /** + * Checks for any pending changes in user data. + * If any deviate from userInfo, display a "save" button which will save the changes to firebase. + */ + useEffect(() => { + if ( + photoURL != userInfo?.publicInfo?.photoURL + ) { + setShowSaveButton(true); + } + else { + setShowSaveButton(false); + } + }, [photoURL]); + + const selectProfilePicture = async () => { + await selectImage({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: true, + aspect: [1, 1], + quality: 1, + }).then(async (result) => { + if (result) { + const imageBlob = await getBlobFromURI(result.assets![0].uri); + if (imageBlob && validateFileBlob(imageBlob, CommonMimeTypes.IMAGE_FILES, true)) { + setPhotoURL(result.assets![0].uri); + setImage(imageBlob); + } + } + }).catch((err) => { + // TypeError means user did not select an image + if (err.name != "TypeError") { + console.error(err); + } + }); + } + + const selectResume = async () => { + const result = await selectFile(); + if (result) { + const resumeBlob = await getBlobFromURI(result.assets![0].uri); + return resumeBlob; + } + return null + } + + const onProfilePictureUploadSuccess = async (URL: string) => { + console.log("File available at", URL); + if (auth.currentUser) { + setPhotoURL(URL); + await updateProfile(auth.currentUser, { + photoURL: URL + }); + await setPublicUserData({ + photoURL: URL + }); + } + } + + // toggle dropdown for class year and major + const toggleDropdown = (dropdownKey: string) => { + if (openDropdown === dropdownKey) { + setOpenDropdown(null); + } else { + setOpenDropdown(dropdownKey); + } + }; + + const onResumeUploadSuccess = async (URL: string) => { + console.log("File available at", URL); + if (auth.currentUser) { + setResumeURL(URL); + await setPrivateUserData({ + resumeURL: URL + }); + } + + } + + const saveChanges = async () => { + setLoading(true) + // upload profile picture + if (image) { + await uploadFile( + image, + CommonMimeTypes.IMAGE_FILES, + `user-docs/${auth.currentUser?.uid}/user-profile-picture`, + onProfilePictureUploadSuccess + ); + } + + /** + * This is some very weird syntax and very javascript specific, so here's an explanation for what's going on: + * + * setPublicUserData() updates the fields that are in the object passed into it. + * The spread operator (...) adds each key in an object to the parent object. + * By adding a conditional and the && operator next to the child object, this essentially creates a "Conditional Key Addition". + * This makes it so the information will not be overridden in Firebase if the value of a key is empty/undefined. + */ + setPublicUserData({ + ...(photoURL !== undefined) && { photoURL: photoURL }, + ...(displayName !== undefined) && { displayName: displayName }, + ...(name !== undefined) && { name: name }, + ...(bio !== undefined) && { bio: bio }, + ...(major !== undefined) && { major: major }, + ...(classYear !== undefined) && { classYear: classYear }, + ...(committees !== undefined) && { committees: committees }, + }) + .then(async () => { + if (auth.currentUser) + await updateProfile(auth.currentUser, { + displayName: displayName, + photoURL: photoURL, + }) + + if (auth.currentUser?.uid) { + const firebaseUser = await getUser(auth.currentUser.uid); + if (firebaseUser) { + setUserInfo(firebaseUser); + await AsyncStorage.setItem("@user", JSON.stringify(firebaseUser)); + } + else { + console.warn("firebaseUser returned as undefined when attempting to sync. Sync will be skipped."); + } + } + }) + .catch(err => console.error("Error attempting to save changes: ", err)) + .finally(() => { + setLoading(false); + setShowSaveButton(false); + }); + + setPrivateUserData({ + ...(resumeURL !== undefined) && { resumeURL: resumeURL }, + }) + } + + + const CommitteeListItemComponent = ({ committeeData, onPress, darkMode, isChecked, committeeIndex }: any) => { + return ( + onPress()} + underlayColor={darkMode ? "#7a7a7a" : "#DDD"} + > + + + + {committeeData.name} + + {isChecked && {committeeIndex + 1}} + + + ); + }; + + const handleCommitteeToggle = (name: string) => { + setCommittees(prevCommittees => { + const isCommitteeSelected = prevCommittees.includes(name); + if (isCommitteeSelected) { + return prevCommittees.filter(committee => committee !== name); + } else { + return [...prevCommittees, name]; + } + }); + }; + + const findMajorByIso = (iso: string) => { + const majorObj = MAJORS.find(major => major.iso === iso); + return majorObj ? majorObj.major : null; + }; + + const progress = useRef(new Animated.Value(0)).current; + const setProgress = (newProgress: number) => { + if (newProgress <= 0) { + progress.setValue(0); + } else if (newProgress >= 100) { + progress.setValue(100); + } else { + Animated.timing(progress, { + toValue: newProgress, + duration: 500, + useNativeDriver: true, + }).start(); + } + }; + + const AnimatedCircle = Animated.createAnimatedComponent(Circle); + const circumference = 2 * Math.PI * 45; // 45 is the radius of the circle + const strokeDashoffset = progress.interpolate({ + inputRange: [0, 100], + outputRange: [circumference, 0] + }); + + + return ( + + + {/* Names Modal */} + { + setDisplayName(userInfo?.publicInfo?.displayName); + setName(userInfo?.publicInfo?.name); + setShowNamesModal(false); + }} + onDone={async () => { + if (validateDisplayName(displayName, true) && validateName(name, true)) { + const isUnique = await isUsernameUnique(displayName!); + if (isUnique) { + saveChanges(); + setShowNamesModal(false); + } else { + setDisplayName(userInfo?.publicInfo?.displayName); + alert("Display name is already taken. Please choose another one."); + } + } + + }} + content={( + + + Display Name + setDisplayName(text)} + value={displayName} + autoCorrect={false} + multiline + inputMode='text' + maxLength={80} + placeholder='Display Name...' + /> + + + Name + setName(text)} + value={name} + autoCorrect={false} + multiline + inputMode='text' + maxLength={80} + placeholder='Full Name..' + /> + + + )} + /> + {/* Bio Modal */} + { + setBio(userInfo?.publicInfo?.bio ?? defaultVals.bio); + setShowBioModal(false); + }} + onDone={() => { + saveChanges(); + setShowBioModal(false); + }} + content={( + + Bio + { + if (text.length <= 250) + setBio(text) + }} + value={bio} + multiline + numberOfLines={8} + placeholder='Write a short bio...' + placeholderTextColor={darkMode ? "#ddd" : "#000"} + /> + + )} + /> + {/* Academic Info Modal */} + { + setMajor(userInfo?.publicInfo?.major ?? defaultVals.major); + setClassYear(userInfo?.publicInfo?.classYear ?? defaultVals.classYear); + setShowAcademicInfoModal(false); + }} + onDone={() => { + saveChanges(); + setShowAcademicInfoModal(false) + }} + content={ + ( + + + setMajor(item.iso)} + searchKey="major" + label="Select major" + isOpen={openDropdown === "major"} + onToggle={() => toggleDropdown("major")} + title={"Major"} + selectedItemProp={{ iso: major, value: findMajorByIso(major!)! }} + dropDownClassName={Platform.OS == "ios" ? "top-20" : undefined} + darkMode={darkMode} + /> + + + setClassYear(item.iso)} + searchKey="year" + label="Select class year" + isOpen={openDropdown === 'year'} + onToggle={() => toggleDropdown('year')} + title={"Class Year"} + selectedItemProp={{ iso: classYear }} + displayType='iso' + dropDownClassName={Platform.OS == "ios" ? "top-20" : undefined} + disableSearch + darkMode={darkMode} + /> + + + + ) + } + /> + + {/* Resume Modal */} + setShowResumeModal(false)} + onDone={() => setShowResumeModal(false)} + darkMode={darkMode} + content={( + + + { + const selectedResume = await selectResume(); + if (selectedResume) { + uploadFile( + selectedResume, + CommonMimeTypes.RESUME_FILES, + `user-docs/${auth.currentUser?.uid}/user-resume`, + onResumeUploadSuccess, + setProgress + ); + } + }}> + + + + + + + + + + {resumeURL && ( + { handleLinkPress(resumeURL!) }}> + + View Resume + + + + + + )} + + + )} + /> + + + await selectProfilePicture()}> + + + + + + + setShowNamesModal(true)} + /> + setShowNamesModal(true)} + /> + setShowBioModal(true)} + /> + + setShowAcademicInfoModal(true)} + /> + setShowAcademicInfoModal(true)} + /> + + setShowResumeModal(true)} + /> + + {loading && } + + {showSaveButton && + saveChanges()} + /> + } + + ); +}; + +/** + * Screen where user can modify how to the app looks. + * These changes are synced in firebase. + */ +const DisplaySettingsScreen = ({ navigation }: NativeStackScreenProps) => { + const { userInfo, setUserInfo } = useContext(UserContext)!; + const [loading, setLoading] = useState(false); + const [darkModeToggled, setDarkModeToggled] = useState(userInfo?.private?.privateInfo?.settings?.darkMode ?? false); + + const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode + + return ( + + + { + setDarkModeToggled(!darkModeToggled); + setLoading(true); + await setPrivateUserData({ + settings: { + darkMode: !darkMode + } + }) + .then(async () => { + if (auth.currentUser?.uid) { + await getUser(auth.currentUser?.uid) + .then(async (firebaseUser) => { + if (firebaseUser) { + setUserInfo(firebaseUser); + await AsyncStorage.setItem("@user", JSON.stringify(firebaseUser)); + } + else { + console.warn("firebaseUser returned as undefined when attempting to sync. Sync will be skipped."); + } + }) + .catch(err => console.error(err)); + } + }) + .catch((err) => console.error(err)) + .finally(() => { + setLoading(false); + }); + }} + /> + {loading && } + + ); +}; + +/** + * Screen where user can both view information about their account and request a change of their email and/or password. + * These changes will go through firebase where an email will be sent to the user. + */ +const AccountSettingsScreen = ({ navigation }: NativeStackScreenProps) => { + const { userInfo, setUserInfo, signOutUser } = useContext(UserContext)!; + const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const deleteConfirmationText = "DELETECONFIRM"; + + const [deleteText, setDeleteText] = useState(''); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + return ( + + + + + { + const updatedPublicData = { + ...userInfo?.publicInfo, + isEmailPublic: !userInfo?.publicInfo?.isEmailPublic, + email: !userInfo?.publicInfo?.isEmailPublic ? auth.currentUser?.email || "" : "", + }; + + await setPublicUserData(updatedPublicData); + + const updatedUserInfo = { + ...userInfo, + publicInfo: updatedPublicData, + }; + + await AsyncStorage.setItem("@user", JSON.stringify(updatedUserInfo)); + setUserInfo(updatedUserInfo); + Alert.alert("Email visibility updated successfully"); + } + } + darkMode={darkMode} + /> + + { + Clipboard.setStringAsync(auth.currentUser?.uid ?? "UID") + .then(() => Alert.alert("Copied", "UID Copied to Clipboard")); + }} + /> + + + {!validateTamuEmail(auth.currentUser?.email ?? "") && + { + Alert.alert("Password Reset", "An email reset link will be sent to your email.") + sendPasswordResetEmail(auth, auth.currentUser?.email!) + }} + darkMode={darkMode} + /> + } + + + { + setDeleteText(""); + setShowDeleteModal(true); + }} + darkMode={darkMode} + /> + + + + + {/* Title */} + + + Account Deletion + + + YOU WILL LOSE ALL YOUR POINTS IF YOU DELETE YOUR ACCOUNT + Please type "{deleteConfirmationText}" to confirm. + + + + + + + { + Alert.alert("Account Deleted", "Your account has been successfully deleted."); + setShowDeleteModal(false); + await deleteAccount(auth.currentUser?.uid!); + await AsyncStorage.removeItem('@user'); + setUserInfo(undefined); + }} + disabled={deleteText !== deleteConfirmationText} + className={`${deleteText !== deleteConfirmationText ? "bg-neutral-400" : "bg-red-700"} rounded-lg justify-center items-center px-4 py-1`} + > + DELETE + + + + { + setShowDeleteModal(false) + }} > + Cancel + + + + + + ); +}; + +const FeedBackSettingsScreen = ({ navigation }: NativeStackScreenProps) => { + const [feedback, setFeedback] = useState(''); + const { userInfo } = useContext(UserContext)!; + + const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + + const handleFeedbackSubmit = async () => { + const response = await submitFeedback(feedback, userInfo!); + if (response.success) { + setFeedback(''); + alert('Feedback submitted successfully'); + } else { + alert('Failed to submit feedback'); + } + }; + + return ( + + Tell us what can be improved + + + + + Submit FeedBack + + + ); +}; + +const FAQSettingsScreen = ({ navigation }: NativeStackScreenProps) => { + const [activeQuestion, setActiveQuestion] = useState(null); + const { userInfo } = useContext(UserContext)!; + + const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + + + const toggleQuestion = (questionNumber: number) => { + if (activeQuestion === questionNumber) { + setActiveQuestion(null); + } else { + setActiveQuestion(questionNumber); + } + }; + + const faqData: { question: string, answer: string }[] = [ + { + question: "What resources does SHPE provide?", + answer: "SHPE offers networking opportunities, professional development workshops, mentorship programs, scholarship opportunities, and community outreach initiatives." + }, + { + question: "How do I become an official SHPE member?", + answer: "To become an official member, register on the SHPE national website, pay the annual membership fee, and join your local chapter activities." + }, + { + question: "What is the Technical Affairs Committee?", + answer: "The Technical Affairs Committee organizes technical events and workshops, promotes STEM education, and provides members with opportunities to develop technical skills." + }, + { + question: "What is the MentorSHPE Committee?", + answer: "The MentorSHPE Committee facilitates mentoring relationships between professional members and students, offering guidance, career advice, and academic support." + }, + { + question: "What is the Scholastic Committee?", + answer: "The Scholastic Committee focuses on academic excellence by providing study sessions, educational resources, and academic advising to members." + }, + { + question: "What is the Secretary Committee?", + answer: "The Secretary Committee is responsible for maintaining organization records, documenting meetings and events, and ensuring effective communication within the chapter." + }, + { + question: "What is the SHPEtinas Committee?", + answer: "The SHPEtinas Committee empowers and supports female members of SHPE through networking events, workshops, and mentorship programs." + }, + { + question: "What do the points I acquire allow me to do?", + answer: "Points earned through participation in events and activities can be used for priority access to certain events, eligibility for exclusive opportunities, and recognition within the organization." + } + ]; + + return ( + + {faqData.map((faq, index) => ( + toggleQuestion(index)} + > + + {faq.question} + + + + + {activeQuestion === index && ( + + {faq.answer} + + )} + + ))} + + + ); +}; +/** + * This screen contains information about the app and info that may be useful to developers. + */ +const AboutSettingsScreen = ({ navigation }: NativeStackScreenProps) => { + const pkg: any = require("../../../package.json"); + const { userInfo } = useContext(UserContext)!; + const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + + return ( + + + + + handleLinkPress("https://jasonisazn.github.io/")} + /> + + ); +}; + + + +export { SettingsScreen, ProfileSettingsScreen, DisplaySettingsScreen, AccountSettingsScreen, FeedBackSettingsScreen, FAQSettingsScreen, AboutSettingsScreen }; From 2065054f1845b150643fc8408961451107fd03cd Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 4 Jun 2024 01:08:38 -0500 Subject: [PATCH 015/198] remove folder --- src/screens/UserProfile/PersonalEventLog.tsx | 82 -- src/screens/UserProfile/PublicProfile.tsx | 494 --------- src/screens/UserProfile/Settings.tsx | 993 ------------------- src/screens/userProfile/PersonalEventLog.tsx | 82 -- src/screens/userProfile/PublicProfile.tsx | 494 --------- src/screens/userProfile/Settings.tsx | 993 ------------------- 6 files changed, 3138 deletions(-) delete mode 100644 src/screens/UserProfile/PersonalEventLog.tsx delete mode 100644 src/screens/UserProfile/PublicProfile.tsx delete mode 100644 src/screens/UserProfile/Settings.tsx delete mode 100644 src/screens/userProfile/PersonalEventLog.tsx delete mode 100644 src/screens/userProfile/PublicProfile.tsx delete mode 100644 src/screens/userProfile/Settings.tsx diff --git a/src/screens/UserProfile/PersonalEventLog.tsx b/src/screens/UserProfile/PersonalEventLog.tsx deleted file mode 100644 index fce9d17d..00000000 --- a/src/screens/UserProfile/PersonalEventLog.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { View, TouchableOpacity, ActivityIndicator, Text } from 'react-native' -import React, { useContext, useEffect, useState } from 'react' -import { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { Octicons } from '@expo/vector-icons'; -import { Timestamp } from 'firebase/firestore'; -import { UserContext } from '../../context/UserContext'; -import { auth } from '../../config/firebaseConfig'; -import { queryUserEventLogs } from '../../api/firebaseUtils'; -import { UserEventData } from '../../types/events'; -import { UserProfileStackParams } from '../../types/navigation'; - -const PersonalEventLog = ({ navigation }: NativeStackScreenProps) => { - const userContext = useContext(UserContext); - const { userInfo } = userContext!; - - const [events, setEvents] = useState([]); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - const fetchUserEventLogs = async () => { - if (auth.currentUser?.uid) { - try { - const data = await queryUserEventLogs(auth.currentUser?.uid); - setEvents(data); - } catch (error) { - console.error('Error fetching user event logs:', error); - } finally { - setIsLoading(false); - } - } - }; - - fetchUserEventLogs(); - }, []); - - if (isLoading) { - return ; - } - - - return ( - - - - navigation.goBack()} - className="rounded-full w-10 h-10 justify-center items-center" - style={{ backgroundColor: 'rgba(0,0,0,0.3)' }} - > - - - - - - - {events.map(({ eventData, eventLog }, index) => ( - - {eventData?.name} - Start Time: {formatTimestamp(eventData?.startTime)} - End Time: {formatTimestamp(eventData?.endTime)} - Total Points Earned: {eventLog?.points} - Sign-In Time: {formatTimestamp(eventLog?.signInTime)} - - {eventLog?.signOutTime && ( - Sign-Out Time: {formatTimestamp(eventLog?.signOutTime)} - )} - - ))} - - - - - - ) -} - -const formatTimestamp = (timestamp: Timestamp | null | undefined) => { - return timestamp ? new Date(timestamp.toDate()).toLocaleString() : 'N/A'; -}; - -export default PersonalEventLog \ No newline at end of file diff --git a/src/screens/UserProfile/PublicProfile.tsx b/src/screens/UserProfile/PublicProfile.tsx deleted file mode 100644 index 4046a153..00000000 --- a/src/screens/UserProfile/PublicProfile.tsx +++ /dev/null @@ -1,494 +0,0 @@ -import { View, Text, ActivityIndicator, Image, Alert, TouchableOpacity, Pressable, TextInput, ScrollView, RefreshControl } from 'react-native'; -import React, { useState, useEffect, useContext, useCallback } from 'react'; -import { NativeStackNavigationProp, NativeStackScreenProps } from '@react-navigation/native-stack'; -import { RouteProp, useFocusEffect } from '@react-navigation/core'; -import { useRoute } from '@react-navigation/native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { Octicons, FontAwesome } from '@expo/vector-icons'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { StatusBar } from 'expo-status-bar'; -import { UserContext } from '../../context/UserContext'; -import { auth } from '../../config/firebaseConfig'; -import { getCommittees, getPublicUserData, getUser, queryUserEventLogs, setUserRoles } from '../../api/firebaseUtils'; -import { getBadgeColor, isMemberVerified } from '../../helpers/membership'; -import { handleLinkPress } from '../../helpers/links'; -import { UserProfileStackParams } from '../../types/navigation'; -import { PublicUserInfo, Roles } from '../../types/user'; -import { Committee } from '../../types/committees'; -import { Images } from '../../../assets'; -import TwitterSvg from '../../components/TwitterSvg'; -import ProfileBadge from '../../components/ProfileBadge'; -import DismissibleModal from '../../components/DismissibleModal'; -import { UserEventData } from '../../types/events'; -import { Timestamp } from 'firebase/firestore'; - -export type PublicProfileScreenProps = { - route: RouteProp; - navigation: NativeStackNavigationProp; -}; - -const PublicProfileScreen: React.FC = ({ route, navigation }) => { - // Data related to public profile user - const { uid } = route.params; - const [publicUserData, setPublicUserData] = useState(); - const { nationalExpiration, chapterExpiration, roles, photoURL, name, major, classYear, bio, points, resumeVerified, resumePublicURL, email, isStudent, committees, pointsRank, isEmailPublic } = publicUserData || {}; - const [committeesData, setCommitteesData] = useState([]); - const [events, setEvents] = useState([]); - const [modifiedRoles, setModifiedRoles] = useState(undefined); - const [isVerified, setIsVerified] = useState(false); - const isOfficer = roles ? roles.officer : false; - const badgeColor = getBadgeColor(isOfficer!, isVerified); - const isCurrentUser = uid === auth.currentUser?.uid; - - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [updatingRoles, setUpdatingRoles] = useState(false); - const [showRoleModal, setShowRoleModal] = useState(false); - const [showEventsLogModal, setEventsLogModal] = useState(false); - - // Data related to currently authenticated user - const { userInfo, setUserInfo } = useContext(UserContext)!; - const isSuperUser = userInfo?.publicInfo?.roles?.admin || userInfo?.publicInfo?.roles?.developer || userInfo?.publicInfo?.roles?.officer - - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; - - - const fetchUserData = async () => { - try { - const firebaseUser = await getUser(auth.currentUser?.uid!) - await AsyncStorage.setItem("@user", JSON.stringify(firebaseUser)); - setUserInfo(firebaseUser); - } catch (error) { - console.error("Error updating user:", error); - } finally { - setRefreshing(false); - } - } - - const onRefresh = useCallback(async () => { - if (isCurrentUser) { - setRefreshing(true); - fetchUserData(); - } - }, [uid]); - - useFocusEffect( - useCallback(() => { - const fetchPublicUserData = async () => { - await getPublicUserData(uid) - .then((res) => { - setPublicUserData(res); - setModifiedRoles(res?.roles); - }) - .catch((error) => console.error("Failed to fetch public user data:", error)) - .finally(() => { - setLoading(false); - }); - }; - - - if (isCurrentUser) { - fetchUserData(); - } - fetchPublicUserData(); - - return () => { }; - }, [auth]) - ); - - // used to get committee color for badges - useEffect(() => { - const fetchCommitteeData = async () => { - const response = await getCommittees(); - setCommitteesData(response); - } - - const fetchUserEventLogs = async () => { - if (auth.currentUser?.uid) { - try { - const data = await queryUserEventLogs(auth.currentUser?.uid); - setEvents(data); - } catch (error) { - console.error('Error fetching user event logs:', error); - } - } - }; - - fetchCommitteeData(); - fetchUserEventLogs(); - }, []) - - useEffect(() => { - if (nationalExpiration && chapterExpiration) { - setIsVerified(isMemberVerified(nationalExpiration, chapterExpiration)); - } - }, [nationalExpiration, chapterExpiration]) - - const RoleItem = ({ roleName, isActive, onToggle, darkMode }: { - roleName: string, - isActive: boolean, - onToggle: () => void, - darkMode: boolean - }) => { - return ( - - - {roleName} - - ); - }; - - if (loading) { - return ( - - - - ); - } - - if (!uid || uid === "") { - return ( - - - - - - - - - - navigation.goBack()} - className="rounded-full w-10 h-10 justify-center items-center" - style={{ backgroundColor: 'rgba(0,0,0,0.3)' }} - > - - - - - - - - - - No User Found - - - - - - - ) - } - - return ( - - } - bounces={isCurrentUser ? true : false} - > - - {/* Profile Header */} - - - - - - - {!isCurrentUser ? - navigation.goBack()} - className="rounded-full w-10 h-10 justify-center items-center" - style={{ backgroundColor: 'rgba(0,0,0,0.3)' }} - > - - - : - - } - - - {isCurrentUser && - navigation.navigate("SettingsScreen")} - className="rounded-md px-3 py-2" - style={{ backgroundColor: 'rgba(0,0,0,0.3)' }} - > - Edit - - } - - - - - - - - - {name ?? "Name"} - {(isOfficer || isVerified) && } - - - - - - {`${major} ${"'" + classYear?.substring(2)}`} - {points !== undefined && ` • ${points.toFixed(2)} pts`} - {points !== undefined && pointsRank && ` • rank ${pointsRank}`} - - - {isSuperUser && - - - setShowRoleModal(true)} - className="rounded-md px-3 py-2" - style={{ backgroundColor: 'rgba(0,0,0,0.3)', marginRight: 10 }} - > - Edit Role - - - - } - - - - - {/* Profile Body */} - - - - {roles?.customTitle ? roles.customTitle : - (isVerified ? "Member" : - (isStudent ? "Student" : "Guest")) - } - - - {bio} - - {(isEmailPublic && email && email.trim() !== "") && ( - (handleLinkPress('mailto:' + email))} - > - - Email - - )} - - {resumeVerified && - handleLinkPress(resumePublicURL!)} - > - - Resume - - } - - - {committees && committees.length > 0 && ( - - Committees - - {committees?.map((committeeName, index) => { - const committeeData = committeesData.find(c => c.firebaseDocName === committeeName); - - return ( - - ); - })} - - - )} - - {isCurrentUser && ( - - navigation.navigate("PersonalEventLogScreen")} - className="rounded-md mt-8" - > - Personal Event Logs - - - - {events.map(({ eventData, eventLog }, index) => ( - - {eventData?.name} - Start Time: {formatTimestamp(eventData?.startTime)} - End Time: {formatTimestamp(eventData?.endTime)} - Total Points Earned: {eventLog?.points} - - - ))} - - - )} - - - - - {/* Role Modal */} - - - {/* Title */} - - - User Permissions - - - {/* Position Custom Title */} - - Enter a custom title - This is only used on profile screen - { - setModifiedRoles({ - ...modifiedRoles, - customTitle: text || "" - }) - }} - placeholder='Enter title' - value={modifiedRoles?.customTitle} - /> - - Select user role - - - {/* Position Selection */} - - setModifiedRoles({ ...modifiedRoles, admin: !modifiedRoles?.admin })} - darkMode={darkMode || false} - /> - setModifiedRoles({ ...modifiedRoles, developer: !modifiedRoles?.developer })} - darkMode={darkMode || false} - - /> - setModifiedRoles({ ...modifiedRoles, officer: !modifiedRoles?.officer })} - darkMode={darkMode || false} - /> - setModifiedRoles({ ...modifiedRoles, secretary: !modifiedRoles?.secretary })} - darkMode={darkMode || false} - /> - setModifiedRoles({ ...modifiedRoles, representative: !modifiedRoles?.representative })} - darkMode={darkMode || false} - /> - setModifiedRoles({ ...modifiedRoles, lead: !modifiedRoles?.lead })} - darkMode={darkMode || false} - /> - - - {/* Action Buttons */} - - { - - // checks if has role but no custom title - if ((modifiedRoles?.admin || modifiedRoles?.developer || modifiedRoles?.officer || modifiedRoles?.secretary || modifiedRoles?.representative || modifiedRoles?.lead) && !modifiedRoles?.customTitle && !modifiedRoles?.customTitle?.length) { - Alert.alert("Missing Title", "You must enter a title "); - return; - } - - - // Checks if has custom title but no role - if (!modifiedRoles?.admin && !modifiedRoles?.developer && !modifiedRoles?.officer && !modifiedRoles?.secretary && !modifiedRoles?.representative && !modifiedRoles?.lead && modifiedRoles?.customTitle) { - Alert.alert("Missing Role", "If a custom title is entered, you must select a role."); - return; - } - - setUpdatingRoles(true); - if (modifiedRoles) - await setUserRoles(uid, modifiedRoles) - .then(() => { - Alert.alert("Permissions Updated", "This user's roles have been updated successfully!") - }) - .catch((err) => { - console.error(err); - Alert.alert("An Issue Occured", "A server issue has occured. Please try again. If this keeps occurring, please contact a developer"); - }); - - setUpdatingRoles(false); - setShowRoleModal(false); - }} - className="bg-pale-blue rounded-lg justify-center items-center px-4 py-1" - > - Done - - - - { - setModifiedRoles(roles) - setShowRoleModal(false) - }} > - Cancel - - - {updatingRoles && } - - - - ) -} - -const formatTimestamp = (timestamp: Timestamp | null | undefined) => { - return timestamp ? new Date(timestamp.toDate()).toLocaleString() : 'N/A'; -}; - -export default PublicProfileScreen; \ No newline at end of file diff --git a/src/screens/UserProfile/Settings.tsx b/src/screens/UserProfile/Settings.tsx deleted file mode 100644 index f3ef2ffc..00000000 --- a/src/screens/UserProfile/Settings.tsx +++ /dev/null @@ -1,993 +0,0 @@ -import { View, Text, Image, ScrollView, TextInput, TouchableHighlight, TouchableOpacity, ActivityIndicator, KeyboardAvoidingView, Platform, Alert, Pressable, Animated } from 'react-native'; -import React, { useContext, useEffect, useRef, useState } from 'react'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { MaterialCommunityIcons } from '@expo/vector-icons' -import { StatusBar } from 'expo-status-bar'; -import * as ImagePicker from "expo-image-picker"; -import { Octicons, FontAwesome } from '@expo/vector-icons'; -import { UserContext } from '../../context/UserContext'; -import { auth } from '../../config/firebaseConfig'; -import { sendPasswordResetEmail, updateProfile } from 'firebase/auth'; -import { setPublicUserData, setPrivateUserData, getUser, getCommittees, submitFeedback, isUsernameUnique, deleteAccount } from '../../api/firebaseUtils'; -import { getBlobFromURI, selectFile, selectImage, uploadFile } from '../../api/fileSelection'; -import { CommonMimeTypes, validateDisplayName, validateFileBlob, validateName, validateTamuEmail } from '../../helpers/validation'; -import { handleLinkPress } from '../../helpers/links'; -import { getBadgeColor, isMemberVerified } from '../../helpers/membership'; -import { UserProfileStackParams } from '../../types/navigation'; -import { Committee } from '../../types/committees'; -import { MAJORS, classYears } from '../../types/user'; -import { Images } from '../../../assets'; -import DownloadIcon from '../../../assets/arrow-down-solid.svg'; -import UploadFileIcon from '../../../assets/file-arrow-up-solid-black.svg'; -import { SettingsSectionTitle, SettingsButton, SettingsToggleButton, SettingsListItem, SettingsSaveButton, SettingsModal } from "../../components/SettingsComponents" -import CustomDropDown from '../../components/CustomDropDown'; -import TwitterSvg from '../../components/TwitterSvg'; -import { Circle, Svg } from 'react-native-svg'; -import DismissibleModal from '../../components/DismissibleModal'; -import * as Clipboard from 'expo-clipboard'; - -/** - * Settings entrance screen which has a search function and paths to every other settings screen - */ -const SettingsScreen = ({ navigation }: NativeStackScreenProps) => { - const { userInfo, signOutUser } = useContext(UserContext)!; - - const { name, roles, photoURL, chapterExpiration, nationalExpiration } = userInfo?.publicInfo ?? {}; - const isOfficer = roles ? roles.officer : false; - - const [isVerified, setIsVerified] = useState(false); - let badgeColor = getBadgeColor(isOfficer!, isVerified); - - useEffect(() => { - if (nationalExpiration && chapterExpiration) { - setIsVerified(isMemberVerified(nationalExpiration, chapterExpiration)); - } - }, [nationalExpiration, chapterExpiration]) - - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; - - return ( - - - - - navigation.navigate("ProfileSettingsScreen")} - > - - - - - - {name} - {(isOfficer || isVerified) && } - - - Edit Profile - - - - - - - navigation.navigate("DisplaySettingsScreen")} - /> - navigation.navigate("AccountSettingsScreen")} - /> - navigation.navigate("FeedbackSettingsScreen")} - /> - navigation.navigate("FAQSettingsScreen")} - /> - navigation.navigate("AboutSettingsScreen")} - /> - - signOutUser(true)} - /> - - ) -} - - -/** - * Screen where a user can edit a majority of their public info. This includes thing like their profile picture, name, display name, committees, etc... - * These changes are synced in firebase. - */ -const ProfileSettingsScreen = ({ navigation }: NativeStackScreenProps) => { - const { userInfo, setUserInfo } = useContext(UserContext)!; - const [loading, setLoading] = useState(false); - const [image, setImage] = useState(null); - const [showSaveButton, setShowSaveButton] = useState(false); - - const defaultVals = { - photoURL: "", - displayName: "DISPLAY NAME", - name: "NAME", - bio: "Write a short bio...", - major: "MAJOR", - classYear: "CLASS YEAR", - committees: [], - } - - //Hooks used to save state of modified fields before user hits "save" - const [photoURL, setPhotoURL] = useState(userInfo?.publicInfo?.photoURL); - const [resumeURL, setResumeURL] = useState(userInfo?.private?.privateInfo?.resumeURL); - const [displayName, setDisplayName] = useState(userInfo?.publicInfo?.displayName); - const [name, setName] = useState(userInfo?.publicInfo?.name); - const [bio, setBio] = useState(userInfo?.publicInfo?.bio); - const [major, setMajor] = useState(userInfo?.publicInfo?.major); - const [classYear, setClassYear] = useState(userInfo?.publicInfo?.classYear); - const [openDropdown, setOpenDropdown] = useState(null); - const [committeesData, setCommitteesData] = useState([]); - const [committees, setCommittees] = useState(userInfo?.publicInfo?.committees || []); - const [prevCommittees, setPrevCommittees] = useState(userInfo?.publicInfo?.committees || []); - - - // Modal options - const [showNamesModal, setShowNamesModal] = useState(false); - const [showBioModal, setShowBioModal] = useState(false); - const [showAcademicInfoModal, setShowAcademicInfoModal] = useState(false); - const [showResumeModal, setShowResumeModal] = useState(false); - - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; - - useEffect(() => { - const fetchCommitteeData = async () => { - const response = await getCommittees(); - setCommitteesData(response); - } - fetchCommitteeData(); - }, []) - - /** - * Checks for any pending changes in user data. - * If any deviate from userInfo, display a "save" button which will save the changes to firebase. - */ - useEffect(() => { - if ( - photoURL != userInfo?.publicInfo?.photoURL - ) { - setShowSaveButton(true); - } - else { - setShowSaveButton(false); - } - }, [photoURL]); - - const selectProfilePicture = async () => { - await selectImage({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, - allowsEditing: true, - aspect: [1, 1], - quality: 1, - }).then(async (result) => { - if (result) { - const imageBlob = await getBlobFromURI(result.assets![0].uri); - if (imageBlob && validateFileBlob(imageBlob, CommonMimeTypes.IMAGE_FILES, true)) { - setPhotoURL(result.assets![0].uri); - setImage(imageBlob); - } - } - }).catch((err) => { - // TypeError means user did not select an image - if (err.name != "TypeError") { - console.error(err); - } - }); - } - - const selectResume = async () => { - const result = await selectFile(); - if (result) { - const resumeBlob = await getBlobFromURI(result.assets![0].uri); - return resumeBlob; - } - return null - } - - const onProfilePictureUploadSuccess = async (URL: string) => { - console.log("File available at", URL); - if (auth.currentUser) { - setPhotoURL(URL); - await updateProfile(auth.currentUser, { - photoURL: URL - }); - await setPublicUserData({ - photoURL: URL - }); - } - } - - // toggle dropdown for class year and major - const toggleDropdown = (dropdownKey: string) => { - if (openDropdown === dropdownKey) { - setOpenDropdown(null); - } else { - setOpenDropdown(dropdownKey); - } - }; - - const onResumeUploadSuccess = async (URL: string) => { - console.log("File available at", URL); - if (auth.currentUser) { - setResumeURL(URL); - await setPrivateUserData({ - resumeURL: URL - }); - } - - } - - const saveChanges = async () => { - setLoading(true) - // upload profile picture - if (image) { - await uploadFile( - image, - CommonMimeTypes.IMAGE_FILES, - `user-docs/${auth.currentUser?.uid}/user-profile-picture`, - onProfilePictureUploadSuccess - ); - } - - /** - * This is some very weird syntax and very javascript specific, so here's an explanation for what's going on: - * - * setPublicUserData() updates the fields that are in the object passed into it. - * The spread operator (...) adds each key in an object to the parent object. - * By adding a conditional and the && operator next to the child object, this essentially creates a "Conditional Key Addition". - * This makes it so the information will not be overridden in Firebase if the value of a key is empty/undefined. - */ - setPublicUserData({ - ...(photoURL !== undefined) && { photoURL: photoURL }, - ...(displayName !== undefined) && { displayName: displayName }, - ...(name !== undefined) && { name: name }, - ...(bio !== undefined) && { bio: bio }, - ...(major !== undefined) && { major: major }, - ...(classYear !== undefined) && { classYear: classYear }, - ...(committees !== undefined) && { committees: committees }, - }) - .then(async () => { - if (auth.currentUser) - await updateProfile(auth.currentUser, { - displayName: displayName, - photoURL: photoURL, - }) - - if (auth.currentUser?.uid) { - const firebaseUser = await getUser(auth.currentUser.uid); - if (firebaseUser) { - setUserInfo(firebaseUser); - await AsyncStorage.setItem("@user", JSON.stringify(firebaseUser)); - } - else { - console.warn("firebaseUser returned as undefined when attempting to sync. Sync will be skipped."); - } - } - }) - .catch(err => console.error("Error attempting to save changes: ", err)) - .finally(() => { - setLoading(false); - setShowSaveButton(false); - }); - - setPrivateUserData({ - ...(resumeURL !== undefined) && { resumeURL: resumeURL }, - }) - } - - - const CommitteeListItemComponent = ({ committeeData, onPress, darkMode, isChecked, committeeIndex }: any) => { - return ( - onPress()} - underlayColor={darkMode ? "#7a7a7a" : "#DDD"} - > - - - - {committeeData.name} - - {isChecked && {committeeIndex + 1}} - - - ); - }; - - const handleCommitteeToggle = (name: string) => { - setCommittees(prevCommittees => { - const isCommitteeSelected = prevCommittees.includes(name); - if (isCommitteeSelected) { - return prevCommittees.filter(committee => committee !== name); - } else { - return [...prevCommittees, name]; - } - }); - }; - - const findMajorByIso = (iso: string) => { - const majorObj = MAJORS.find(major => major.iso === iso); - return majorObj ? majorObj.major : null; - }; - - const progress = useRef(new Animated.Value(0)).current; - const setProgress = (newProgress: number) => { - if (newProgress <= 0) { - progress.setValue(0); - } else if (newProgress >= 100) { - progress.setValue(100); - } else { - Animated.timing(progress, { - toValue: newProgress, - duration: 500, - useNativeDriver: true, - }).start(); - } - }; - - const AnimatedCircle = Animated.createAnimatedComponent(Circle); - const circumference = 2 * Math.PI * 45; // 45 is the radius of the circle - const strokeDashoffset = progress.interpolate({ - inputRange: [0, 100], - outputRange: [circumference, 0] - }); - - - return ( - - - {/* Names Modal */} - { - setDisplayName(userInfo?.publicInfo?.displayName); - setName(userInfo?.publicInfo?.name); - setShowNamesModal(false); - }} - onDone={async () => { - if (validateDisplayName(displayName, true) && validateName(name, true)) { - const isUnique = await isUsernameUnique(displayName!); - if (isUnique) { - saveChanges(); - setShowNamesModal(false); - } else { - setDisplayName(userInfo?.publicInfo?.displayName); - alert("Display name is already taken. Please choose another one."); - } - } - - }} - content={( - - - Display Name - setDisplayName(text)} - value={displayName} - autoCorrect={false} - multiline - inputMode='text' - maxLength={80} - placeholder='Display Name...' - /> - - - Name - setName(text)} - value={name} - autoCorrect={false} - multiline - inputMode='text' - maxLength={80} - placeholder='Full Name..' - /> - - - )} - /> - {/* Bio Modal */} - { - setBio(userInfo?.publicInfo?.bio ?? defaultVals.bio); - setShowBioModal(false); - }} - onDone={() => { - saveChanges(); - setShowBioModal(false); - }} - content={( - - Bio - { - if (text.length <= 250) - setBio(text) - }} - value={bio} - multiline - numberOfLines={8} - placeholder='Write a short bio...' - placeholderTextColor={darkMode ? "#ddd" : "#000"} - /> - - )} - /> - {/* Academic Info Modal */} - { - setMajor(userInfo?.publicInfo?.major ?? defaultVals.major); - setClassYear(userInfo?.publicInfo?.classYear ?? defaultVals.classYear); - setShowAcademicInfoModal(false); - }} - onDone={() => { - saveChanges(); - setShowAcademicInfoModal(false) - }} - content={ - ( - - - setMajor(item.iso)} - searchKey="major" - label="Select major" - isOpen={openDropdown === "major"} - onToggle={() => toggleDropdown("major")} - title={"Major"} - selectedItemProp={{ iso: major, value: findMajorByIso(major!)! }} - dropDownClassName={Platform.OS == "ios" ? "top-20" : undefined} - darkMode={darkMode} - /> - - - setClassYear(item.iso)} - searchKey="year" - label="Select class year" - isOpen={openDropdown === 'year'} - onToggle={() => toggleDropdown('year')} - title={"Class Year"} - selectedItemProp={{ iso: classYear }} - displayType='iso' - dropDownClassName={Platform.OS == "ios" ? "top-20" : undefined} - disableSearch - darkMode={darkMode} - /> - - - - ) - } - /> - - {/* Resume Modal */} - setShowResumeModal(false)} - onDone={() => setShowResumeModal(false)} - darkMode={darkMode} - content={( - - - { - const selectedResume = await selectResume(); - if (selectedResume) { - uploadFile( - selectedResume, - CommonMimeTypes.RESUME_FILES, - `user-docs/${auth.currentUser?.uid}/user-resume`, - onResumeUploadSuccess, - setProgress - ); - } - }}> - - - - - - - - - - {resumeURL && ( - { handleLinkPress(resumeURL!) }}> - - View Resume - - - - - - )} - - - )} - /> - - - await selectProfilePicture()}> - - - - - - - setShowNamesModal(true)} - /> - setShowNamesModal(true)} - /> - setShowBioModal(true)} - /> - - setShowAcademicInfoModal(true)} - /> - setShowAcademicInfoModal(true)} - /> - - setShowResumeModal(true)} - /> - - {loading && } - - {showSaveButton && - saveChanges()} - /> - } - - ); -}; - -/** - * Screen where user can modify how to the app looks. - * These changes are synced in firebase. - */ -const DisplaySettingsScreen = ({ navigation }: NativeStackScreenProps) => { - const { userInfo, setUserInfo } = useContext(UserContext)!; - const [loading, setLoading] = useState(false); - const [darkModeToggled, setDarkModeToggled] = useState(userInfo?.private?.privateInfo?.settings?.darkMode ?? false); - - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode - - return ( - - - { - setDarkModeToggled(!darkModeToggled); - setLoading(true); - await setPrivateUserData({ - settings: { - darkMode: !darkMode - } - }) - .then(async () => { - if (auth.currentUser?.uid) { - await getUser(auth.currentUser?.uid) - .then(async (firebaseUser) => { - if (firebaseUser) { - setUserInfo(firebaseUser); - await AsyncStorage.setItem("@user", JSON.stringify(firebaseUser)); - } - else { - console.warn("firebaseUser returned as undefined when attempting to sync. Sync will be skipped."); - } - }) - .catch(err => console.error(err)); - } - }) - .catch((err) => console.error(err)) - .finally(() => { - setLoading(false); - }); - }} - /> - {loading && } - - ); -}; - -/** - * Screen where user can both view information about their account and request a change of their email and/or password. - * These changes will go through firebase where an email will be sent to the user. - */ -const AccountSettingsScreen = ({ navigation }: NativeStackScreenProps) => { - const { userInfo, setUserInfo, signOutUser } = useContext(UserContext)!; - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; - const deleteConfirmationText = "DELETECONFIRM"; - - const [deleteText, setDeleteText] = useState(''); - const [showDeleteModal, setShowDeleteModal] = useState(false); - - return ( - - - - - { - const updatedPublicData = { - ...userInfo?.publicInfo, - isEmailPublic: !userInfo?.publicInfo?.isEmailPublic, - email: !userInfo?.publicInfo?.isEmailPublic ? auth.currentUser?.email || "" : "", - }; - - await setPublicUserData(updatedPublicData); - - const updatedUserInfo = { - ...userInfo, - publicInfo: updatedPublicData, - }; - - await AsyncStorage.setItem("@user", JSON.stringify(updatedUserInfo)); - setUserInfo(updatedUserInfo); - Alert.alert("Email visibility updated successfully"); - } - } - darkMode={darkMode} - /> - - { - Clipboard.setStringAsync(auth.currentUser?.uid ?? "UID") - .then(() => Alert.alert("Copied", "UID Copied to Clipboard")); - }} - /> - - - {!validateTamuEmail(auth.currentUser?.email ?? "") && - { - Alert.alert("Password Reset", "An email reset link will be sent to your email.") - sendPasswordResetEmail(auth, auth.currentUser?.email!) - }} - darkMode={darkMode} - /> - } - - - { - setDeleteText(""); - setShowDeleteModal(true); - }} - darkMode={darkMode} - /> - - - - - {/* Title */} - - - Account Deletion - - - YOU WILL LOSE ALL YOUR POINTS IF YOU DELETE YOUR ACCOUNT - Please type "{deleteConfirmationText}" to confirm. - - - - - - - { - Alert.alert("Account Deleted", "Your account has been successfully deleted."); - setShowDeleteModal(false); - await deleteAccount(auth.currentUser?.uid!); - await AsyncStorage.removeItem('@user'); - setUserInfo(undefined); - }} - disabled={deleteText !== deleteConfirmationText} - className={`${deleteText !== deleteConfirmationText ? "bg-neutral-400" : "bg-red-700"} rounded-lg justify-center items-center px-4 py-1`} - > - DELETE - - - - { - setShowDeleteModal(false) - }} > - Cancel - - - - - - ); -}; - -const FeedBackSettingsScreen = ({ navigation }: NativeStackScreenProps) => { - const [feedback, setFeedback] = useState(''); - const { userInfo } = useContext(UserContext)!; - - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; - - const handleFeedbackSubmit = async () => { - const response = await submitFeedback(feedback, userInfo!); - if (response.success) { - setFeedback(''); - alert('Feedback submitted successfully'); - } else { - alert('Failed to submit feedback'); - } - }; - - return ( - - Tell us what can be improved - - - - - Submit FeedBack - - - ); -}; - -const FAQSettingsScreen = ({ navigation }: NativeStackScreenProps) => { - const [activeQuestion, setActiveQuestion] = useState(null); - const { userInfo } = useContext(UserContext)!; - - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; - - - const toggleQuestion = (questionNumber: number) => { - if (activeQuestion === questionNumber) { - setActiveQuestion(null); - } else { - setActiveQuestion(questionNumber); - } - }; - - const faqData: { question: string, answer: string }[] = [ - { - question: "What resources does SHPE provide?", - answer: "SHPE offers networking opportunities, professional development workshops, mentorship programs, scholarship opportunities, and community outreach initiatives." - }, - { - question: "How do I become an official SHPE member?", - answer: "To become an official member, register on the SHPE national website, pay the annual membership fee, and join your local chapter activities." - }, - { - question: "What is the Technical Affairs Committee?", - answer: "The Technical Affairs Committee organizes technical events and workshops, promotes STEM education, and provides members with opportunities to develop technical skills." - }, - { - question: "What is the MentorSHPE Committee?", - answer: "The MentorSHPE Committee facilitates mentoring relationships between professional members and students, offering guidance, career advice, and academic support." - }, - { - question: "What is the Scholastic Committee?", - answer: "The Scholastic Committee focuses on academic excellence by providing study sessions, educational resources, and academic advising to members." - }, - { - question: "What is the Secretary Committee?", - answer: "The Secretary Committee is responsible for maintaining organization records, documenting meetings and events, and ensuring effective communication within the chapter." - }, - { - question: "What is the SHPEtinas Committee?", - answer: "The SHPEtinas Committee empowers and supports female members of SHPE through networking events, workshops, and mentorship programs." - }, - { - question: "What do the points I acquire allow me to do?", - answer: "Points earned through participation in events and activities can be used for priority access to certain events, eligibility for exclusive opportunities, and recognition within the organization." - } - ]; - - return ( - - {faqData.map((faq, index) => ( - toggleQuestion(index)} - > - - {faq.question} - - - - - {activeQuestion === index && ( - - {faq.answer} - - )} - - ))} - - - ); -}; -/** - * This screen contains information about the app and info that may be useful to developers. - */ -const AboutSettingsScreen = ({ navigation }: NativeStackScreenProps) => { - const pkg: any = require("../../../package.json"); - const { userInfo } = useContext(UserContext)!; - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; - - return ( - - - - - handleLinkPress("https://jasonisazn.github.io/")} - /> - - ); -}; - - - -export { SettingsScreen, ProfileSettingsScreen, DisplaySettingsScreen, AccountSettingsScreen, FeedBackSettingsScreen, FAQSettingsScreen, AboutSettingsScreen }; diff --git a/src/screens/userProfile/PersonalEventLog.tsx b/src/screens/userProfile/PersonalEventLog.tsx deleted file mode 100644 index fce9d17d..00000000 --- a/src/screens/userProfile/PersonalEventLog.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { View, TouchableOpacity, ActivityIndicator, Text } from 'react-native' -import React, { useContext, useEffect, useState } from 'react' -import { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { Octicons } from '@expo/vector-icons'; -import { Timestamp } from 'firebase/firestore'; -import { UserContext } from '../../context/UserContext'; -import { auth } from '../../config/firebaseConfig'; -import { queryUserEventLogs } from '../../api/firebaseUtils'; -import { UserEventData } from '../../types/events'; -import { UserProfileStackParams } from '../../types/navigation'; - -const PersonalEventLog = ({ navigation }: NativeStackScreenProps) => { - const userContext = useContext(UserContext); - const { userInfo } = userContext!; - - const [events, setEvents] = useState([]); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - const fetchUserEventLogs = async () => { - if (auth.currentUser?.uid) { - try { - const data = await queryUserEventLogs(auth.currentUser?.uid); - setEvents(data); - } catch (error) { - console.error('Error fetching user event logs:', error); - } finally { - setIsLoading(false); - } - } - }; - - fetchUserEventLogs(); - }, []); - - if (isLoading) { - return ; - } - - - return ( - - - - navigation.goBack()} - className="rounded-full w-10 h-10 justify-center items-center" - style={{ backgroundColor: 'rgba(0,0,0,0.3)' }} - > - - - - - - - {events.map(({ eventData, eventLog }, index) => ( - - {eventData?.name} - Start Time: {formatTimestamp(eventData?.startTime)} - End Time: {formatTimestamp(eventData?.endTime)} - Total Points Earned: {eventLog?.points} - Sign-In Time: {formatTimestamp(eventLog?.signInTime)} - - {eventLog?.signOutTime && ( - Sign-Out Time: {formatTimestamp(eventLog?.signOutTime)} - )} - - ))} - - - - - - ) -} - -const formatTimestamp = (timestamp: Timestamp | null | undefined) => { - return timestamp ? new Date(timestamp.toDate()).toLocaleString() : 'N/A'; -}; - -export default PersonalEventLog \ No newline at end of file diff --git a/src/screens/userProfile/PublicProfile.tsx b/src/screens/userProfile/PublicProfile.tsx deleted file mode 100644 index 4046a153..00000000 --- a/src/screens/userProfile/PublicProfile.tsx +++ /dev/null @@ -1,494 +0,0 @@ -import { View, Text, ActivityIndicator, Image, Alert, TouchableOpacity, Pressable, TextInput, ScrollView, RefreshControl } from 'react-native'; -import React, { useState, useEffect, useContext, useCallback } from 'react'; -import { NativeStackNavigationProp, NativeStackScreenProps } from '@react-navigation/native-stack'; -import { RouteProp, useFocusEffect } from '@react-navigation/core'; -import { useRoute } from '@react-navigation/native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { Octicons, FontAwesome } from '@expo/vector-icons'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { StatusBar } from 'expo-status-bar'; -import { UserContext } from '../../context/UserContext'; -import { auth } from '../../config/firebaseConfig'; -import { getCommittees, getPublicUserData, getUser, queryUserEventLogs, setUserRoles } from '../../api/firebaseUtils'; -import { getBadgeColor, isMemberVerified } from '../../helpers/membership'; -import { handleLinkPress } from '../../helpers/links'; -import { UserProfileStackParams } from '../../types/navigation'; -import { PublicUserInfo, Roles } from '../../types/user'; -import { Committee } from '../../types/committees'; -import { Images } from '../../../assets'; -import TwitterSvg from '../../components/TwitterSvg'; -import ProfileBadge from '../../components/ProfileBadge'; -import DismissibleModal from '../../components/DismissibleModal'; -import { UserEventData } from '../../types/events'; -import { Timestamp } from 'firebase/firestore'; - -export type PublicProfileScreenProps = { - route: RouteProp; - navigation: NativeStackNavigationProp; -}; - -const PublicProfileScreen: React.FC = ({ route, navigation }) => { - // Data related to public profile user - const { uid } = route.params; - const [publicUserData, setPublicUserData] = useState(); - const { nationalExpiration, chapterExpiration, roles, photoURL, name, major, classYear, bio, points, resumeVerified, resumePublicURL, email, isStudent, committees, pointsRank, isEmailPublic } = publicUserData || {}; - const [committeesData, setCommitteesData] = useState([]); - const [events, setEvents] = useState([]); - const [modifiedRoles, setModifiedRoles] = useState(undefined); - const [isVerified, setIsVerified] = useState(false); - const isOfficer = roles ? roles.officer : false; - const badgeColor = getBadgeColor(isOfficer!, isVerified); - const isCurrentUser = uid === auth.currentUser?.uid; - - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [updatingRoles, setUpdatingRoles] = useState(false); - const [showRoleModal, setShowRoleModal] = useState(false); - const [showEventsLogModal, setEventsLogModal] = useState(false); - - // Data related to currently authenticated user - const { userInfo, setUserInfo } = useContext(UserContext)!; - const isSuperUser = userInfo?.publicInfo?.roles?.admin || userInfo?.publicInfo?.roles?.developer || userInfo?.publicInfo?.roles?.officer - - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; - - - const fetchUserData = async () => { - try { - const firebaseUser = await getUser(auth.currentUser?.uid!) - await AsyncStorage.setItem("@user", JSON.stringify(firebaseUser)); - setUserInfo(firebaseUser); - } catch (error) { - console.error("Error updating user:", error); - } finally { - setRefreshing(false); - } - } - - const onRefresh = useCallback(async () => { - if (isCurrentUser) { - setRefreshing(true); - fetchUserData(); - } - }, [uid]); - - useFocusEffect( - useCallback(() => { - const fetchPublicUserData = async () => { - await getPublicUserData(uid) - .then((res) => { - setPublicUserData(res); - setModifiedRoles(res?.roles); - }) - .catch((error) => console.error("Failed to fetch public user data:", error)) - .finally(() => { - setLoading(false); - }); - }; - - - if (isCurrentUser) { - fetchUserData(); - } - fetchPublicUserData(); - - return () => { }; - }, [auth]) - ); - - // used to get committee color for badges - useEffect(() => { - const fetchCommitteeData = async () => { - const response = await getCommittees(); - setCommitteesData(response); - } - - const fetchUserEventLogs = async () => { - if (auth.currentUser?.uid) { - try { - const data = await queryUserEventLogs(auth.currentUser?.uid); - setEvents(data); - } catch (error) { - console.error('Error fetching user event logs:', error); - } - } - }; - - fetchCommitteeData(); - fetchUserEventLogs(); - }, []) - - useEffect(() => { - if (nationalExpiration && chapterExpiration) { - setIsVerified(isMemberVerified(nationalExpiration, chapterExpiration)); - } - }, [nationalExpiration, chapterExpiration]) - - const RoleItem = ({ roleName, isActive, onToggle, darkMode }: { - roleName: string, - isActive: boolean, - onToggle: () => void, - darkMode: boolean - }) => { - return ( - - - {roleName} - - ); - }; - - if (loading) { - return ( - - - - ); - } - - if (!uid || uid === "") { - return ( - - - - - - - - - - navigation.goBack()} - className="rounded-full w-10 h-10 justify-center items-center" - style={{ backgroundColor: 'rgba(0,0,0,0.3)' }} - > - - - - - - - - - - No User Found - - - - - - - ) - } - - return ( - - } - bounces={isCurrentUser ? true : false} - > - - {/* Profile Header */} - - - - - - - {!isCurrentUser ? - navigation.goBack()} - className="rounded-full w-10 h-10 justify-center items-center" - style={{ backgroundColor: 'rgba(0,0,0,0.3)' }} - > - - - : - - } - - - {isCurrentUser && - navigation.navigate("SettingsScreen")} - className="rounded-md px-3 py-2" - style={{ backgroundColor: 'rgba(0,0,0,0.3)' }} - > - Edit - - } - - - - - - - - - {name ?? "Name"} - {(isOfficer || isVerified) && } - - - - - - {`${major} ${"'" + classYear?.substring(2)}`} - {points !== undefined && ` • ${points.toFixed(2)} pts`} - {points !== undefined && pointsRank && ` • rank ${pointsRank}`} - - - {isSuperUser && - - - setShowRoleModal(true)} - className="rounded-md px-3 py-2" - style={{ backgroundColor: 'rgba(0,0,0,0.3)', marginRight: 10 }} - > - Edit Role - - - - } - - - - - {/* Profile Body */} - - - - {roles?.customTitle ? roles.customTitle : - (isVerified ? "Member" : - (isStudent ? "Student" : "Guest")) - } - - - {bio} - - {(isEmailPublic && email && email.trim() !== "") && ( - (handleLinkPress('mailto:' + email))} - > - - Email - - )} - - {resumeVerified && - handleLinkPress(resumePublicURL!)} - > - - Resume - - } - - - {committees && committees.length > 0 && ( - - Committees - - {committees?.map((committeeName, index) => { - const committeeData = committeesData.find(c => c.firebaseDocName === committeeName); - - return ( - - ); - })} - - - )} - - {isCurrentUser && ( - - navigation.navigate("PersonalEventLogScreen")} - className="rounded-md mt-8" - > - Personal Event Logs - - - - {events.map(({ eventData, eventLog }, index) => ( - - {eventData?.name} - Start Time: {formatTimestamp(eventData?.startTime)} - End Time: {formatTimestamp(eventData?.endTime)} - Total Points Earned: {eventLog?.points} - - - ))} - - - )} - - - - - {/* Role Modal */} - - - {/* Title */} - - - User Permissions - - - {/* Position Custom Title */} - - Enter a custom title - This is only used on profile screen - { - setModifiedRoles({ - ...modifiedRoles, - customTitle: text || "" - }) - }} - placeholder='Enter title' - value={modifiedRoles?.customTitle} - /> - - Select user role - - - {/* Position Selection */} - - setModifiedRoles({ ...modifiedRoles, admin: !modifiedRoles?.admin })} - darkMode={darkMode || false} - /> - setModifiedRoles({ ...modifiedRoles, developer: !modifiedRoles?.developer })} - darkMode={darkMode || false} - - /> - setModifiedRoles({ ...modifiedRoles, officer: !modifiedRoles?.officer })} - darkMode={darkMode || false} - /> - setModifiedRoles({ ...modifiedRoles, secretary: !modifiedRoles?.secretary })} - darkMode={darkMode || false} - /> - setModifiedRoles({ ...modifiedRoles, representative: !modifiedRoles?.representative })} - darkMode={darkMode || false} - /> - setModifiedRoles({ ...modifiedRoles, lead: !modifiedRoles?.lead })} - darkMode={darkMode || false} - /> - - - {/* Action Buttons */} - - { - - // checks if has role but no custom title - if ((modifiedRoles?.admin || modifiedRoles?.developer || modifiedRoles?.officer || modifiedRoles?.secretary || modifiedRoles?.representative || modifiedRoles?.lead) && !modifiedRoles?.customTitle && !modifiedRoles?.customTitle?.length) { - Alert.alert("Missing Title", "You must enter a title "); - return; - } - - - // Checks if has custom title but no role - if (!modifiedRoles?.admin && !modifiedRoles?.developer && !modifiedRoles?.officer && !modifiedRoles?.secretary && !modifiedRoles?.representative && !modifiedRoles?.lead && modifiedRoles?.customTitle) { - Alert.alert("Missing Role", "If a custom title is entered, you must select a role."); - return; - } - - setUpdatingRoles(true); - if (modifiedRoles) - await setUserRoles(uid, modifiedRoles) - .then(() => { - Alert.alert("Permissions Updated", "This user's roles have been updated successfully!") - }) - .catch((err) => { - console.error(err); - Alert.alert("An Issue Occured", "A server issue has occured. Please try again. If this keeps occurring, please contact a developer"); - }); - - setUpdatingRoles(false); - setShowRoleModal(false); - }} - className="bg-pale-blue rounded-lg justify-center items-center px-4 py-1" - > - Done - - - - { - setModifiedRoles(roles) - setShowRoleModal(false) - }} > - Cancel - - - {updatingRoles && } - - - - ) -} - -const formatTimestamp = (timestamp: Timestamp | null | undefined) => { - return timestamp ? new Date(timestamp.toDate()).toLocaleString() : 'N/A'; -}; - -export default PublicProfileScreen; \ No newline at end of file diff --git a/src/screens/userProfile/Settings.tsx b/src/screens/userProfile/Settings.tsx deleted file mode 100644 index f3ef2ffc..00000000 --- a/src/screens/userProfile/Settings.tsx +++ /dev/null @@ -1,993 +0,0 @@ -import { View, Text, Image, ScrollView, TextInput, TouchableHighlight, TouchableOpacity, ActivityIndicator, KeyboardAvoidingView, Platform, Alert, Pressable, Animated } from 'react-native'; -import React, { useContext, useEffect, useRef, useState } from 'react'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { MaterialCommunityIcons } from '@expo/vector-icons' -import { StatusBar } from 'expo-status-bar'; -import * as ImagePicker from "expo-image-picker"; -import { Octicons, FontAwesome } from '@expo/vector-icons'; -import { UserContext } from '../../context/UserContext'; -import { auth } from '../../config/firebaseConfig'; -import { sendPasswordResetEmail, updateProfile } from 'firebase/auth'; -import { setPublicUserData, setPrivateUserData, getUser, getCommittees, submitFeedback, isUsernameUnique, deleteAccount } from '../../api/firebaseUtils'; -import { getBlobFromURI, selectFile, selectImage, uploadFile } from '../../api/fileSelection'; -import { CommonMimeTypes, validateDisplayName, validateFileBlob, validateName, validateTamuEmail } from '../../helpers/validation'; -import { handleLinkPress } from '../../helpers/links'; -import { getBadgeColor, isMemberVerified } from '../../helpers/membership'; -import { UserProfileStackParams } from '../../types/navigation'; -import { Committee } from '../../types/committees'; -import { MAJORS, classYears } from '../../types/user'; -import { Images } from '../../../assets'; -import DownloadIcon from '../../../assets/arrow-down-solid.svg'; -import UploadFileIcon from '../../../assets/file-arrow-up-solid-black.svg'; -import { SettingsSectionTitle, SettingsButton, SettingsToggleButton, SettingsListItem, SettingsSaveButton, SettingsModal } from "../../components/SettingsComponents" -import CustomDropDown from '../../components/CustomDropDown'; -import TwitterSvg from '../../components/TwitterSvg'; -import { Circle, Svg } from 'react-native-svg'; -import DismissibleModal from '../../components/DismissibleModal'; -import * as Clipboard from 'expo-clipboard'; - -/** - * Settings entrance screen which has a search function and paths to every other settings screen - */ -const SettingsScreen = ({ navigation }: NativeStackScreenProps) => { - const { userInfo, signOutUser } = useContext(UserContext)!; - - const { name, roles, photoURL, chapterExpiration, nationalExpiration } = userInfo?.publicInfo ?? {}; - const isOfficer = roles ? roles.officer : false; - - const [isVerified, setIsVerified] = useState(false); - let badgeColor = getBadgeColor(isOfficer!, isVerified); - - useEffect(() => { - if (nationalExpiration && chapterExpiration) { - setIsVerified(isMemberVerified(nationalExpiration, chapterExpiration)); - } - }, [nationalExpiration, chapterExpiration]) - - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; - - return ( - - - - - navigation.navigate("ProfileSettingsScreen")} - > - - - - - - {name} - {(isOfficer || isVerified) && } - - - Edit Profile - - - - - - - navigation.navigate("DisplaySettingsScreen")} - /> - navigation.navigate("AccountSettingsScreen")} - /> - navigation.navigate("FeedbackSettingsScreen")} - /> - navigation.navigate("FAQSettingsScreen")} - /> - navigation.navigate("AboutSettingsScreen")} - /> - - signOutUser(true)} - /> - - ) -} - - -/** - * Screen where a user can edit a majority of their public info. This includes thing like their profile picture, name, display name, committees, etc... - * These changes are synced in firebase. - */ -const ProfileSettingsScreen = ({ navigation }: NativeStackScreenProps) => { - const { userInfo, setUserInfo } = useContext(UserContext)!; - const [loading, setLoading] = useState(false); - const [image, setImage] = useState(null); - const [showSaveButton, setShowSaveButton] = useState(false); - - const defaultVals = { - photoURL: "", - displayName: "DISPLAY NAME", - name: "NAME", - bio: "Write a short bio...", - major: "MAJOR", - classYear: "CLASS YEAR", - committees: [], - } - - //Hooks used to save state of modified fields before user hits "save" - const [photoURL, setPhotoURL] = useState(userInfo?.publicInfo?.photoURL); - const [resumeURL, setResumeURL] = useState(userInfo?.private?.privateInfo?.resumeURL); - const [displayName, setDisplayName] = useState(userInfo?.publicInfo?.displayName); - const [name, setName] = useState(userInfo?.publicInfo?.name); - const [bio, setBio] = useState(userInfo?.publicInfo?.bio); - const [major, setMajor] = useState(userInfo?.publicInfo?.major); - const [classYear, setClassYear] = useState(userInfo?.publicInfo?.classYear); - const [openDropdown, setOpenDropdown] = useState(null); - const [committeesData, setCommitteesData] = useState([]); - const [committees, setCommittees] = useState(userInfo?.publicInfo?.committees || []); - const [prevCommittees, setPrevCommittees] = useState(userInfo?.publicInfo?.committees || []); - - - // Modal options - const [showNamesModal, setShowNamesModal] = useState(false); - const [showBioModal, setShowBioModal] = useState(false); - const [showAcademicInfoModal, setShowAcademicInfoModal] = useState(false); - const [showResumeModal, setShowResumeModal] = useState(false); - - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; - - useEffect(() => { - const fetchCommitteeData = async () => { - const response = await getCommittees(); - setCommitteesData(response); - } - fetchCommitteeData(); - }, []) - - /** - * Checks for any pending changes in user data. - * If any deviate from userInfo, display a "save" button which will save the changes to firebase. - */ - useEffect(() => { - if ( - photoURL != userInfo?.publicInfo?.photoURL - ) { - setShowSaveButton(true); - } - else { - setShowSaveButton(false); - } - }, [photoURL]); - - const selectProfilePicture = async () => { - await selectImage({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, - allowsEditing: true, - aspect: [1, 1], - quality: 1, - }).then(async (result) => { - if (result) { - const imageBlob = await getBlobFromURI(result.assets![0].uri); - if (imageBlob && validateFileBlob(imageBlob, CommonMimeTypes.IMAGE_FILES, true)) { - setPhotoURL(result.assets![0].uri); - setImage(imageBlob); - } - } - }).catch((err) => { - // TypeError means user did not select an image - if (err.name != "TypeError") { - console.error(err); - } - }); - } - - const selectResume = async () => { - const result = await selectFile(); - if (result) { - const resumeBlob = await getBlobFromURI(result.assets![0].uri); - return resumeBlob; - } - return null - } - - const onProfilePictureUploadSuccess = async (URL: string) => { - console.log("File available at", URL); - if (auth.currentUser) { - setPhotoURL(URL); - await updateProfile(auth.currentUser, { - photoURL: URL - }); - await setPublicUserData({ - photoURL: URL - }); - } - } - - // toggle dropdown for class year and major - const toggleDropdown = (dropdownKey: string) => { - if (openDropdown === dropdownKey) { - setOpenDropdown(null); - } else { - setOpenDropdown(dropdownKey); - } - }; - - const onResumeUploadSuccess = async (URL: string) => { - console.log("File available at", URL); - if (auth.currentUser) { - setResumeURL(URL); - await setPrivateUserData({ - resumeURL: URL - }); - } - - } - - const saveChanges = async () => { - setLoading(true) - // upload profile picture - if (image) { - await uploadFile( - image, - CommonMimeTypes.IMAGE_FILES, - `user-docs/${auth.currentUser?.uid}/user-profile-picture`, - onProfilePictureUploadSuccess - ); - } - - /** - * This is some very weird syntax and very javascript specific, so here's an explanation for what's going on: - * - * setPublicUserData() updates the fields that are in the object passed into it. - * The spread operator (...) adds each key in an object to the parent object. - * By adding a conditional and the && operator next to the child object, this essentially creates a "Conditional Key Addition". - * This makes it so the information will not be overridden in Firebase if the value of a key is empty/undefined. - */ - setPublicUserData({ - ...(photoURL !== undefined) && { photoURL: photoURL }, - ...(displayName !== undefined) && { displayName: displayName }, - ...(name !== undefined) && { name: name }, - ...(bio !== undefined) && { bio: bio }, - ...(major !== undefined) && { major: major }, - ...(classYear !== undefined) && { classYear: classYear }, - ...(committees !== undefined) && { committees: committees }, - }) - .then(async () => { - if (auth.currentUser) - await updateProfile(auth.currentUser, { - displayName: displayName, - photoURL: photoURL, - }) - - if (auth.currentUser?.uid) { - const firebaseUser = await getUser(auth.currentUser.uid); - if (firebaseUser) { - setUserInfo(firebaseUser); - await AsyncStorage.setItem("@user", JSON.stringify(firebaseUser)); - } - else { - console.warn("firebaseUser returned as undefined when attempting to sync. Sync will be skipped."); - } - } - }) - .catch(err => console.error("Error attempting to save changes: ", err)) - .finally(() => { - setLoading(false); - setShowSaveButton(false); - }); - - setPrivateUserData({ - ...(resumeURL !== undefined) && { resumeURL: resumeURL }, - }) - } - - - const CommitteeListItemComponent = ({ committeeData, onPress, darkMode, isChecked, committeeIndex }: any) => { - return ( - onPress()} - underlayColor={darkMode ? "#7a7a7a" : "#DDD"} - > - - - - {committeeData.name} - - {isChecked && {committeeIndex + 1}} - - - ); - }; - - const handleCommitteeToggle = (name: string) => { - setCommittees(prevCommittees => { - const isCommitteeSelected = prevCommittees.includes(name); - if (isCommitteeSelected) { - return prevCommittees.filter(committee => committee !== name); - } else { - return [...prevCommittees, name]; - } - }); - }; - - const findMajorByIso = (iso: string) => { - const majorObj = MAJORS.find(major => major.iso === iso); - return majorObj ? majorObj.major : null; - }; - - const progress = useRef(new Animated.Value(0)).current; - const setProgress = (newProgress: number) => { - if (newProgress <= 0) { - progress.setValue(0); - } else if (newProgress >= 100) { - progress.setValue(100); - } else { - Animated.timing(progress, { - toValue: newProgress, - duration: 500, - useNativeDriver: true, - }).start(); - } - }; - - const AnimatedCircle = Animated.createAnimatedComponent(Circle); - const circumference = 2 * Math.PI * 45; // 45 is the radius of the circle - const strokeDashoffset = progress.interpolate({ - inputRange: [0, 100], - outputRange: [circumference, 0] - }); - - - return ( - - - {/* Names Modal */} - { - setDisplayName(userInfo?.publicInfo?.displayName); - setName(userInfo?.publicInfo?.name); - setShowNamesModal(false); - }} - onDone={async () => { - if (validateDisplayName(displayName, true) && validateName(name, true)) { - const isUnique = await isUsernameUnique(displayName!); - if (isUnique) { - saveChanges(); - setShowNamesModal(false); - } else { - setDisplayName(userInfo?.publicInfo?.displayName); - alert("Display name is already taken. Please choose another one."); - } - } - - }} - content={( - - - Display Name - setDisplayName(text)} - value={displayName} - autoCorrect={false} - multiline - inputMode='text' - maxLength={80} - placeholder='Display Name...' - /> - - - Name - setName(text)} - value={name} - autoCorrect={false} - multiline - inputMode='text' - maxLength={80} - placeholder='Full Name..' - /> - - - )} - /> - {/* Bio Modal */} - { - setBio(userInfo?.publicInfo?.bio ?? defaultVals.bio); - setShowBioModal(false); - }} - onDone={() => { - saveChanges(); - setShowBioModal(false); - }} - content={( - - Bio - { - if (text.length <= 250) - setBio(text) - }} - value={bio} - multiline - numberOfLines={8} - placeholder='Write a short bio...' - placeholderTextColor={darkMode ? "#ddd" : "#000"} - /> - - )} - /> - {/* Academic Info Modal */} - { - setMajor(userInfo?.publicInfo?.major ?? defaultVals.major); - setClassYear(userInfo?.publicInfo?.classYear ?? defaultVals.classYear); - setShowAcademicInfoModal(false); - }} - onDone={() => { - saveChanges(); - setShowAcademicInfoModal(false) - }} - content={ - ( - - - setMajor(item.iso)} - searchKey="major" - label="Select major" - isOpen={openDropdown === "major"} - onToggle={() => toggleDropdown("major")} - title={"Major"} - selectedItemProp={{ iso: major, value: findMajorByIso(major!)! }} - dropDownClassName={Platform.OS == "ios" ? "top-20" : undefined} - darkMode={darkMode} - /> - - - setClassYear(item.iso)} - searchKey="year" - label="Select class year" - isOpen={openDropdown === 'year'} - onToggle={() => toggleDropdown('year')} - title={"Class Year"} - selectedItemProp={{ iso: classYear }} - displayType='iso' - dropDownClassName={Platform.OS == "ios" ? "top-20" : undefined} - disableSearch - darkMode={darkMode} - /> - - - - ) - } - /> - - {/* Resume Modal */} - setShowResumeModal(false)} - onDone={() => setShowResumeModal(false)} - darkMode={darkMode} - content={( - - - { - const selectedResume = await selectResume(); - if (selectedResume) { - uploadFile( - selectedResume, - CommonMimeTypes.RESUME_FILES, - `user-docs/${auth.currentUser?.uid}/user-resume`, - onResumeUploadSuccess, - setProgress - ); - } - }}> - - - - - - - - - - {resumeURL && ( - { handleLinkPress(resumeURL!) }}> - - View Resume - - - - - - )} - - - )} - /> - - - await selectProfilePicture()}> - - - - - - - setShowNamesModal(true)} - /> - setShowNamesModal(true)} - /> - setShowBioModal(true)} - /> - - setShowAcademicInfoModal(true)} - /> - setShowAcademicInfoModal(true)} - /> - - setShowResumeModal(true)} - /> - - {loading && } - - {showSaveButton && - saveChanges()} - /> - } - - ); -}; - -/** - * Screen where user can modify how to the app looks. - * These changes are synced in firebase. - */ -const DisplaySettingsScreen = ({ navigation }: NativeStackScreenProps) => { - const { userInfo, setUserInfo } = useContext(UserContext)!; - const [loading, setLoading] = useState(false); - const [darkModeToggled, setDarkModeToggled] = useState(userInfo?.private?.privateInfo?.settings?.darkMode ?? false); - - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode - - return ( - - - { - setDarkModeToggled(!darkModeToggled); - setLoading(true); - await setPrivateUserData({ - settings: { - darkMode: !darkMode - } - }) - .then(async () => { - if (auth.currentUser?.uid) { - await getUser(auth.currentUser?.uid) - .then(async (firebaseUser) => { - if (firebaseUser) { - setUserInfo(firebaseUser); - await AsyncStorage.setItem("@user", JSON.stringify(firebaseUser)); - } - else { - console.warn("firebaseUser returned as undefined when attempting to sync. Sync will be skipped."); - } - }) - .catch(err => console.error(err)); - } - }) - .catch((err) => console.error(err)) - .finally(() => { - setLoading(false); - }); - }} - /> - {loading && } - - ); -}; - -/** - * Screen where user can both view information about their account and request a change of their email and/or password. - * These changes will go through firebase where an email will be sent to the user. - */ -const AccountSettingsScreen = ({ navigation }: NativeStackScreenProps) => { - const { userInfo, setUserInfo, signOutUser } = useContext(UserContext)!; - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; - const deleteConfirmationText = "DELETECONFIRM"; - - const [deleteText, setDeleteText] = useState(''); - const [showDeleteModal, setShowDeleteModal] = useState(false); - - return ( - - - - - { - const updatedPublicData = { - ...userInfo?.publicInfo, - isEmailPublic: !userInfo?.publicInfo?.isEmailPublic, - email: !userInfo?.publicInfo?.isEmailPublic ? auth.currentUser?.email || "" : "", - }; - - await setPublicUserData(updatedPublicData); - - const updatedUserInfo = { - ...userInfo, - publicInfo: updatedPublicData, - }; - - await AsyncStorage.setItem("@user", JSON.stringify(updatedUserInfo)); - setUserInfo(updatedUserInfo); - Alert.alert("Email visibility updated successfully"); - } - } - darkMode={darkMode} - /> - - { - Clipboard.setStringAsync(auth.currentUser?.uid ?? "UID") - .then(() => Alert.alert("Copied", "UID Copied to Clipboard")); - }} - /> - - - {!validateTamuEmail(auth.currentUser?.email ?? "") && - { - Alert.alert("Password Reset", "An email reset link will be sent to your email.") - sendPasswordResetEmail(auth, auth.currentUser?.email!) - }} - darkMode={darkMode} - /> - } - - - { - setDeleteText(""); - setShowDeleteModal(true); - }} - darkMode={darkMode} - /> - - - - - {/* Title */} - - - Account Deletion - - - YOU WILL LOSE ALL YOUR POINTS IF YOU DELETE YOUR ACCOUNT - Please type "{deleteConfirmationText}" to confirm. - - - - - - - { - Alert.alert("Account Deleted", "Your account has been successfully deleted."); - setShowDeleteModal(false); - await deleteAccount(auth.currentUser?.uid!); - await AsyncStorage.removeItem('@user'); - setUserInfo(undefined); - }} - disabled={deleteText !== deleteConfirmationText} - className={`${deleteText !== deleteConfirmationText ? "bg-neutral-400" : "bg-red-700"} rounded-lg justify-center items-center px-4 py-1`} - > - DELETE - - - - { - setShowDeleteModal(false) - }} > - Cancel - - - - - - ); -}; - -const FeedBackSettingsScreen = ({ navigation }: NativeStackScreenProps) => { - const [feedback, setFeedback] = useState(''); - const { userInfo } = useContext(UserContext)!; - - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; - - const handleFeedbackSubmit = async () => { - const response = await submitFeedback(feedback, userInfo!); - if (response.success) { - setFeedback(''); - alert('Feedback submitted successfully'); - } else { - alert('Failed to submit feedback'); - } - }; - - return ( - - Tell us what can be improved - - - - - Submit FeedBack - - - ); -}; - -const FAQSettingsScreen = ({ navigation }: NativeStackScreenProps) => { - const [activeQuestion, setActiveQuestion] = useState(null); - const { userInfo } = useContext(UserContext)!; - - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; - - - const toggleQuestion = (questionNumber: number) => { - if (activeQuestion === questionNumber) { - setActiveQuestion(null); - } else { - setActiveQuestion(questionNumber); - } - }; - - const faqData: { question: string, answer: string }[] = [ - { - question: "What resources does SHPE provide?", - answer: "SHPE offers networking opportunities, professional development workshops, mentorship programs, scholarship opportunities, and community outreach initiatives." - }, - { - question: "How do I become an official SHPE member?", - answer: "To become an official member, register on the SHPE national website, pay the annual membership fee, and join your local chapter activities." - }, - { - question: "What is the Technical Affairs Committee?", - answer: "The Technical Affairs Committee organizes technical events and workshops, promotes STEM education, and provides members with opportunities to develop technical skills." - }, - { - question: "What is the MentorSHPE Committee?", - answer: "The MentorSHPE Committee facilitates mentoring relationships between professional members and students, offering guidance, career advice, and academic support." - }, - { - question: "What is the Scholastic Committee?", - answer: "The Scholastic Committee focuses on academic excellence by providing study sessions, educational resources, and academic advising to members." - }, - { - question: "What is the Secretary Committee?", - answer: "The Secretary Committee is responsible for maintaining organization records, documenting meetings and events, and ensuring effective communication within the chapter." - }, - { - question: "What is the SHPEtinas Committee?", - answer: "The SHPEtinas Committee empowers and supports female members of SHPE through networking events, workshops, and mentorship programs." - }, - { - question: "What do the points I acquire allow me to do?", - answer: "Points earned through participation in events and activities can be used for priority access to certain events, eligibility for exclusive opportunities, and recognition within the organization." - } - ]; - - return ( - - {faqData.map((faq, index) => ( - toggleQuestion(index)} - > - - {faq.question} - - - - - {activeQuestion === index && ( - - {faq.answer} - - )} - - ))} - - - ); -}; -/** - * This screen contains information about the app and info that may be useful to developers. - */ -const AboutSettingsScreen = ({ navigation }: NativeStackScreenProps) => { - const pkg: any = require("../../../package.json"); - const { userInfo } = useContext(UserContext)!; - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; - - return ( - - - - - handleLinkPress("https://jasonisazn.github.io/")} - /> - - ); -}; - - - -export { SettingsScreen, ProfileSettingsScreen, DisplaySettingsScreen, AccountSettingsScreen, FeedBackSettingsScreen, FAQSettingsScreen, AboutSettingsScreen }; From 7cff8188c536db2473009a632f53a38ce505ce9d Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 4 Jun 2024 01:11:46 -0500 Subject: [PATCH 016/198] temp fix --- src/screens/committees/Committee.tsx | 457 ---------- src/screens/committees/CommitteeCard.tsx | 90 -- src/screens/committees/CommitteeEditor.tsx | 783 ------------------ src/screens/committees/CommitteeTeamCard.tsx | 63 -- src/screens/committees/Committees.tsx | 114 --- .../Committee.tsx | 0 .../CommitteeCard.tsx | 0 .../CommitteeEditor.tsx | 0 .../CommitteeTeamCard.tsx | 0 .../Committees.tsx | 0 10 files changed, 1507 deletions(-) delete mode 100644 src/screens/committees/Committee.tsx delete mode 100644 src/screens/committees/CommitteeCard.tsx delete mode 100644 src/screens/committees/CommitteeEditor.tsx delete mode 100644 src/screens/committees/CommitteeTeamCard.tsx delete mode 100644 src/screens/committees/Committees.tsx rename src/screens/{Committees => committees_temp}/Committee.tsx (100%) rename src/screens/{Committees => committees_temp}/CommitteeCard.tsx (100%) rename src/screens/{Committees => committees_temp}/CommitteeEditor.tsx (100%) rename src/screens/{Committees => committees_temp}/CommitteeTeamCard.tsx (100%) rename src/screens/{Committees => committees_temp}/Committees.tsx (100%) diff --git a/src/screens/committees/Committee.tsx b/src/screens/committees/Committee.tsx deleted file mode 100644 index 2a2e0dca..00000000 --- a/src/screens/committees/Committee.tsx +++ /dev/null @@ -1,457 +0,0 @@ -import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator } from 'react-native' -import React, { useCallback, useContext, useEffect, useState } from 'react' -import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { collection, deleteDoc, doc, getDoc, getDocs, setDoc } from 'firebase/firestore'; -import { Octicons } from '@expo/vector-icons'; -import { StatusBar } from 'expo-status-bar'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { UserContext } from '../../context/UserContext'; -import { getCommitteeEvents, getPublicUserData, setPublicUserData } from '../../api/firebaseUtils'; -import { calculateHexLuminosity } from '../../helpers/colorUtils'; -import { handleLinkPress } from '../../helpers/links'; -import { getLogoComponent } from '../../types/committees'; -import { SHPEEvent } from '../../types/events'; -import { PublicUserInfo } from '../../types/user'; -import DismissibleModal from '../../components/DismissibleModal'; -import { auth, db } from '../../config/firebaseConfig'; -import EventsList from '../../components/EventsList'; -import MembersList from '../../components/MembersList'; -import CommitteeTeamCard from './CommitteeTeamCard'; -import { RouteProp } from '@react-navigation/core'; -import { CommitteesStackParams } from '../../types/navigation'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; - - -const Committee: React.FC = ({ route, navigation }) => { - const initialCommittee = route.params.committee; - - const { name, color, logo, description, memberApplicationLink, representativeApplicationLink, leadApplicationLink, firebaseDocName, isOpen, memberCount } = initialCommittee; - const [events, setEvents] = useState([]); - const { LogoComponent, height, width } = getLogoComponent(logo); - const luminosity = calculateHexLuminosity(color!); - const isLightColor = luminosity < 155; - - const { userInfo, setUserInfo } = useContext(UserContext)!; - const [isInCommittee, setIsInCommittee] = useState(); - const [confirmVisible, setConfirmVisible] = useState(false); - const [loading, setLoading] = useState(true); - const [loadingMembers, setLoadingMembers] = useState(false); - const [loadingCountChange, setLoadingCountChange] = useState(false) - const [isRequesting, setIsRequesting] = useState(false); - const [members, setMembers] = useState([]); - const [forceUpdate, setForceUpdate] = useState(0); - const [membersListVisible, setMembersListVisible] = useState(false); - const insets = useSafeAreaInsets(); - - const [localTeamMembers, setLocalTeamMembers] = useState({ - leads: [], - representatives: [], - head: null, - }); - - useEffect(() => { - const fetchEvents = async () => { - setLoading(true); - const response = await getCommitteeEvents([firebaseDocName!]); - setEvents(response); - setLoading(false); - } - - const fetchUserData = async () => { - if (initialCommittee) { - const { head, representatives, leads } = initialCommittee; - const newTeamMembers: TeamMembersState = { leads: [], representatives: [], head: null }; - - if (head) { - const headData = await getPublicUserData(head); - if (headData) { - headData.uid = head; - newTeamMembers.head = headData; - } - } - - if (representatives && representatives.length > 0) { - newTeamMembers.representatives = await Promise.all( - representatives.map(async (uid) => { - const repData = await getPublicUserData(uid); - if (repData) { - repData.uid = uid; - } - return repData; - }) - ); - } - - if (leads && leads.length > 0) { - newTeamMembers.leads = await Promise.all( - leads.map(async (uid) => { - const leadData = await getPublicUserData(uid); - if (leadData) { - leadData.uid = uid; - } - return leadData; - }) - ); - } - - setLocalTeamMembers(newTeamMembers); - } - }; - - fetchEvents(); - fetchUserData(); - }, []) - - useEffect(() => { - const committeeExists = userInfo?.publicInfo?.committees?.includes(firebaseDocName!); - setIsInCommittee(committeeExists); - }, [userInfo]); - - useEffect(() => { - const checkRequestStatus = async () => { - if (auth.currentUser && !isInCommittee) { - const requestRef = doc(db, `committeeVerification/${firebaseDocName}/requests/${auth.currentUser.uid}`); - const requestSnapshot = await getDoc(requestRef); - setIsRequesting(requestSnapshot.exists()); - } - }; - - checkRequestStatus(); - }, [auth.currentUser, isInCommittee, firebaseDocName, db]); - - const fetchCommitteeMembers = async (committeeFirebaseDocName: string) => { - // Force update only happens once - if (forceUpdate == 1) { - return; - } - setLoadingMembers(true); - - const allUsersSnapshot = await await getDocs(collection(db, 'users')); - const committeeMembers: PublicUserInfo[] = []; - - for (const userDoc of allUsersSnapshot.docs) { - const userData = userDoc.data(); - if (userData.committees && userData.committees.includes(committeeFirebaseDocName)) { - committeeMembers.push({ ...userData, uid: userDoc.id }); - } - } - - setMembers(committeeMembers); - setLoadingMembers(false); - setForceUpdate(1); - }; - - const submitCommitteeRequest = useCallback(async () => { - if (auth.currentUser) { - await setDoc(doc(db, `committeeVerification/${firebaseDocName}/requests/${auth.currentUser.uid}`), { - uploadDate: new Date().toISOString(), - }, { merge: true }); - } - }, [userInfo]); - - const removeCommitteeRequest = useCallback(async () => { - if (auth.currentUser) { - const requestDocRef = doc(db, `committeeVerification/${firebaseDocName}/requests/${auth.currentUser.uid}`); - await deleteDoc(requestDocRef); - } - }, [userInfo]); - - const handleJoinLeave = async () => { - setLoadingCountChange(true); - if (isInCommittee) { - let updatedCommittees = [...userInfo?.publicInfo?.committees || []]; - updatedCommittees = updatedCommittees.filter(c => c !== firebaseDocName); - - try { - await setPublicUserData({ committees: updatedCommittees }); - - const updatedUserInfo = { - ...userInfo, - publicInfo: { - ...userInfo?.publicInfo, - committees: updatedCommittees - } - }; - - try { - await AsyncStorage.setItem("@user", JSON.stringify(updatedUserInfo)); - setUserInfo(updatedUserInfo); - } catch (error) { - console.error("Error updating user info:", error); - } - - } catch (err) { - console.error(err); - } - } else { - if (isOpen) { - let updatedCommittees = [...userInfo?.publicInfo?.committees || []]; - updatedCommittees.push(firebaseDocName!); - - try { - await setPublicUserData({ committees: updatedCommittees }); - - const updatedUserInfo = { - ...userInfo, - publicInfo: { - ...userInfo?.publicInfo, - committees: updatedCommittees - } - }; - - try { - await AsyncStorage.setItem("@user", JSON.stringify(updatedUserInfo)); - setUserInfo(updatedUserInfo); - } catch (error) { - console.error("Error updating user info:", error); - } - - } catch (err) { - console.error(err); - } - } else { - submitCommitteeRequest(); - setIsRequesting(true); - } - } - setLoadingCountChange(false); - } - - return ( - - - {/* Header */} - - - - {name} - { - setMembersListVisible(true); - fetchCommitteeMembers(firebaseDocName!); - }} - > - {memberCount} Members - - - navigation.goBack()}> - - - - - - {/* Content */} - - - {/* Logo and Join/Leave Button */} - - - - - { - if (!userInfo?.publicInfo?.isStudent) { - alert("You must be a student to join a committee.") - return; - } - - if (isRequesting) { - removeCommitteeRequest(); - setIsRequesting(false) - } else { - // Join or Leave confirmation - setConfirmVisible(!confirmVisible) - } - }} - disabled={loadingCountChange} - > - - {loadingCountChange ? ( - - ) : ( - - {isInCommittee ? "Leave" : isRequesting ? "Cancel\nRequest" : "Join"} - - )} - - - - {/* Name and Application Buttons */} - - - {name} - {isOpen ? "(Open)" : "(Closed)"} - - - {memberApplicationLink && ( - handleLinkPress(memberApplicationLink!)} - > - Member Application - - )} - {representativeApplicationLink && ( - - handleLinkPress(representativeApplicationLink!)} - > - Representative Application - - )} - {leadApplicationLink && ( - - handleLinkPress(leadApplicationLink!)} - > - Lead Application - - )} - - - - - {/* About */} - - About - {description} - - - {/* Upcoming Events */} - - Upcoming Events - - - - {/* Team List */} - - Meet the Team - - - Head - - - {localTeamMembers.representatives && localTeamMembers.representatives.length > 0 && ( - <> - Representatives - {localTeamMembers.representatives.map((representative, index) => ( - - - - ))} - - )} - {localTeamMembers.leads && localTeamMembers.leads.length > 0 && ( - <> - Leads - {localTeamMembers.leads.map((lead, index) => ( - - - - ))} - - )} - - - - - - - - - - - {name} - - - { - setMembersListVisible(false); - }}> - - - - - - {loadingMembers && ( - - - - )} - - - { - navigation.navigate('PublicProfile', { uid }) - setMembersListVisible(false); - }} - users={members} - /> - - - - - - - - - - - {isInCommittee ? "Are you sure you want leave?" : "Are you sure you want to join?"} - - { - setConfirmVisible(false); - handleJoinLeave(); - }} - > - {isInCommittee ? "Leave" : "Join"} - - - { setConfirmVisible(false) }} > - Cancel - - - - - - - ) -} - -interface TeamMembersState { - leads: (PublicUserInfo | undefined)[]; - representatives: (PublicUserInfo | undefined)[]; - head: PublicUserInfo | null | undefined; -} - -type CommitteeScreenRouteProps = { - route: RouteProp; - navigation: NativeStackNavigationProp; -}; - -export default Committee \ No newline at end of file diff --git a/src/screens/committees/CommitteeCard.tsx b/src/screens/committees/CommitteeCard.tsx deleted file mode 100644 index c3a2b666..00000000 --- a/src/screens/committees/CommitteeCard.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { View, Text, Image, TouchableOpacity } from 'react-native'; -import React, { useContext, useEffect, useState } from 'react' -import { UserContext } from '../../context/UserContext'; -import { calculateHexLuminosity } from '../../helpers/colorUtils'; -import { Committee, getLogoComponent } from "../../types/committees"; -import { Images } from "../../../assets" -import { PublicUserInfo } from '../../types/user'; -import { getPublicUserData } from '../../api/firebaseUtils'; - -const CommitteeCard: React.FC = ({ committee, handleCardPress, navigation }) => { - const { name, color, logo, head, memberCount } = committee; - const { userInfo } = useContext(UserContext)!; - const isSuperUser = userInfo?.publicInfo?.roles?.admin || userInfo?.publicInfo?.roles?.developer || userInfo?.publicInfo?.roles?.officer - const { LogoComponent, height, width } = getLogoComponent(logo); - - const isTextLight = (colorHex: string) => { - const luminosity = calculateHexLuminosity(colorHex); - return luminosity < 155; - }; - - const [localHead, setLocalHead] = useState(null); - - useEffect(() => { - const fetchHeadData = async () => { - if (head) { - const headData = await getPublicUserData(head); - setLocalHead(headData || null); - } - } - fetchHeadData(); - }, []) - - return ( - - { - if (navigation) { - navigation.navigate("CommitteeScreen", { committee }) - } - if (handleCardPress) { - handleCardPress(committee?.firebaseDocName!) - } - }} - className='flex-row w-[90%] h-28 rounded-xl' - style={{ backgroundColor: color }} - > - - - - - - {localHead && ( - - - - )} - - - - - {name} - - - {memberCount} Members - - - - - {isSuperUser && ( - { navigation.navigate("CommitteeEditor", { committee }) }} - className='absolute right-10 bg-pale-blue rounded-lg px-5 py-1 -top-3' - > - Edit - - )} - - ); -}; - - -interface CommitteeCardProps { - committee: Committee - navigation?: any - canEdit?: boolean - handleCardPress?: (uid: string) => string | void; -} - - -export default CommitteeCard; \ No newline at end of file diff --git a/src/screens/committees/CommitteeEditor.tsx b/src/screens/committees/CommitteeEditor.tsx deleted file mode 100644 index ea5a24b3..00000000 --- a/src/screens/committees/CommitteeEditor.tsx +++ /dev/null @@ -1,783 +0,0 @@ -import { View, Text, TextInput, TouchableOpacity, ScrollView, Modal, Pressable, Switch, FlatList } from 'react-native' -import React, { useEffect, useState } from 'react' -import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { Octicons, FontAwesome } from '@expo/vector-icons'; -import { deleteCommittee, getLeads, getPublicUserData, getRepresentatives, getTeamMembers, resetCommittee, setCommitteeData } from '../../api/firebaseUtils'; -import { Committee, committeeLogos, getLogoComponent } from '../../types/committees'; -import { PublicUserInfo } from '../../types/user'; -import MembersList from '../../components/MembersList'; -import DismissibleModal from '../../components/DismissibleModal'; -import CommitteeTeamCard from './CommitteeTeamCard'; -import { CommitteesStackParams } from '../../types/navigation'; -import { RouteProp } from '@react-navigation/core'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; - -const CommitteeEditor = ({ navigation, route }: CommitteeEditorProps) => { - const committeeData = route?.params?.committee; - const [localCommitteeData, setLocalCommitteeData] = useState(committeeData || { - leads: [], - representatives: [], - memberCount: 0, - memberApplicationLink: '', - representativeApplicationLink: '', - leadApplicationLink: '', - isOpen: false - }); - - const [localTeamMembers, setLocalTeamMembers] = useState({ - leads: [], - representatives: [], - head: null, - }); - - const [logoSelectModal, setLogoSelectModal] = useState(false); - const [selectedLogoData, setSelectedLogoData] = useState<{ - LogoComponent: React.ElementType; - width: number; - height: number; - } | null>(null); - const [teamMembers, setTeamMembers] = useState([]) - const [representatives, setRepresentatives] = useState([]) - const [leads, setLeads] = useState([]) - const [headModalVisible, setHeadModalVisible] = useState(false); - const [leadsModalVisible, setLeadsModalVisible] = useState(false); - const [repsModalVisible, setRepsModalVisible] = useState(false); - const [resetModalVisible, setResetModalVisible] = useState(false); - const [deleteModalVisible, setDeleteModalVisible] = useState(false); - const [isMemberLinkActive, setIsMemberLinkActive] = useState(!!committeeData?.memberApplicationLink); - const [isRepLinkActive, setIsRepLinkActive] = useState(!!committeeData?.representativeApplicationLink); - const [isLeadLinkActive, setIsLeadLinkActive] = useState(!!committeeData?.leadApplicationLink); - const [isOpen, setIsOpen] = useState(!!committeeData?.isOpen); - - const insets = useSafeAreaInsets(); - - useEffect(() => { - const fetchUserData = async () => { - if (committeeData) { - const { head, representatives, leads } = committeeData; - const newTeamMembers: TeamMembersState = { leads: [], representatives: [], head: null }; - - if (head) { - newTeamMembers.head = await getPublicUserData(head); - } - - if (representatives && representatives.length > 0) { - newTeamMembers.representatives = await Promise.all( - representatives.map(async (uid) => await getPublicUserData(uid)) - ); - } - - if (leads && leads.length > 0) { - newTeamMembers.leads = await Promise.all( - leads.map(async (uid) => await getPublicUserData(uid)) - ); - } - setLocalTeamMembers(newTeamMembers); - } - }; - - fetchUserData(); - }, [committeeData]); - - useEffect(() => { - const fetchTeamUsers = async () => { - const fetchTeamMembers = await getTeamMembers(); - const fetchRepresentatives = await getRepresentatives(); - const fetchLeads = await getLeads(); - if (fetchTeamMembers) { - setTeamMembers(fetchTeamMembers) - } - if (fetchRepresentatives) { - setRepresentatives(fetchRepresentatives) - } - if (fetchLeads) { - setLeads(fetchLeads) - } - } - - fetchTeamUsers(); - }, []) - - const setHeadUserData = (uid: string,) => { - const headInfo = teamMembers.find(member => member.uid === uid); - if (headInfo) { - setLocalTeamMembers({ - ...localTeamMembers, - head: headInfo - }); - } - - setLocalCommitteeData({ - ...localCommitteeData, - head: uid - }); - - }; - - const setLeadUserData = (uid: string) => { - const leadInfo = leads.find(lead => lead.uid === uid); - if (leadInfo) { - setLocalTeamMembers(prevTeamMembers => ({ - ...prevTeamMembers, - leads: [...(prevTeamMembers?.leads || []), leadInfo] - })); - } - - setLocalCommitteeData(prevCommitteeData => ({ - ...prevCommitteeData, - leads: [...(prevCommitteeData?.leads || []), uid] - })); - - - }; - - const setRepresentativeUserData = (uid: string) => { - const repInfo = representatives.find(rep => rep.uid === uid); - if (repInfo) { - setLocalTeamMembers(prevTeamMembers => ({ - ...prevTeamMembers, - representatives: [...(prevTeamMembers?.representatives || []), repInfo] - })); - } - - setLocalCommitteeData(prevCommitteeData => ({ - ...prevCommitteeData, - representatives: [...(prevCommitteeData?.representatives || []), uid] - })); - }; - - const addLead = (uid: string) => { - const currentUIDList = localCommitteeData?.leads || []; - if (currentUIDList.includes(uid)) { - return; - } - - setLeadUserData(uid); - }; - - const addRepresentative = (uid: string) => { - const currentUIDList = localCommitteeData?.representatives || []; - if (currentUIDList.includes(uid)) { // Check if the UID already exists in the list - return; - } - - setRepresentativeUserData(uid); - }; - - const removeHead = () => { - setLocalCommitteeData(prevCommitteeData => ({ - ...prevCommitteeData, - head: undefined - })); - - setLocalTeamMembers(prevTeamMembers => ({ - ...prevTeamMembers, - head: null - })); - - } - - - - const removeLead = (uid: string) => { - setLocalCommitteeData(prevCommitteeData => ({ - ...prevCommitteeData, - leads: prevCommitteeData?.leads?.filter(existingUID => existingUID !== uid) || [] - })); - - setLocalTeamMembers(prevTeamMembers => ({ - ...prevTeamMembers, - leads: prevTeamMembers?.leads?.filter(lead => lead?.uid !== uid) || [] - })); - }; - - const removeRepresentative = (uid: string) => { - setLocalCommitteeData(prevCommitteeData => ({ - ...prevCommitteeData, - representatives: prevCommitteeData?.representatives?.filter(existingUID => existingUID !== uid) || [] - })); - - setLocalTeamMembers(prevTeamMembers => ({ - ...prevTeamMembers, - representatives: prevTeamMembers?.representatives?.filter(representative => representative?.uid !== uid) || [] - })); - }; - - - const handleColorChosen = (color: string) => { - setLocalCommitteeData({ - ...localCommitteeData, - color: color - }); - }; - - const handleResetCommittee = async () => { - if (localCommitteeData.firebaseDocName) { - await resetCommittee(localCommitteeData.firebaseDocName); - } - }; - - const handleDeleteCommittee = async () => { - if (localCommitteeData.firebaseDocName) { - await deleteCommittee(localCommitteeData.firebaseDocName); - } - }; - - // Update the selected logo component whenever localCommitteeData.logo changes - useEffect(() => { - if (localCommitteeData.logo) { - const logoData = getLogoComponent(localCommitteeData.logo); - setSelectedLogoData(logoData); - } - }, [localCommitteeData.logo]); - - const BubbleToggle = ({ isActive, onToggle, label }: { - isActive: boolean, - onToggle: () => void, - label: string - }) => { - return ( - - - - - {label} - - ); - }; - - const renderItem = ({ item }: { item: any }) => { - const [name, logoData] = item; - return ( - { - setLocalCommitteeData({ ...localCommitteeData, logo: name }); - setLogoSelectModal(false); - }} - > - - - ); - }; - - return ( - - {/* Header */} - - - Committee - - navigation.goBack()} className='p-2'> - - - - - - {/* Logo, Name, and Color Selection */} - - {selectedLogoData && (() => { - const { LogoComponent, width, height } = selectedLogoData; - return ( - - - setLogoSelectModal(true)} - > - - - { - setLocalCommitteeData({ ...localCommitteeData, logo: undefined }) - setSelectedLogoData(null) - }} - > - - - - ); - })()} - - {!selectedLogoData && ( - setLogoSelectModal(true)} - > - - - - UPLOAD - - - - - - - )} - - - { - const trimmedText = text.trim(); - const formattedFirebaseName = trimmedText.toLowerCase().replace(/\s+/g, '-'); - setLocalCommitteeData({ - ...localCommitteeData, - name: text, - firebaseDocName: formattedFirebaseName - }); - }} - value={localCommitteeData?.name} - editable={!committeeData} - selectTextOnFocus={!committeeData} - placeholder='Select a committee name' - /> - - - - Open Committee - { - setIsOpen(previousState => !previousState) - setLocalCommitteeData({ ...localCommitteeData, isOpen: !isOpen }) - }} - value={isOpen} - /> - - - - - - {/* Team Selection */} - - Choose your team - - - Head - {!localCommitteeData.head && ( - setHeadModalVisible(true)} - > - - - )} - - {localTeamMembers.head && ( - - - - { removeHead() }} - > - - - - )} - - - - - Representative - setRepsModalVisible(true)} - > - - - - {localTeamMembers.representatives?.map((representative, index) => ( - - - - { removeRepresentative(representative?.uid!) }} - > - - - - ))} - - - - - Leads - setLeadsModalVisible(true)} - > - - - - {localTeamMembers.leads?.map((lead, index) => ( - - - - { removeLead(lead?.uid!) }} - > - - - - ))} - - - - {/* Description Form */} - - Description - { - if (text.length <= 250) { - setLocalCommitteeData({ ...localCommitteeData, description: text }) - } - }} - placeholder="Add a description" - multiline={true} - style={{ textAlignVertical: 'top' }} - /> - - - {/* Application */} - - Applications - { setIsMemberLinkActive(!isMemberLinkActive) }} - label="Members Application Link" - /> - {isMemberLinkActive && ( - setLocalCommitteeData({ ...localCommitteeData, memberApplicationLink: text })} - placeholder="Add member application link" - /> - )} - - {/* Representatives Application Link */} - { setIsRepLinkActive(!isRepLinkActive) }} - label="Representatives Application Link" - /> - {isRepLinkActive && ( - setLocalCommitteeData({ ...localCommitteeData, representativeApplicationLink: text })} - placeholder="Add representative application link" - /> - )} - - {/* Leads Application Link */} - { setIsLeadLinkActive(!isLeadLinkActive) }} - label="Leads Application Link" - /> - {isLeadLinkActive && ( - setLocalCommitteeData({ ...localCommitteeData, leadApplicationLink: text })} - placeholder="Add lead application link" - /> - )} - - - - - { - const updatedCommitteeData = { - ...localCommitteeData, - memberApplicationLink: isMemberLinkActive ? localCommitteeData.memberApplicationLink : '', - representativeApplicationLink: isRepLinkActive ? localCommitteeData.representativeApplicationLink : '', - leadApplicationLink: isLeadLinkActive ? localCommitteeData.leadApplicationLink : '' - }; - - await setCommitteeData(updatedCommitteeData); - navigation.goBack(); - }} - > - {committeeData ? "Update Committee " : "Create Committee"} - - - {committeeData && ( - - - - { setResetModalVisible(true) }} - > - Reset - - - - { setDeleteModalVisible(true) }} - > - Delete - - - - - )} - - - - - { - setHeadModalVisible(false); - }} - > - - - - - Select a Head - - setHeadModalVisible(false)} - > - - - - - - - { - setHeadModalVisible(false) - setHeadUserData(uid) - }} - users={teamMembers} - /> - - - - - { - setLeadsModalVisible(false); - }} - > - - - - - Select a Lead - - setLeadsModalVisible(false)} - > - - - - - - - { - addLead(uid) - setLeadsModalVisible(false) - }} - users={leads} - /> - - - - - { - setRepsModalVisible(false); - }} - > - - - - - Select a Rep - - setRepsModalVisible(false)} - > - - - - - - - - { - addRepresentative(uid) - setRepsModalVisible(false) - }} - users={representatives} - /> - - - - - - - - - Select a Logo - - - setLogoSelectModal(false)}> - - - - - - item[0]} - numColumns={3} - contentContainerStyle={{ padding: 10 }} - style={{ backgroundColor: 'gray', borderRadius: 10 }} - /> - - - - - - - - - Reset Committee - - - - - - { - await handleResetCommittee() - setResetModalVisible(false) - navigation.goBack() - }} - > - Reset - - - setResetModalVisible(false)} - > - Cancel - - - - - - - - - - - Delete Committee - - - - - { - await handleDeleteCommittee() - setDeleteModalVisible(false) - navigation.goBack() - }} - > - Delete - - - setDeleteModalVisible(false)} - > - Cancel - - - - - - ) -} - -interface TeamMembersState { - leads: (PublicUserInfo | undefined)[]; - representatives: (PublicUserInfo | undefined)[]; - head: PublicUserInfo | null | undefined; -} - -type CommitteeEditorProps = { - route: RouteProp; - navigation: NativeStackNavigationProp; -}; - - -export default CommitteeEditor \ No newline at end of file diff --git a/src/screens/committees/CommitteeTeamCard.tsx b/src/screens/committees/CommitteeTeamCard.tsx deleted file mode 100644 index 5d570420..00000000 --- a/src/screens/committees/CommitteeTeamCard.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { Image, Text, TouchableOpacity, View } from 'react-native' -import React, { useEffect, useState } from 'react' -import { CommitteesStackParams } from '../../types/navigation' -import { getBadgeColor, isMemberVerified } from '../../helpers/membership' -import { Images } from '../../../assets' -import TwitterSvg from '../../components/TwitterSvg' -import { NativeStackNavigationProp } from '@react-navigation/native-stack' -import { PublicUserInfo } from '../../types/user' - -const CommitteeTeamCard: React.FC = ({ userData, navigation }) => { - if (!userData || Object.keys(userData).length === 0) { - return null; - } - - const { name, roles, uid, photoURL, chapterExpiration, nationalExpiration, email, isEmailPublic } = userData - const isOfficer = roles ? roles.officer : false; - const [isVerified, setIsVerified] = useState(false); - - let badgeColor = getBadgeColor(isOfficer!, isVerified); - - useEffect(() => { - if (nationalExpiration && chapterExpiration) { - setIsVerified(isMemberVerified(nationalExpiration, chapterExpiration)); - } - }, [nationalExpiration, chapterExpiration]) - - const handleCardPress = (uid: string): string | void => { - navigation!.navigate("PublicProfile", { uid }); - }; - - return ( - (navigation && handleCardPress(uid!))} - activeOpacity={!!handleCardPress && 1 || 0.6} - > - - - - - - {name} - {(isOfficer || isVerified) && } - - {(isEmailPublic && email && email.trim() !== "") && ( - {email} - )} - - - - - ) -} - -type CommitteeTeamCardProps = { - userData: PublicUserInfo; - navigation?: NativeStackNavigationProp -} - -export default CommitteeTeamCard \ No newline at end of file diff --git a/src/screens/committees/Committees.tsx b/src/screens/committees/Committees.tsx deleted file mode 100644 index 7f745cdc..00000000 --- a/src/screens/committees/Committees.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { View, ScrollView, Text, TouchableOpacity, ActivityIndicator, Image } from 'react-native' -import React, { useCallback, useContext, useEffect, useState } from 'react' -import { useFocusEffect } from '@react-navigation/core' -import { Octicons } from '@expo/vector-icons'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { UserContext } from '../../context/UserContext' -import { auth } from '../../config/firebaseConfig'; -import { getCommittees, getUser } from '../../api/firebaseUtils' -import { Committee } from "../../types/committees" -import CommitteeCard from './CommitteeCard' -import { SafeAreaView } from 'react-native-safe-area-context'; -import { Images } from '../../../assets'; -import { CommitteesStackParams } from '../../types/navigation'; -import { NativeStackScreenProps } from '@react-navigation/native-stack'; - - -const Committees = ({ navigation }: NativeStackScreenProps) => { - const [committees, setCommittees] = useState([]); - const [loading, setLoading] = useState(true); - const { userInfo, setUserInfo } = useContext(UserContext)!; - - const isSuperUser = userInfo?.publicInfo?.roles?.admin || userInfo?.publicInfo?.roles?.developer || userInfo?.publicInfo?.roles?.officer - - const fetchCommittees = async () => { - setLoading(true); - const response = await getCommittees(); - setCommittees(response); - setLoading(false); - } - - - const fetchUserData = async () => { - console.log("Fetching user data..."); - try { - const firebaseUser = await getUser(auth.currentUser?.uid!) - await AsyncStorage.setItem("@user", JSON.stringify(firebaseUser)); - setUserInfo(firebaseUser); - } catch (error) { - console.error("Error updating user:", error); - } - } - - useEffect(() => { - fetchCommittees(); - fetchUserData(); - }, []); - - // a refetch for officer for when they update committees - useFocusEffect( - useCallback(() => { - if (isSuperUser) { - fetchCommittees(); - } - return () => { }; - }, [isSuperUser]) - ); - - return ( - - - - {/* Header */} - - - - - - - - - {isSuperUser && ( - - navigation.navigate("CommitteeEditor", { committee: undefined })} - className='flex-row w-[90%] h-28 rounded-xl bg-[#D3D3D3]' - > - - - - - - - - - - Create a Committee - - - - - )} - - {loading && ( - - )} - - - - {!loading && committees.map((committee) => ( - - ))} - - - ) -} - -export default Committees; diff --git a/src/screens/Committees/Committee.tsx b/src/screens/committees_temp/Committee.tsx similarity index 100% rename from src/screens/Committees/Committee.tsx rename to src/screens/committees_temp/Committee.tsx diff --git a/src/screens/Committees/CommitteeCard.tsx b/src/screens/committees_temp/CommitteeCard.tsx similarity index 100% rename from src/screens/Committees/CommitteeCard.tsx rename to src/screens/committees_temp/CommitteeCard.tsx diff --git a/src/screens/Committees/CommitteeEditor.tsx b/src/screens/committees_temp/CommitteeEditor.tsx similarity index 100% rename from src/screens/Committees/CommitteeEditor.tsx rename to src/screens/committees_temp/CommitteeEditor.tsx diff --git a/src/screens/Committees/CommitteeTeamCard.tsx b/src/screens/committees_temp/CommitteeTeamCard.tsx similarity index 100% rename from src/screens/Committees/CommitteeTeamCard.tsx rename to src/screens/committees_temp/CommitteeTeamCard.tsx diff --git a/src/screens/Committees/Committees.tsx b/src/screens/committees_temp/Committees.tsx similarity index 100% rename from src/screens/Committees/Committees.tsx rename to src/screens/committees_temp/Committees.tsx From 73d0e59029d00542fad26cce4c4b6424b1ed7832 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 4 Jun 2024 01:12:19 -0500 Subject: [PATCH 017/198] fix cap naming folder --- src/screens/{committees_temp => committees}/Committee.tsx | 0 src/screens/{committees_temp => committees}/CommitteeCard.tsx | 0 src/screens/{committees_temp => committees}/CommitteeEditor.tsx | 0 src/screens/{committees_temp => committees}/CommitteeTeamCard.tsx | 0 src/screens/{committees_temp => committees}/Committees.tsx | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename src/screens/{committees_temp => committees}/Committee.tsx (100%) rename src/screens/{committees_temp => committees}/CommitteeCard.tsx (100%) rename src/screens/{committees_temp => committees}/CommitteeEditor.tsx (100%) rename src/screens/{committees_temp => committees}/CommitteeTeamCard.tsx (100%) rename src/screens/{committees_temp => committees}/Committees.tsx (100%) diff --git a/src/screens/committees_temp/Committee.tsx b/src/screens/committees/Committee.tsx similarity index 100% rename from src/screens/committees_temp/Committee.tsx rename to src/screens/committees/Committee.tsx diff --git a/src/screens/committees_temp/CommitteeCard.tsx b/src/screens/committees/CommitteeCard.tsx similarity index 100% rename from src/screens/committees_temp/CommitteeCard.tsx rename to src/screens/committees/CommitteeCard.tsx diff --git a/src/screens/committees_temp/CommitteeEditor.tsx b/src/screens/committees/CommitteeEditor.tsx similarity index 100% rename from src/screens/committees_temp/CommitteeEditor.tsx rename to src/screens/committees/CommitteeEditor.tsx diff --git a/src/screens/committees_temp/CommitteeTeamCard.tsx b/src/screens/committees/CommitteeTeamCard.tsx similarity index 100% rename from src/screens/committees_temp/CommitteeTeamCard.tsx rename to src/screens/committees/CommitteeTeamCard.tsx diff --git a/src/screens/committees_temp/Committees.tsx b/src/screens/committees/Committees.tsx similarity index 100% rename from src/screens/committees_temp/Committees.tsx rename to src/screens/committees/Committees.tsx From 25874a612909f4c8176b9c38b814c90d03771b92 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 4 Jun 2024 01:14:33 -0500 Subject: [PATCH 018/198] fix folder name --- src/screens/userProfile/PersonalEventLog.tsx | 82 ++ src/screens/userProfile/PublicProfile.tsx | 494 +++++++++ src/screens/userProfile/Settings.tsx | 993 +++++++++++++++++++ 3 files changed, 1569 insertions(+) create mode 100644 src/screens/userProfile/PersonalEventLog.tsx create mode 100644 src/screens/userProfile/PublicProfile.tsx create mode 100644 src/screens/userProfile/Settings.tsx diff --git a/src/screens/userProfile/PersonalEventLog.tsx b/src/screens/userProfile/PersonalEventLog.tsx new file mode 100644 index 00000000..fce9d17d --- /dev/null +++ b/src/screens/userProfile/PersonalEventLog.tsx @@ -0,0 +1,82 @@ +import { View, TouchableOpacity, ActivityIndicator, Text } from 'react-native' +import React, { useContext, useEffect, useState } from 'react' +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Octicons } from '@expo/vector-icons'; +import { Timestamp } from 'firebase/firestore'; +import { UserContext } from '../../context/UserContext'; +import { auth } from '../../config/firebaseConfig'; +import { queryUserEventLogs } from '../../api/firebaseUtils'; +import { UserEventData } from '../../types/events'; +import { UserProfileStackParams } from '../../types/navigation'; + +const PersonalEventLog = ({ navigation }: NativeStackScreenProps) => { + const userContext = useContext(UserContext); + const { userInfo } = userContext!; + + const [events, setEvents] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchUserEventLogs = async () => { + if (auth.currentUser?.uid) { + try { + const data = await queryUserEventLogs(auth.currentUser?.uid); + setEvents(data); + } catch (error) { + console.error('Error fetching user event logs:', error); + } finally { + setIsLoading(false); + } + } + }; + + fetchUserEventLogs(); + }, []); + + if (isLoading) { + return ; + } + + + return ( + + + + navigation.goBack()} + className="rounded-full w-10 h-10 justify-center items-center" + style={{ backgroundColor: 'rgba(0,0,0,0.3)' }} + > + + + + + + + {events.map(({ eventData, eventLog }, index) => ( + + {eventData?.name} + Start Time: {formatTimestamp(eventData?.startTime)} + End Time: {formatTimestamp(eventData?.endTime)} + Total Points Earned: {eventLog?.points} + Sign-In Time: {formatTimestamp(eventLog?.signInTime)} + + {eventLog?.signOutTime && ( + Sign-Out Time: {formatTimestamp(eventLog?.signOutTime)} + )} + + ))} + + + + + + ) +} + +const formatTimestamp = (timestamp: Timestamp | null | undefined) => { + return timestamp ? new Date(timestamp.toDate()).toLocaleString() : 'N/A'; +}; + +export default PersonalEventLog \ No newline at end of file diff --git a/src/screens/userProfile/PublicProfile.tsx b/src/screens/userProfile/PublicProfile.tsx new file mode 100644 index 00000000..4046a153 --- /dev/null +++ b/src/screens/userProfile/PublicProfile.tsx @@ -0,0 +1,494 @@ +import { View, Text, ActivityIndicator, Image, Alert, TouchableOpacity, Pressable, TextInput, ScrollView, RefreshControl } from 'react-native'; +import React, { useState, useEffect, useContext, useCallback } from 'react'; +import { NativeStackNavigationProp, NativeStackScreenProps } from '@react-navigation/native-stack'; +import { RouteProp, useFocusEffect } from '@react-navigation/core'; +import { useRoute } from '@react-navigation/native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Octicons, FontAwesome } from '@expo/vector-icons'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { StatusBar } from 'expo-status-bar'; +import { UserContext } from '../../context/UserContext'; +import { auth } from '../../config/firebaseConfig'; +import { getCommittees, getPublicUserData, getUser, queryUserEventLogs, setUserRoles } from '../../api/firebaseUtils'; +import { getBadgeColor, isMemberVerified } from '../../helpers/membership'; +import { handleLinkPress } from '../../helpers/links'; +import { UserProfileStackParams } from '../../types/navigation'; +import { PublicUserInfo, Roles } from '../../types/user'; +import { Committee } from '../../types/committees'; +import { Images } from '../../../assets'; +import TwitterSvg from '../../components/TwitterSvg'; +import ProfileBadge from '../../components/ProfileBadge'; +import DismissibleModal from '../../components/DismissibleModal'; +import { UserEventData } from '../../types/events'; +import { Timestamp } from 'firebase/firestore'; + +export type PublicProfileScreenProps = { + route: RouteProp; + navigation: NativeStackNavigationProp; +}; + +const PublicProfileScreen: React.FC = ({ route, navigation }) => { + // Data related to public profile user + const { uid } = route.params; + const [publicUserData, setPublicUserData] = useState(); + const { nationalExpiration, chapterExpiration, roles, photoURL, name, major, classYear, bio, points, resumeVerified, resumePublicURL, email, isStudent, committees, pointsRank, isEmailPublic } = publicUserData || {}; + const [committeesData, setCommitteesData] = useState([]); + const [events, setEvents] = useState([]); + const [modifiedRoles, setModifiedRoles] = useState(undefined); + const [isVerified, setIsVerified] = useState(false); + const isOfficer = roles ? roles.officer : false; + const badgeColor = getBadgeColor(isOfficer!, isVerified); + const isCurrentUser = uid === auth.currentUser?.uid; + + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [updatingRoles, setUpdatingRoles] = useState(false); + const [showRoleModal, setShowRoleModal] = useState(false); + const [showEventsLogModal, setEventsLogModal] = useState(false); + + // Data related to currently authenticated user + const { userInfo, setUserInfo } = useContext(UserContext)!; + const isSuperUser = userInfo?.publicInfo?.roles?.admin || userInfo?.publicInfo?.roles?.developer || userInfo?.publicInfo?.roles?.officer + + const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + + + const fetchUserData = async () => { + try { + const firebaseUser = await getUser(auth.currentUser?.uid!) + await AsyncStorage.setItem("@user", JSON.stringify(firebaseUser)); + setUserInfo(firebaseUser); + } catch (error) { + console.error("Error updating user:", error); + } finally { + setRefreshing(false); + } + } + + const onRefresh = useCallback(async () => { + if (isCurrentUser) { + setRefreshing(true); + fetchUserData(); + } + }, [uid]); + + useFocusEffect( + useCallback(() => { + const fetchPublicUserData = async () => { + await getPublicUserData(uid) + .then((res) => { + setPublicUserData(res); + setModifiedRoles(res?.roles); + }) + .catch((error) => console.error("Failed to fetch public user data:", error)) + .finally(() => { + setLoading(false); + }); + }; + + + if (isCurrentUser) { + fetchUserData(); + } + fetchPublicUserData(); + + return () => { }; + }, [auth]) + ); + + // used to get committee color for badges + useEffect(() => { + const fetchCommitteeData = async () => { + const response = await getCommittees(); + setCommitteesData(response); + } + + const fetchUserEventLogs = async () => { + if (auth.currentUser?.uid) { + try { + const data = await queryUserEventLogs(auth.currentUser?.uid); + setEvents(data); + } catch (error) { + console.error('Error fetching user event logs:', error); + } + } + }; + + fetchCommitteeData(); + fetchUserEventLogs(); + }, []) + + useEffect(() => { + if (nationalExpiration && chapterExpiration) { + setIsVerified(isMemberVerified(nationalExpiration, chapterExpiration)); + } + }, [nationalExpiration, chapterExpiration]) + + const RoleItem = ({ roleName, isActive, onToggle, darkMode }: { + roleName: string, + isActive: boolean, + onToggle: () => void, + darkMode: boolean + }) => { + return ( + + + {roleName} + + ); + }; + + if (loading) { + return ( + + + + ); + } + + if (!uid || uid === "") { + return ( + + + + + + + + + + navigation.goBack()} + className="rounded-full w-10 h-10 justify-center items-center" + style={{ backgroundColor: 'rgba(0,0,0,0.3)' }} + > + + + + + + + + + + No User Found + + + + + + + ) + } + + return ( + + } + bounces={isCurrentUser ? true : false} + > + + {/* Profile Header */} + + + + + + + {!isCurrentUser ? + navigation.goBack()} + className="rounded-full w-10 h-10 justify-center items-center" + style={{ backgroundColor: 'rgba(0,0,0,0.3)' }} + > + + + : + + } + + + {isCurrentUser && + navigation.navigate("SettingsScreen")} + className="rounded-md px-3 py-2" + style={{ backgroundColor: 'rgba(0,0,0,0.3)' }} + > + Edit + + } + + + + + + + + + {name ?? "Name"} + {(isOfficer || isVerified) && } + + + + + + {`${major} ${"'" + classYear?.substring(2)}`} + {points !== undefined && ` • ${points.toFixed(2)} pts`} + {points !== undefined && pointsRank && ` • rank ${pointsRank}`} + + + {isSuperUser && + + + setShowRoleModal(true)} + className="rounded-md px-3 py-2" + style={{ backgroundColor: 'rgba(0,0,0,0.3)', marginRight: 10 }} + > + Edit Role + + + + } + + + + + {/* Profile Body */} + + + + {roles?.customTitle ? roles.customTitle : + (isVerified ? "Member" : + (isStudent ? "Student" : "Guest")) + } + + + {bio} + + {(isEmailPublic && email && email.trim() !== "") && ( + (handleLinkPress('mailto:' + email))} + > + + Email + + )} + + {resumeVerified && + handleLinkPress(resumePublicURL!)} + > + + Resume + + } + + + {committees && committees.length > 0 && ( + + Committees + + {committees?.map((committeeName, index) => { + const committeeData = committeesData.find(c => c.firebaseDocName === committeeName); + + return ( + + ); + })} + + + )} + + {isCurrentUser && ( + + navigation.navigate("PersonalEventLogScreen")} + className="rounded-md mt-8" + > + Personal Event Logs + + + + {events.map(({ eventData, eventLog }, index) => ( + + {eventData?.name} + Start Time: {formatTimestamp(eventData?.startTime)} + End Time: {formatTimestamp(eventData?.endTime)} + Total Points Earned: {eventLog?.points} + + + ))} + + + )} + + + + + {/* Role Modal */} + + + {/* Title */} + + + User Permissions + + + {/* Position Custom Title */} + + Enter a custom title + This is only used on profile screen + { + setModifiedRoles({ + ...modifiedRoles, + customTitle: text || "" + }) + }} + placeholder='Enter title' + value={modifiedRoles?.customTitle} + /> + + Select user role + + + {/* Position Selection */} + + setModifiedRoles({ ...modifiedRoles, admin: !modifiedRoles?.admin })} + darkMode={darkMode || false} + /> + setModifiedRoles({ ...modifiedRoles, developer: !modifiedRoles?.developer })} + darkMode={darkMode || false} + + /> + setModifiedRoles({ ...modifiedRoles, officer: !modifiedRoles?.officer })} + darkMode={darkMode || false} + /> + setModifiedRoles({ ...modifiedRoles, secretary: !modifiedRoles?.secretary })} + darkMode={darkMode || false} + /> + setModifiedRoles({ ...modifiedRoles, representative: !modifiedRoles?.representative })} + darkMode={darkMode || false} + /> + setModifiedRoles({ ...modifiedRoles, lead: !modifiedRoles?.lead })} + darkMode={darkMode || false} + /> + + + {/* Action Buttons */} + + { + + // checks if has role but no custom title + if ((modifiedRoles?.admin || modifiedRoles?.developer || modifiedRoles?.officer || modifiedRoles?.secretary || modifiedRoles?.representative || modifiedRoles?.lead) && !modifiedRoles?.customTitle && !modifiedRoles?.customTitle?.length) { + Alert.alert("Missing Title", "You must enter a title "); + return; + } + + + // Checks if has custom title but no role + if (!modifiedRoles?.admin && !modifiedRoles?.developer && !modifiedRoles?.officer && !modifiedRoles?.secretary && !modifiedRoles?.representative && !modifiedRoles?.lead && modifiedRoles?.customTitle) { + Alert.alert("Missing Role", "If a custom title is entered, you must select a role."); + return; + } + + setUpdatingRoles(true); + if (modifiedRoles) + await setUserRoles(uid, modifiedRoles) + .then(() => { + Alert.alert("Permissions Updated", "This user's roles have been updated successfully!") + }) + .catch((err) => { + console.error(err); + Alert.alert("An Issue Occured", "A server issue has occured. Please try again. If this keeps occurring, please contact a developer"); + }); + + setUpdatingRoles(false); + setShowRoleModal(false); + }} + className="bg-pale-blue rounded-lg justify-center items-center px-4 py-1" + > + Done + + + + { + setModifiedRoles(roles) + setShowRoleModal(false) + }} > + Cancel + + + {updatingRoles && } + + + + ) +} + +const formatTimestamp = (timestamp: Timestamp | null | undefined) => { + return timestamp ? new Date(timestamp.toDate()).toLocaleString() : 'N/A'; +}; + +export default PublicProfileScreen; \ No newline at end of file diff --git a/src/screens/userProfile/Settings.tsx b/src/screens/userProfile/Settings.tsx new file mode 100644 index 00000000..f3ef2ffc --- /dev/null +++ b/src/screens/userProfile/Settings.tsx @@ -0,0 +1,993 @@ +import { View, Text, Image, ScrollView, TextInput, TouchableHighlight, TouchableOpacity, ActivityIndicator, KeyboardAvoidingView, Platform, Alert, Pressable, Animated } from 'react-native'; +import React, { useContext, useEffect, useRef, useState } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { MaterialCommunityIcons } from '@expo/vector-icons' +import { StatusBar } from 'expo-status-bar'; +import * as ImagePicker from "expo-image-picker"; +import { Octicons, FontAwesome } from '@expo/vector-icons'; +import { UserContext } from '../../context/UserContext'; +import { auth } from '../../config/firebaseConfig'; +import { sendPasswordResetEmail, updateProfile } from 'firebase/auth'; +import { setPublicUserData, setPrivateUserData, getUser, getCommittees, submitFeedback, isUsernameUnique, deleteAccount } from '../../api/firebaseUtils'; +import { getBlobFromURI, selectFile, selectImage, uploadFile } from '../../api/fileSelection'; +import { CommonMimeTypes, validateDisplayName, validateFileBlob, validateName, validateTamuEmail } from '../../helpers/validation'; +import { handleLinkPress } from '../../helpers/links'; +import { getBadgeColor, isMemberVerified } from '../../helpers/membership'; +import { UserProfileStackParams } from '../../types/navigation'; +import { Committee } from '../../types/committees'; +import { MAJORS, classYears } from '../../types/user'; +import { Images } from '../../../assets'; +import DownloadIcon from '../../../assets/arrow-down-solid.svg'; +import UploadFileIcon from '../../../assets/file-arrow-up-solid-black.svg'; +import { SettingsSectionTitle, SettingsButton, SettingsToggleButton, SettingsListItem, SettingsSaveButton, SettingsModal } from "../../components/SettingsComponents" +import CustomDropDown from '../../components/CustomDropDown'; +import TwitterSvg from '../../components/TwitterSvg'; +import { Circle, Svg } from 'react-native-svg'; +import DismissibleModal from '../../components/DismissibleModal'; +import * as Clipboard from 'expo-clipboard'; + +/** + * Settings entrance screen which has a search function and paths to every other settings screen + */ +const SettingsScreen = ({ navigation }: NativeStackScreenProps) => { + const { userInfo, signOutUser } = useContext(UserContext)!; + + const { name, roles, photoURL, chapterExpiration, nationalExpiration } = userInfo?.publicInfo ?? {}; + const isOfficer = roles ? roles.officer : false; + + const [isVerified, setIsVerified] = useState(false); + let badgeColor = getBadgeColor(isOfficer!, isVerified); + + useEffect(() => { + if (nationalExpiration && chapterExpiration) { + setIsVerified(isMemberVerified(nationalExpiration, chapterExpiration)); + } + }, [nationalExpiration, chapterExpiration]) + + const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + + return ( + + + + + navigation.navigate("ProfileSettingsScreen")} + > + + + + + + {name} + {(isOfficer || isVerified) && } + + + Edit Profile + + + + + + + navigation.navigate("DisplaySettingsScreen")} + /> + navigation.navigate("AccountSettingsScreen")} + /> + navigation.navigate("FeedbackSettingsScreen")} + /> + navigation.navigate("FAQSettingsScreen")} + /> + navigation.navigate("AboutSettingsScreen")} + /> + + signOutUser(true)} + /> + + ) +} + + +/** + * Screen where a user can edit a majority of their public info. This includes thing like their profile picture, name, display name, committees, etc... + * These changes are synced in firebase. + */ +const ProfileSettingsScreen = ({ navigation }: NativeStackScreenProps) => { + const { userInfo, setUserInfo } = useContext(UserContext)!; + const [loading, setLoading] = useState(false); + const [image, setImage] = useState(null); + const [showSaveButton, setShowSaveButton] = useState(false); + + const defaultVals = { + photoURL: "", + displayName: "DISPLAY NAME", + name: "NAME", + bio: "Write a short bio...", + major: "MAJOR", + classYear: "CLASS YEAR", + committees: [], + } + + //Hooks used to save state of modified fields before user hits "save" + const [photoURL, setPhotoURL] = useState(userInfo?.publicInfo?.photoURL); + const [resumeURL, setResumeURL] = useState(userInfo?.private?.privateInfo?.resumeURL); + const [displayName, setDisplayName] = useState(userInfo?.publicInfo?.displayName); + const [name, setName] = useState(userInfo?.publicInfo?.name); + const [bio, setBio] = useState(userInfo?.publicInfo?.bio); + const [major, setMajor] = useState(userInfo?.publicInfo?.major); + const [classYear, setClassYear] = useState(userInfo?.publicInfo?.classYear); + const [openDropdown, setOpenDropdown] = useState(null); + const [committeesData, setCommitteesData] = useState([]); + const [committees, setCommittees] = useState(userInfo?.publicInfo?.committees || []); + const [prevCommittees, setPrevCommittees] = useState(userInfo?.publicInfo?.committees || []); + + + // Modal options + const [showNamesModal, setShowNamesModal] = useState(false); + const [showBioModal, setShowBioModal] = useState(false); + const [showAcademicInfoModal, setShowAcademicInfoModal] = useState(false); + const [showResumeModal, setShowResumeModal] = useState(false); + + const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + + useEffect(() => { + const fetchCommitteeData = async () => { + const response = await getCommittees(); + setCommitteesData(response); + } + fetchCommitteeData(); + }, []) + + /** + * Checks for any pending changes in user data. + * If any deviate from userInfo, display a "save" button which will save the changes to firebase. + */ + useEffect(() => { + if ( + photoURL != userInfo?.publicInfo?.photoURL + ) { + setShowSaveButton(true); + } + else { + setShowSaveButton(false); + } + }, [photoURL]); + + const selectProfilePicture = async () => { + await selectImage({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: true, + aspect: [1, 1], + quality: 1, + }).then(async (result) => { + if (result) { + const imageBlob = await getBlobFromURI(result.assets![0].uri); + if (imageBlob && validateFileBlob(imageBlob, CommonMimeTypes.IMAGE_FILES, true)) { + setPhotoURL(result.assets![0].uri); + setImage(imageBlob); + } + } + }).catch((err) => { + // TypeError means user did not select an image + if (err.name != "TypeError") { + console.error(err); + } + }); + } + + const selectResume = async () => { + const result = await selectFile(); + if (result) { + const resumeBlob = await getBlobFromURI(result.assets![0].uri); + return resumeBlob; + } + return null + } + + const onProfilePictureUploadSuccess = async (URL: string) => { + console.log("File available at", URL); + if (auth.currentUser) { + setPhotoURL(URL); + await updateProfile(auth.currentUser, { + photoURL: URL + }); + await setPublicUserData({ + photoURL: URL + }); + } + } + + // toggle dropdown for class year and major + const toggleDropdown = (dropdownKey: string) => { + if (openDropdown === dropdownKey) { + setOpenDropdown(null); + } else { + setOpenDropdown(dropdownKey); + } + }; + + const onResumeUploadSuccess = async (URL: string) => { + console.log("File available at", URL); + if (auth.currentUser) { + setResumeURL(URL); + await setPrivateUserData({ + resumeURL: URL + }); + } + + } + + const saveChanges = async () => { + setLoading(true) + // upload profile picture + if (image) { + await uploadFile( + image, + CommonMimeTypes.IMAGE_FILES, + `user-docs/${auth.currentUser?.uid}/user-profile-picture`, + onProfilePictureUploadSuccess + ); + } + + /** + * This is some very weird syntax and very javascript specific, so here's an explanation for what's going on: + * + * setPublicUserData() updates the fields that are in the object passed into it. + * The spread operator (...) adds each key in an object to the parent object. + * By adding a conditional and the && operator next to the child object, this essentially creates a "Conditional Key Addition". + * This makes it so the information will not be overridden in Firebase if the value of a key is empty/undefined. + */ + setPublicUserData({ + ...(photoURL !== undefined) && { photoURL: photoURL }, + ...(displayName !== undefined) && { displayName: displayName }, + ...(name !== undefined) && { name: name }, + ...(bio !== undefined) && { bio: bio }, + ...(major !== undefined) && { major: major }, + ...(classYear !== undefined) && { classYear: classYear }, + ...(committees !== undefined) && { committees: committees }, + }) + .then(async () => { + if (auth.currentUser) + await updateProfile(auth.currentUser, { + displayName: displayName, + photoURL: photoURL, + }) + + if (auth.currentUser?.uid) { + const firebaseUser = await getUser(auth.currentUser.uid); + if (firebaseUser) { + setUserInfo(firebaseUser); + await AsyncStorage.setItem("@user", JSON.stringify(firebaseUser)); + } + else { + console.warn("firebaseUser returned as undefined when attempting to sync. Sync will be skipped."); + } + } + }) + .catch(err => console.error("Error attempting to save changes: ", err)) + .finally(() => { + setLoading(false); + setShowSaveButton(false); + }); + + setPrivateUserData({ + ...(resumeURL !== undefined) && { resumeURL: resumeURL }, + }) + } + + + const CommitteeListItemComponent = ({ committeeData, onPress, darkMode, isChecked, committeeIndex }: any) => { + return ( + onPress()} + underlayColor={darkMode ? "#7a7a7a" : "#DDD"} + > + + + + {committeeData.name} + + {isChecked && {committeeIndex + 1}} + + + ); + }; + + const handleCommitteeToggle = (name: string) => { + setCommittees(prevCommittees => { + const isCommitteeSelected = prevCommittees.includes(name); + if (isCommitteeSelected) { + return prevCommittees.filter(committee => committee !== name); + } else { + return [...prevCommittees, name]; + } + }); + }; + + const findMajorByIso = (iso: string) => { + const majorObj = MAJORS.find(major => major.iso === iso); + return majorObj ? majorObj.major : null; + }; + + const progress = useRef(new Animated.Value(0)).current; + const setProgress = (newProgress: number) => { + if (newProgress <= 0) { + progress.setValue(0); + } else if (newProgress >= 100) { + progress.setValue(100); + } else { + Animated.timing(progress, { + toValue: newProgress, + duration: 500, + useNativeDriver: true, + }).start(); + } + }; + + const AnimatedCircle = Animated.createAnimatedComponent(Circle); + const circumference = 2 * Math.PI * 45; // 45 is the radius of the circle + const strokeDashoffset = progress.interpolate({ + inputRange: [0, 100], + outputRange: [circumference, 0] + }); + + + return ( + + + {/* Names Modal */} + { + setDisplayName(userInfo?.publicInfo?.displayName); + setName(userInfo?.publicInfo?.name); + setShowNamesModal(false); + }} + onDone={async () => { + if (validateDisplayName(displayName, true) && validateName(name, true)) { + const isUnique = await isUsernameUnique(displayName!); + if (isUnique) { + saveChanges(); + setShowNamesModal(false); + } else { + setDisplayName(userInfo?.publicInfo?.displayName); + alert("Display name is already taken. Please choose another one."); + } + } + + }} + content={( + + + Display Name + setDisplayName(text)} + value={displayName} + autoCorrect={false} + multiline + inputMode='text' + maxLength={80} + placeholder='Display Name...' + /> + + + Name + setName(text)} + value={name} + autoCorrect={false} + multiline + inputMode='text' + maxLength={80} + placeholder='Full Name..' + /> + + + )} + /> + {/* Bio Modal */} + { + setBio(userInfo?.publicInfo?.bio ?? defaultVals.bio); + setShowBioModal(false); + }} + onDone={() => { + saveChanges(); + setShowBioModal(false); + }} + content={( + + Bio + { + if (text.length <= 250) + setBio(text) + }} + value={bio} + multiline + numberOfLines={8} + placeholder='Write a short bio...' + placeholderTextColor={darkMode ? "#ddd" : "#000"} + /> + + )} + /> + {/* Academic Info Modal */} + { + setMajor(userInfo?.publicInfo?.major ?? defaultVals.major); + setClassYear(userInfo?.publicInfo?.classYear ?? defaultVals.classYear); + setShowAcademicInfoModal(false); + }} + onDone={() => { + saveChanges(); + setShowAcademicInfoModal(false) + }} + content={ + ( + + + setMajor(item.iso)} + searchKey="major" + label="Select major" + isOpen={openDropdown === "major"} + onToggle={() => toggleDropdown("major")} + title={"Major"} + selectedItemProp={{ iso: major, value: findMajorByIso(major!)! }} + dropDownClassName={Platform.OS == "ios" ? "top-20" : undefined} + darkMode={darkMode} + /> + + + setClassYear(item.iso)} + searchKey="year" + label="Select class year" + isOpen={openDropdown === 'year'} + onToggle={() => toggleDropdown('year')} + title={"Class Year"} + selectedItemProp={{ iso: classYear }} + displayType='iso' + dropDownClassName={Platform.OS == "ios" ? "top-20" : undefined} + disableSearch + darkMode={darkMode} + /> + + + + ) + } + /> + + {/* Resume Modal */} + setShowResumeModal(false)} + onDone={() => setShowResumeModal(false)} + darkMode={darkMode} + content={( + + + { + const selectedResume = await selectResume(); + if (selectedResume) { + uploadFile( + selectedResume, + CommonMimeTypes.RESUME_FILES, + `user-docs/${auth.currentUser?.uid}/user-resume`, + onResumeUploadSuccess, + setProgress + ); + } + }}> + + + + + + + + + + {resumeURL && ( + { handleLinkPress(resumeURL!) }}> + + View Resume + + + + + + )} + + + )} + /> + + + await selectProfilePicture()}> + + + + + + + setShowNamesModal(true)} + /> + setShowNamesModal(true)} + /> + setShowBioModal(true)} + /> + + setShowAcademicInfoModal(true)} + /> + setShowAcademicInfoModal(true)} + /> + + setShowResumeModal(true)} + /> + + {loading && } + + {showSaveButton && + saveChanges()} + /> + } + + ); +}; + +/** + * Screen where user can modify how to the app looks. + * These changes are synced in firebase. + */ +const DisplaySettingsScreen = ({ navigation }: NativeStackScreenProps) => { + const { userInfo, setUserInfo } = useContext(UserContext)!; + const [loading, setLoading] = useState(false); + const [darkModeToggled, setDarkModeToggled] = useState(userInfo?.private?.privateInfo?.settings?.darkMode ?? false); + + const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode + + return ( + + + { + setDarkModeToggled(!darkModeToggled); + setLoading(true); + await setPrivateUserData({ + settings: { + darkMode: !darkMode + } + }) + .then(async () => { + if (auth.currentUser?.uid) { + await getUser(auth.currentUser?.uid) + .then(async (firebaseUser) => { + if (firebaseUser) { + setUserInfo(firebaseUser); + await AsyncStorage.setItem("@user", JSON.stringify(firebaseUser)); + } + else { + console.warn("firebaseUser returned as undefined when attempting to sync. Sync will be skipped."); + } + }) + .catch(err => console.error(err)); + } + }) + .catch((err) => console.error(err)) + .finally(() => { + setLoading(false); + }); + }} + /> + {loading && } + + ); +}; + +/** + * Screen where user can both view information about their account and request a change of their email and/or password. + * These changes will go through firebase where an email will be sent to the user. + */ +const AccountSettingsScreen = ({ navigation }: NativeStackScreenProps) => { + const { userInfo, setUserInfo, signOutUser } = useContext(UserContext)!; + const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const deleteConfirmationText = "DELETECONFIRM"; + + const [deleteText, setDeleteText] = useState(''); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + return ( + + + + + { + const updatedPublicData = { + ...userInfo?.publicInfo, + isEmailPublic: !userInfo?.publicInfo?.isEmailPublic, + email: !userInfo?.publicInfo?.isEmailPublic ? auth.currentUser?.email || "" : "", + }; + + await setPublicUserData(updatedPublicData); + + const updatedUserInfo = { + ...userInfo, + publicInfo: updatedPublicData, + }; + + await AsyncStorage.setItem("@user", JSON.stringify(updatedUserInfo)); + setUserInfo(updatedUserInfo); + Alert.alert("Email visibility updated successfully"); + } + } + darkMode={darkMode} + /> + + { + Clipboard.setStringAsync(auth.currentUser?.uid ?? "UID") + .then(() => Alert.alert("Copied", "UID Copied to Clipboard")); + }} + /> + + + {!validateTamuEmail(auth.currentUser?.email ?? "") && + { + Alert.alert("Password Reset", "An email reset link will be sent to your email.") + sendPasswordResetEmail(auth, auth.currentUser?.email!) + }} + darkMode={darkMode} + /> + } + + + { + setDeleteText(""); + setShowDeleteModal(true); + }} + darkMode={darkMode} + /> + + + + + {/* Title */} + + + Account Deletion + + + YOU WILL LOSE ALL YOUR POINTS IF YOU DELETE YOUR ACCOUNT + Please type "{deleteConfirmationText}" to confirm. + + + + + + + { + Alert.alert("Account Deleted", "Your account has been successfully deleted."); + setShowDeleteModal(false); + await deleteAccount(auth.currentUser?.uid!); + await AsyncStorage.removeItem('@user'); + setUserInfo(undefined); + }} + disabled={deleteText !== deleteConfirmationText} + className={`${deleteText !== deleteConfirmationText ? "bg-neutral-400" : "bg-red-700"} rounded-lg justify-center items-center px-4 py-1`} + > + DELETE + + + + { + setShowDeleteModal(false) + }} > + Cancel + + + + + + ); +}; + +const FeedBackSettingsScreen = ({ navigation }: NativeStackScreenProps) => { + const [feedback, setFeedback] = useState(''); + const { userInfo } = useContext(UserContext)!; + + const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + + const handleFeedbackSubmit = async () => { + const response = await submitFeedback(feedback, userInfo!); + if (response.success) { + setFeedback(''); + alert('Feedback submitted successfully'); + } else { + alert('Failed to submit feedback'); + } + }; + + return ( + + Tell us what can be improved + + + + + Submit FeedBack + + + ); +}; + +const FAQSettingsScreen = ({ navigation }: NativeStackScreenProps) => { + const [activeQuestion, setActiveQuestion] = useState(null); + const { userInfo } = useContext(UserContext)!; + + const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + + + const toggleQuestion = (questionNumber: number) => { + if (activeQuestion === questionNumber) { + setActiveQuestion(null); + } else { + setActiveQuestion(questionNumber); + } + }; + + const faqData: { question: string, answer: string }[] = [ + { + question: "What resources does SHPE provide?", + answer: "SHPE offers networking opportunities, professional development workshops, mentorship programs, scholarship opportunities, and community outreach initiatives." + }, + { + question: "How do I become an official SHPE member?", + answer: "To become an official member, register on the SHPE national website, pay the annual membership fee, and join your local chapter activities." + }, + { + question: "What is the Technical Affairs Committee?", + answer: "The Technical Affairs Committee organizes technical events and workshops, promotes STEM education, and provides members with opportunities to develop technical skills." + }, + { + question: "What is the MentorSHPE Committee?", + answer: "The MentorSHPE Committee facilitates mentoring relationships between professional members and students, offering guidance, career advice, and academic support." + }, + { + question: "What is the Scholastic Committee?", + answer: "The Scholastic Committee focuses on academic excellence by providing study sessions, educational resources, and academic advising to members." + }, + { + question: "What is the Secretary Committee?", + answer: "The Secretary Committee is responsible for maintaining organization records, documenting meetings and events, and ensuring effective communication within the chapter." + }, + { + question: "What is the SHPEtinas Committee?", + answer: "The SHPEtinas Committee empowers and supports female members of SHPE through networking events, workshops, and mentorship programs." + }, + { + question: "What do the points I acquire allow me to do?", + answer: "Points earned through participation in events and activities can be used for priority access to certain events, eligibility for exclusive opportunities, and recognition within the organization." + } + ]; + + return ( + + {faqData.map((faq, index) => ( + toggleQuestion(index)} + > + + {faq.question} + + + + + {activeQuestion === index && ( + + {faq.answer} + + )} + + ))} + + + ); +}; +/** + * This screen contains information about the app and info that may be useful to developers. + */ +const AboutSettingsScreen = ({ navigation }: NativeStackScreenProps) => { + const pkg: any = require("../../../package.json"); + const { userInfo } = useContext(UserContext)!; + const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + + return ( + + + + + handleLinkPress("https://jasonisazn.github.io/")} + /> + + ); +}; + + + +export { SettingsScreen, ProfileSettingsScreen, DisplaySettingsScreen, AccountSettingsScreen, FeedBackSettingsScreen, FAQSettingsScreen, AboutSettingsScreen }; From a977841a9b97d499bbceaffcb4beb194f5612025 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 4 Jun 2024 01:29:30 -0500 Subject: [PATCH 019/198] update yarn lock --- yarn.lock | 5 ----- 1 file changed, 5 deletions(-) diff --git a/yarn.lock b/yarn.lock index 24ddf8a5..d3d4f8ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9634,11 +9634,6 @@ readline@^1.3.0: resolved "https://registry.npmjs.org/readline/-/readline-1.3.0.tgz" integrity sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg== -reanimated-color-picker@^2.4.1: - version "2.4.1" - resolved "https://registry.npmjs.org/reanimated-color-picker/-/reanimated-color-picker-2.4.1.tgz" - integrity sha512-BM5uV8QHRMc4e6YWyCOtMNcGN0QMdnpjfV3Cv7CikHksIflJoqnRtT2hGcKp8MNmpc8nKGQhvCeHj5iSYsiC7A== - recast@^0.21.0: version "0.21.5" resolved "https://registry.npmjs.org/recast/-/recast-0.21.5.tgz" From d29a773998a438d08095b0d0df3bbcab5620939c Mon Sep 17 00:00:00 2001 From: LucientZ Date: Mon, 10 Jun 2024 14:54:07 -0500 Subject: [PATCH 020/198] Added d.ts to jest file extensions --- jest.config.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/jest.config.ts b/jest.config.ts index 8d86c841..cebe46f9 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -6,10 +6,11 @@ const config: Config = { "/node_modules/(?!((jest-)?react-native|@firebase/.*|firebase/.*|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|@react-native-async-storage/async-storage|whatwg-fetch)", ], setupFiles: ["./src/__mocks__/index.ts"], - testEnvironment: 'node', + testEnvironment: "node", coveragePathIgnorePatterns: [ "/src/config/" - ] + ], + moduleFileExtensions: ["js", "mjs", "cjs", "jsx", "ts", "tsx", "json", "node", "d.ts"], }; export default config; From f7d3c469be25333e4d5106684c9a22e8d0e1da6e Mon Sep 17 00:00:00 2001 From: LucientZ Date: Mon, 10 Jun 2024 14:54:28 -0500 Subject: [PATCH 021/198] Mocked firebase/auth for unit tests --- src/__mocks__/@firebase/auth.ts | 198 ++++++++++++++++++++++++++++++++ src/__mocks__/index.ts | 1 + 2 files changed, 199 insertions(+) create mode 100644 src/__mocks__/@firebase/auth.ts diff --git a/src/__mocks__/@firebase/auth.ts b/src/__mocks__/@firebase/auth.ts new file mode 100644 index 00000000..60d7bda1 --- /dev/null +++ b/src/__mocks__/@firebase/auth.ts @@ -0,0 +1,198 @@ +import { FirebaseApp, initializeApp } from "firebase/app"; +import { Auth, AuthProvider, AuthSettings, CompleteFn, Config, EmulatorConfig, ErrorFn, IdTokenResult, NextOrObserver, Persistence, PopupRedirectResolver, Unsubscribe, User, UserCredential, UserInfo, UserMetadata } from "firebase/auth"; + +/** + * Barebones implementation of Auth to work with unit tests. + */ +class MockAuth implements Auth { + app: FirebaseApp; + name: string; + config: Config; + settings: AuthSettings; + languageCode: string | null; + tenantId: string | null; + currentUser: User | null; + emulatorConfig: EmulatorConfig | null; + + async setPersistence(persistence: Persistence): Promise { } + + onAuthStateChanged(nextOrObserver: NextOrObserver, error?: ErrorFn | undefined, completed?: CompleteFn | undefined): Unsubscribe { + return () => { }; + } + + beforeAuthStateChanged(callback: (user: User | null) => void | Promise, onAbort?: (() => void) | undefined): Unsubscribe { + return () => { }; + } + + onIdTokenChanged(nextOrObserver: NextOrObserver, error?: ErrorFn | undefined, completed?: CompleteFn | undefined): Unsubscribe { + return () => { }; + } + + async authStateReady(): Promise { } + + updateCurrentUser(user: User | null): Promise { + return new Promise((resolve) => { + this.currentUser = user; + resolve() + }); + } + + useDeviceLanguage(): void { + this.languageCode = "en"; + } + + signOut(): Promise { + return new Promise((resolve) => { + this.currentUser = null; + resolve() + }); + } + + constructor() { + this.name = "Test User"; + this.languageCode = null; + this.tenantId = null; + this.currentUser = null; + this.emulatorConfig = null; + + this.config = { + apiKey: process.env.GOOGLE_API_KEY ?? "", + apiHost: process.env.FIREBASE_EMULATOR_ADDRESS ?? "", + apiScheme: "", + tokenApiHost: process.env.FIREBASE_EMULATOR_ADDRESS ?? "", + sdkClientVersion: "" + }; + + this.settings = { + appVerificationDisabledForTesting: true + }; + + this.app = initializeApp(); + } + +} + +/** + * Barebones implementation of a user class. + * This can be extended to emulate users with different claims + */ +class MockUser implements User { + emailVerified: boolean; + isAnonymous: boolean; + metadata: UserMetadata; + providerData: UserInfo[]; + refreshToken: string; + tenantId: string | null; + + async delete(): Promise { } + + async getIdToken(forceRefresh?: boolean | undefined): Promise { + return "TESTIDTOKEN"; + } + + async getIdTokenResult(forceRefresh?: boolean | undefined): Promise { + return { + authTime: new Date().toUTCString(), + expirationTime: new Date('14 Jun 2070 00:00:00 PDT').toUTCString(), + issuedAtTime: new Date().toUTCString(), + signInProvider: null, + signInSecondFactor: null, + token: this.refreshToken, + claims: {} + } + } + + async reload(): Promise { } + + toJSON(): object { + return { + displayName: this.displayName, + email: this.email, + phoneNumber: this.phoneNumber, + photoURL: this.photoURL, + providerId: this.providerId, + uid: this.uid, + } + } + + displayName: string | null; + email: string | null; + phoneNumber: string | null; + photoURL: string | null; + providerId: string; + uid: string; + + constructor() { + this.emailVerified = true; + this.isAnonymous = false; + this.metadata = {}; + this.providerData = [this]; + this.refreshToken = ""; + this.tenantId = null; + this.displayName = "Test User"; + this.email = "test@test.com"; + this.phoneNumber = null; + this.photoURL = null; + this.providerId = "TEST"; + this.uid = "ABCD1234"; + } +} + +class MockAdminUser extends MockUser { + async getIdTokenResult(forceRefresh?: boolean | undefined): Promise { + return { + authTime: new Date().toUTCString(), + expirationTime: new Date('14 Jun 2070 00:00:00 PDT').toUTCString(), + issuedAtTime: new Date().toUTCString(), + signInProvider: null, + signInSecondFactor: null, + token: this.refreshToken, + claims: { + admin: true + } + } + } +} + +function getAuth(app?: FirebaseApp): Auth { + return new MockAuth(); +} + +function initializeAuth(app?: FirebaseApp): Auth { + return new MockAuth(); +} + +function connectAuthEmulator(auth: Auth, url: string, options?: { disableWarnings: boolean; }): void { + +} + +async function signIn(auth: Auth): Promise { + const user = new MockUser(); + + auth.updateCurrentUser(user); + + return { + user, + providerId: null, + operationType: "signIn" + } +} + +async function signInWithPopup(auth: Auth, provider: AuthProvider, resolver?: PopupRedirectResolver | undefined): Promise { + return signIn(auth); +} + +async function signInAnonymously(auth: Auth) { + return signIn(auth); +} + +async function deleteUser(user: User): Promise { } + +export { + getAuth, + initializeAuth, + signInWithPopup, + signInAnonymously, + connectAuthEmulator, + deleteUser +}; \ No newline at end of file diff --git a/src/__mocks__/index.ts b/src/__mocks__/index.ts index 94c2a5be..28da6b7b 100644 --- a/src/__mocks__/index.ts +++ b/src/__mocks__/index.ts @@ -2,3 +2,4 @@ import { Alert } from 'react-native'; jest.spyOn(Alert, 'alert'); jest.mock("@react-native-async-storage/async-storage"); +jest.mock("@firebase/auth"); \ No newline at end of file From d25b2420fdff3d56c67a4af3670a036130f613c3 Mon Sep 17 00:00:00 2001 From: LucientZ Date: Tue, 11 Jun 2024 16:02:48 -0500 Subject: [PATCH 022/198] Added firebase emulator config --- firebase-emulator/firestore.indexes.json | 389 +++++++++++++++++++++++ firebase-emulator/firestore.rules | 104 ++++++ firebase-emulator/storage.rules | 10 + firebase.json | 36 ++- 4 files changed, 538 insertions(+), 1 deletion(-) create mode 100644 firebase-emulator/firestore.indexes.json create mode 100644 firebase-emulator/firestore.rules create mode 100644 firebase-emulator/storage.rules diff --git a/firebase-emulator/firestore.indexes.json b/firebase-emulator/firestore.indexes.json new file mode 100644 index 00000000..117b655f --- /dev/null +++ b/firebase-emulator/firestore.indexes.json @@ -0,0 +1,389 @@ +{ + "indexes": [ + { + "collectionGroup": "event-logs", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "creationTime", + "order": "ASCENDING" + }, + { + "fieldPath": "points", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "event-logs", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "signInTime", + "order": "ASCENDING" + }, + { + "fieldPath": "points", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "event-logs", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "verified", + "order": "ASCENDING" + }, + { + "fieldPath": "creationTime", + "order": "ASCENDING" + }, + { + "fieldPath": "points", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "event-logs", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "verified", + "order": "ASCENDING" + }, + { + "fieldPath": "points", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "events", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "committee", + "order": "ASCENDING" + }, + { + "fieldPath": "endDate", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "events", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "committee", + "order": "ASCENDING" + }, + { + "fieldPath": "endTime", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "events", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "eventType", + "order": "ASCENDING" + }, + { + "fieldPath": "endTime", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "events", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "notificationGroup", + "order": "ASCENDING" + }, + { + "fieldPath": "endDate", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "events", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "notificationGroup", + "arrayConfig": "CONTAINS" + }, + { + "fieldPath": "endDate", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "events", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "notificationSent", + "order": "ASCENDING" + }, + { + "fieldPath": "startTime", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "office-hour-log", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "uid", + "order": "ASCENDING" + }, + { + "fieldPath": "timestamp", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "users", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "classYear", + "order": "ASCENDING" + }, + { + "fieldPath": "major", + "order": "ASCENDING" + }, + { + "fieldPath": "roles.officer", + "order": "ASCENDING" + }, + { + "fieldPath": "name", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "users", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "classYear", + "order": "ASCENDING" + }, + { + "fieldPath": "major", + "order": "ASCENDING" + }, + { + "fieldPath": "roles.officer", + "order": "ASCENDING" + }, + { + "fieldPath": "points", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "users", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "classYear", + "order": "ASCENDING" + }, + { + "fieldPath": "roles.officer", + "order": "ASCENDING" + }, + { + "fieldPath": "name", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "users", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "classYear", + "order": "ASCENDING" + }, + { + "fieldPath": "roles.officer", + "order": "ASCENDING" + }, + { + "fieldPath": "points", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "users", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "major", + "order": "ASCENDING" + }, + { + "fieldPath": "roles.officer", + "order": "ASCENDING" + }, + { + "fieldPath": "name", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "users", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "major", + "order": "ASCENDING" + }, + { + "fieldPath": "roles.officer", + "order": "ASCENDING" + }, + { + "fieldPath": "points", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "users", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "roles.lead", + "order": "ASCENDING" + }, + { + "fieldPath": "roles.officer", + "order": "ASCENDING" + }, + { + "fieldPath": "pointsThisMonth", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "users", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "roles.officer", + "order": "ASCENDING" + }, + { + "fieldPath": "name", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "users", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "roles.officer", + "order": "ASCENDING" + }, + { + "fieldPath": "points", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "users", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "roles.officer", + "order": "ASCENDING" + }, + { + "fieldPath": "pointsThisMonth", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "users", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "roles.officer", + "order": "ASCENDING" + }, + { + "fieldPath": "pointsThisMonth", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "users", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "roles.officer", + "order": "ASCENDING" + }, + { + "fieldPath": "roles.representative", + "order": "ASCENDING" + }, + { + "fieldPath": "pointsThisMonth", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "users", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "roles.officer", + "order": "ASCENDING" + }, + { + "fieldPath": "tamuEmail", + "order": "ASCENDING" + }, + { + "fieldPath": "name", + "order": "ASCENDING" + } + ] + } + ], + "fieldOverrides": [] +} diff --git a/firebase-emulator/firestore.rules b/firebase-emulator/firestore.rules new file mode 100644 index 00000000..298cc2c4 --- /dev/null +++ b/firebase-emulator/firestore.rules @@ -0,0 +1,104 @@ +rules_version = '2'; + +service cloud.firestore { + match /databases/{database}/documents { + function isAdmin(request){ + return request.auth.token.admin == true; + } + + function isOfficer(request){ + return request.auth.token.officer == true; + } + + function isDeveloper(request){ + return request.auth.token.developer == true; + } + + function isSuperUser(request){ + return isAdmin(request) || isOfficer(request) || isDeveloper(request); + } + + match /events/{document=**} { + allow read: if true + allow create, write, delete, update: if request.auth.uid != null && isSuperUser(request); + } + + match /users/{userId} { + match /private/{document=**}{ + allow read, write, create, update, delete: if request.auth.uid != null && request.auth.uid == userId; + } + match /event-logs/{document=**}{ + allow read: if request.auth.uid != null; + allow write: if request.auth.uid != null && isSuperUser(request); + } + allow read: if request.auth.uid != null; + allow create, write, update, delete: if request.auth.uid != null && (request.auth.uid == userId || isSuperUser(request)); + } + + match /deleted-accounts/{userId}/{document=**} { + allow read, write, create, update, delete: if request.auth.uid != null && request.auth.uid == userId; + } + + + match /office-hours { + match /officer-log/{document=**} { + allow read, write, create, update, delete: if request.auth.uid != null && isSuperUser(request); + } + match /member-log/{document=**} { + allow read, write, create, update, delete: if request.auth.uid != null + } + match /officer-count { + allow read: if request.auth.uid != null; + allow write, create, update, delete: if request.auth.uid != null && isSuperUser(request); + } + match /officers-status/officers/{document=**} { + allow read, create, write, update, delete: if request.auth.uid != null && isSuperUser(request); + } + } + + match /committees/{document=**} { + allow read: if request.auth.uid != null; + allow write,create,update,delete: if request.auth.uid != null && isSuperUser(request); + } + + match /member-of-the-month/{document=**} { + allow read: if request.auth.uid != null; + allow write,create,update,delete: if request.auth.uid != null && isSuperUser(request); + } + + match /restrictions/{document=**}{ + allow read: if request.auth.uid != null; + allow write, create, update, delete: if request.auth.uid != null && isSuperUser(request); + } + + match /resumes/{document=**} { + allow read, write,create,update,delete: if request.auth.uid != null && isSuperUser(request); + } + + match /links/{document=**} { + allow read: if request.auth.uid != null; + allow write,create,update,delete: if request.auth.uid != null && isSuperUser(request); + } + + match /feedback/{document=**} { + allow read,write: if request.auth.uid != null + allow create,update,delete: if request.auth.uid != null; + } + + match /memberSHPE/{userId} { + allow read,write,create,update,delete: if request.auth.uid != null && (request.auth.uid == userId || isSuperUser(request)); + } + + match /shirt-sizes/{userId} { + allow read,write,create,update,delete: if request.auth.uid != null && (request.auth.uid == userId || isSuperUser(request)); + } + + match /resumeVerification/{userId} { + allow read,write,create,update,delete: if request.auth.uid != null && (request.auth.uid == userId || isSuperUser(request)); + } + + match /committeeVerification/{committee}/requests/{userId} { + allow read,write,create,update,delete: if request.auth.uid != null && (request.auth.uid == userId || isSuperUser(request)); + } + } +} \ No newline at end of file diff --git a/firebase-emulator/storage.rules b/firebase-emulator/storage.rules new file mode 100644 index 00000000..520fa093 --- /dev/null +++ b/firebase-emulator/storage.rules @@ -0,0 +1,10 @@ +rules_version = '2'; + +// Allows anyone to read/write to cloud storage. Do NOT use in production! +service firebase.storage { + match /b/{bucket}/o { + match /{allPaths=**} { + allow read, write: if true; + } + } +} diff --git a/firebase.json b/firebase.json index 29b501f5..2444e20e 100644 --- a/firebase.json +++ b/firebase.json @@ -14,5 +14,39 @@ "npm --prefix \"$RESOURCE_DIR\" run build" ] } - ] + ], + "emulators": { + "auth": { + "host": "0.0.0.0", + "port": 9099 + }, + "functions": { + "host": "0.0.0.0", + "port": 5001 + }, + "firestore": { + "host": "0.0.0.0", + "port": 8080 + }, + "pubsub": { + "host": "0.0.0.0", + "port": 8085 + }, + "storage": { + "host": "0.0.0.0", + "port": 9199 + }, + "ui": { + "host": "0.0.0.0", + "enabled": true + }, + "singleProjectMode": true + }, + "storage": { + "rules": "firebase-emulator/storage.rules" + }, + "firestore": { + "rules": "firebase-emulator/firestore.rules", + "indexes": "firebase-emulator/firestore.indexes.json" + } } From 7b3d698359bd49131ab2b3d60101ae7d80fd42c8 Mon Sep 17 00:00:00 2001 From: LucientZ Date: Tue, 11 Jun 2024 16:03:25 -0500 Subject: [PATCH 023/198] Modified formatting of auth mock --- src/__mocks__/@firebase/auth.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/__mocks__/@firebase/auth.ts b/src/__mocks__/@firebase/auth.ts index 60d7bda1..5813e6b4 100644 --- a/src/__mocks__/@firebase/auth.ts +++ b/src/__mocks__/@firebase/auth.ts @@ -154,18 +154,20 @@ class MockAdminUser extends MockUser { } } -function getAuth(app?: FirebaseApp): Auth { - return new MockAuth(); -} - function initializeAuth(app?: FirebaseApp): Auth { - return new MockAuth(); + const auth = new MockAuth(); + if (app) { + auth.app = app; + } + return auth; } -function connectAuthEmulator(auth: Auth, url: string, options?: { disableWarnings: boolean; }): void { - +function getAuth(app?: FirebaseApp): Auth { + return initializeAuth(app); } +function connectAuthEmulator(auth: Auth, url: string, options?: { disableWarnings: boolean; }): void { } + async function signIn(auth: Auth): Promise { const user = new MockUser(); From 39181f6afd404b6d360562f68b58aac8999023ba Mon Sep 17 00:00:00 2001 From: LucientZ Date: Tue, 11 Jun 2024 16:03:54 -0500 Subject: [PATCH 024/198] Added ability for app or jest to connect to firebase emulator --- src/config/firebaseConfig.ts | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/config/firebaseConfig.ts b/src/config/firebaseConfig.ts index 126aba8e..6a7b81c0 100644 --- a/src/config/firebaseConfig.ts +++ b/src/config/firebaseConfig.ts @@ -1,9 +1,9 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { initializeApp, FirebaseApp, getApp, getApps } from 'firebase/app'; -import { getAuth, initializeAuth, getReactNativePersistence, Auth } from "firebase/auth"; -import { getFirestore } from "firebase/firestore"; -import { getFunctions } from 'firebase/functions'; -import { getStorage } from "firebase/storage"; +import { getAuth, initializeAuth, getReactNativePersistence, Auth, connectAuthEmulator } from "firebase/auth"; +import { connectFirestoreEmulator, getFirestore } from "firebase/firestore"; +import { connectFunctionsEmulator, getFunctions } from 'firebase/functions'; +import { connectStorageEmulator, getStorage } from "firebase/storage"; const firebaseConfig = { @@ -24,7 +24,7 @@ let auth: Auth; if (getApps().length === 0) { app = initializeApp(firebaseConfig); - // For jest unit tests, getReactNativePersistence is not defined. + // Non react-native environments do not define getReactNativePersistence auth = initializeAuth(app, { persistence: getReactNativePersistence ? getReactNativePersistence(AsyncStorage) : undefined, }); @@ -38,4 +38,24 @@ const db = getFirestore(app); const storage = getStorage(app); const functions = getFunctions(app); +if (process.env.FIREBASE_EMULATOR_ADDRESS !== undefined) { + const address = process.env.FIREBASE_EMULATOR_ADDRESS; + if(process.env.FIREBASE_AUTH_PORT !== undefined){ + console.debug(`Connecting auth to http://${address}:${process.env.FIREBASE_AUTH_PORT}`); + connectAuthEmulator(auth, `http://${address}:${process.env.FIREBASE_AUTH_PORT}`); + } + if(process.env.FIREBASE_FIRESTORE_PORT !== undefined){ + console.debug(`Connecting to firestore at http://${address}:${process.env.FIREBASE_FIRESTORE_PORT}`); + connectFirestoreEmulator(db, address, Number(process.env.FIREBASE_FIRESTORE_PORT)); + } + if(process.env.FIREBASE_CLOUD_FUNCTIONS_PORT !== undefined){ + console.debug(`Connecting to cloud functions at http://${address}:${process.env.FIREBASE_CLOUD_FUNCTIONS_PORT}`); + connectFunctionsEmulator(functions, address, Number(process.env.FIREBASE_CLOUD_FUNCTIONS_PORT)); + } + if(process.env.FIREBASE_STORAGE_PORT !== undefined){ + console.debug(`Connecting to cloud functions at http://${address}:${process.env.FIREBASE_STORAGE_PORT}`); + connectStorageEmulator(storage, address, Number(process.env.FIREBASE_STORAGE_PORT)); + } +} + export { db, auth, storage, functions }; From 99787821633bd91dbd89fa904755f86bb368f097 Mon Sep 17 00:00:00 2001 From: LucientZ Date: Tue, 11 Jun 2024 17:25:26 -0500 Subject: [PATCH 025/198] Fixed jest mocks --- src/__mocks__/@firebase/auth.ts | 40 +++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/__mocks__/@firebase/auth.ts b/src/__mocks__/@firebase/auth.ts index 5813e6b4..bb4d8a4f 100644 --- a/src/__mocks__/@firebase/auth.ts +++ b/src/__mocks__/@firebase/auth.ts @@ -1,5 +1,14 @@ import { FirebaseApp, initializeApp } from "firebase/app"; -import { Auth, AuthProvider, AuthSettings, CompleteFn, Config, EmulatorConfig, ErrorFn, IdTokenResult, NextOrObserver, Persistence, PopupRedirectResolver, Unsubscribe, User, UserCredential, UserInfo, UserMetadata } from "firebase/auth"; +import { Auth, AuthProvider, AuthSettings, CompleteFn, Config, Dependencies, EmulatorConfig, ErrorFn, IdTokenResult, NextOrObserver, Persistence, PopupRedirectResolver, Unsubscribe, User, UserCredential, UserInfo, UserMetadata } from "firebase/auth"; + +const firebaseConfig = { + apiKey: process.env.GOOGLE_API_KEY, + authDomain: "tamushpemobileapp.firebaseapp.com", + projectId: "tamushpemobileapp", + storageBucket: "tamushpemobileapp.appspot.com", + messagingSenderId: "600060629240", + appId: "1:600060629240:web:1e97e43973746bcc266b0d" +}; /** * Barebones implementation of Auth to work with unit tests. @@ -66,10 +75,9 @@ class MockAuth implements Auth { this.settings = { appVerificationDisabledForTesting: true }; - - this.app = initializeApp(); + + this.app = initializeApp(firebaseConfig); } - } /** @@ -154,21 +162,21 @@ class MockAdminUser extends MockUser { } } -function initializeAuth(app?: FirebaseApp): Auth { +const initializeAuth = jest.fn((app?: FirebaseApp, deps?: Dependencies | undefined): Auth => { const auth = new MockAuth(); if (app) { auth.app = app; } return auth; -} +}); -function getAuth(app?: FirebaseApp): Auth { +const getAuth = jest.fn((app?: FirebaseApp): Auth => { return initializeAuth(app); -} +}); -function connectAuthEmulator(auth: Auth, url: string, options?: { disableWarnings: boolean; }): void { } +const connectAuthEmulator = jest.fn((auth: Auth, url: string, options?: { disableWarnings: boolean; }): void => { }); -async function signIn(auth: Auth): Promise { +const signIn = jest.fn(async (auth: Auth): Promise => { const user = new MockUser(); auth.updateCurrentUser(user); @@ -178,17 +186,15 @@ async function signIn(auth: Auth): Promise { providerId: null, operationType: "signIn" } -} +}); -async function signInWithPopup(auth: Auth, provider: AuthProvider, resolver?: PopupRedirectResolver | undefined): Promise { +const signInWithPopup = jest.fn((auth: Auth, provider: AuthProvider, resolver?: PopupRedirectResolver | undefined): Promise => { return signIn(auth); -} +}); -async function signInAnonymously(auth: Auth) { - return signIn(auth); -} +const signInAnonymously = jest.fn(signIn); -async function deleteUser(user: User): Promise { } +const deleteUser = jest.fn(async (user: User): Promise => { }); export { getAuth, From e4e785119ea1955fd923cddfae0b8fa87739a7f0 Mon Sep 17 00:00:00 2001 From: LucientZ Date: Tue, 11 Jun 2024 17:46:33 -0500 Subject: [PATCH 026/198] Modified debug message --- src/config/firebaseConfig.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/config/firebaseConfig.ts b/src/config/firebaseConfig.ts index 6a7b81c0..0ff15f70 100644 --- a/src/config/firebaseConfig.ts +++ b/src/config/firebaseConfig.ts @@ -40,20 +40,17 @@ const functions = getFunctions(app); if (process.env.FIREBASE_EMULATOR_ADDRESS !== undefined) { const address = process.env.FIREBASE_EMULATOR_ADDRESS; + console.debug("Connecting to firebase emulators."); if(process.env.FIREBASE_AUTH_PORT !== undefined){ - console.debug(`Connecting auth to http://${address}:${process.env.FIREBASE_AUTH_PORT}`); connectAuthEmulator(auth, `http://${address}:${process.env.FIREBASE_AUTH_PORT}`); } if(process.env.FIREBASE_FIRESTORE_PORT !== undefined){ - console.debug(`Connecting to firestore at http://${address}:${process.env.FIREBASE_FIRESTORE_PORT}`); connectFirestoreEmulator(db, address, Number(process.env.FIREBASE_FIRESTORE_PORT)); } if(process.env.FIREBASE_CLOUD_FUNCTIONS_PORT !== undefined){ - console.debug(`Connecting to cloud functions at http://${address}:${process.env.FIREBASE_CLOUD_FUNCTIONS_PORT}`); connectFunctionsEmulator(functions, address, Number(process.env.FIREBASE_CLOUD_FUNCTIONS_PORT)); } if(process.env.FIREBASE_STORAGE_PORT !== undefined){ - console.debug(`Connecting to cloud functions at http://${address}:${process.env.FIREBASE_STORAGE_PORT}`); connectStorageEmulator(storage, address, Number(process.env.FIREBASE_STORAGE_PORT)); } } From b4a2322d61191d2d084cabf077cfff8aa1ddb52a Mon Sep 17 00:00:00 2001 From: LucientZ Date: Tue, 11 Jun 2024 17:46:51 -0500 Subject: [PATCH 027/198] Added initial tests for firebaseUtils --- src/api/__tests__/firebaseUtils.test.ts | 31 +++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/api/__tests__/firebaseUtils.test.ts diff --git a/src/api/__tests__/firebaseUtils.test.ts b/src/api/__tests__/firebaseUtils.test.ts new file mode 100644 index 00000000..87cbf34b --- /dev/null +++ b/src/api/__tests__/firebaseUtils.test.ts @@ -0,0 +1,31 @@ +import { signInAnonymously } from "firebase/auth"; +import { getPublicUserData, initializeCurrentUserData } from "../firebaseUtils"; +import { auth, storage } from "../../config/firebaseConfig"; + +beforeAll(() => { + signInAnonymously(auth); +}); + +describe("Testing environment is set up correctly", () => { + test("Environment variables are setup", () => { + expect(process.env.FIREBASE_AUTH_PORT).toBeDefined(); + expect(process.env.FIREBASE_CLOUD_FUNCTIONS_PORT).toBeDefined(); + expect(process.env.FIREBASE_FIRESTORE_PORT).toBeDefined(); + expect(process.env.FIREBASE_STORAGE_PORT).toBeDefined(); + + expect(Number(process.env.FIREBASE_AUTH_PORT)).not.toBeNaN(); + expect(Number(process.env.FIREBASE_CLOUD_FUNCTIONS_PORT)).not.toBeNaN(); + expect(Number(process.env.FIREBASE_FIRESTORE_PORT)).not.toBeNaN(); + expect(Number(process.env.FIREBASE_STORAGE_PORT)).not.toBeNaN(); + }); +}); + + +describe("User Profile", () => { + test("Initializes correctly", () => { + // initializeCurrentUserData(); + + // const user = getPublicUserData(); + }); +}); + From 0495cc0a105d465d49ac6ffbfda510c9d776344d Mon Sep 17 00:00:00 2001 From: LucientZ Date: Tue, 11 Jun 2024 17:47:17 -0500 Subject: [PATCH 028/198] Modified tests to include emulator environment variables --- .github/workflows/CITests.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/CITests.yml b/.github/workflows/CITests.yml index f0e70ef4..a3e850d7 100644 --- a/.github/workflows/CITests.yml +++ b/.github/workflows/CITests.yml @@ -28,6 +28,11 @@ jobs: FLICKER_USER_ID: ${{ secrets.FLICKER_USER_ID }} FLICKER_API_KEY: ${{ secrets.FLICKER_API_KEY }} GOOGLE_PLACES_API_KEY: ${{ secrets.GOOGLE_PLACES_API_KEY }} + FIREBASE_EMULATOR_ADDRESS: 127.0.0.1 + FIREBASE_AUTH_PORT: 9099 + FIREBASE_FIRESTORE_PORT: 8080 + FIREBASE_CLOUD_FUNCTIONS_PORT: 5001 + FIREBASE_STORAGE_PORT: 9199 run: echo "Environment variables set" - name: Install dependencies From dc31b7e5ad46607b8135a609d2f71ff972df492d Mon Sep 17 00:00:00 2001 From: LucientZ Date: Tue, 11 Jun 2024 17:49:22 -0500 Subject: [PATCH 029/198] Moved env variables and added temporary test branch --- .github/workflows/CITests.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/CITests.yml b/.github/workflows/CITests.yml index a3e850d7..8e77a347 100644 --- a/.github/workflows/CITests.yml +++ b/.github/workflows/CITests.yml @@ -2,7 +2,7 @@ name: Node CI Tests on: push: - branches: [ "main" ] + branches: [ "main", "firebase-emulator" ] pull_request: branches: [ "main", "dev" ] @@ -28,11 +28,6 @@ jobs: FLICKER_USER_ID: ${{ secrets.FLICKER_USER_ID }} FLICKER_API_KEY: ${{ secrets.FLICKER_API_KEY }} GOOGLE_PLACES_API_KEY: ${{ secrets.GOOGLE_PLACES_API_KEY }} - FIREBASE_EMULATOR_ADDRESS: 127.0.0.1 - FIREBASE_AUTH_PORT: 9099 - FIREBASE_FIRESTORE_PORT: 8080 - FIREBASE_CLOUD_FUNCTIONS_PORT: 5001 - FIREBASE_STORAGE_PORT: 9199 run: echo "Environment variables set" - name: Install dependencies @@ -44,4 +39,9 @@ jobs: FLICKER_USER_ID: ${{ secrets.FLICKER_USER_ID }} FLICKER_API_KEY: ${{ secrets.FLICKER_API_KEY }} GOOGLE_PLACES_API_KEY: ${{ secrets.GOOGLE_PLACES_API_KEY }} + FIREBASE_EMULATOR_ADDRESS: 127.0.0.1 + FIREBASE_AUTH_PORT: 9099 + FIREBASE_FIRESTORE_PORT: 8080 + FIREBASE_CLOUD_FUNCTIONS_PORT: 5001 + FIREBASE_STORAGE_PORT: 9199 run: yarn test From 3ad2cbcd2d6b52c9cdd76937c2faed28ec6f7d43 Mon Sep 17 00:00:00 2001 From: LucientZ Date: Tue, 11 Jun 2024 17:55:14 -0500 Subject: [PATCH 030/198] Added firebase emulator dependencies --- .github/workflows/CITests.yml | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/workflows/CITests.yml b/.github/workflows/CITests.yml index 8e77a347..7f0551a2 100644 --- a/.github/workflows/CITests.yml +++ b/.github/workflows/CITests.yml @@ -23,17 +23,6 @@ jobs: node-version: ${{ matrix.node-version }} - name: Set Environment Variables - env: - GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} - FLICKER_USER_ID: ${{ secrets.FLICKER_USER_ID }} - FLICKER_API_KEY: ${{ secrets.FLICKER_API_KEY }} - GOOGLE_PLACES_API_KEY: ${{ secrets.GOOGLE_PLACES_API_KEY }} - run: echo "Environment variables set" - - - name: Install dependencies - run: yarn install - - - name: Run tests env: GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} FLICKER_USER_ID: ${{ secrets.FLICKER_USER_ID }} @@ -44,4 +33,14 @@ jobs: FIREBASE_FIRESTORE_PORT: 8080 FIREBASE_CLOUD_FUNCTIONS_PORT: 5001 FIREBASE_STORAGE_PORT: 9199 - run: yarn test + run: echo "Environment variables set" + + - name: Install dependencies + run: | + yarn install + npm install -g firebase-tools + npm ci + + + - name: Run tests + run: firebase emulators:exec \"yarn test\"" From 1f1e91033690e67ff72cd3d9bf5804bd25f7b136 Mon Sep 17 00:00:00 2001 From: LucientZ Date: Tue, 11 Jun 2024 17:57:06 -0500 Subject: [PATCH 031/198] Removed npm ci --- .github/workflows/CITests.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/CITests.yml b/.github/workflows/CITests.yml index 7f0551a2..583c2bef 100644 --- a/.github/workflows/CITests.yml +++ b/.github/workflows/CITests.yml @@ -39,7 +39,6 @@ jobs: run: | yarn install npm install -g firebase-tools - npm ci - name: Run tests From 6ef8fd05e57d6556305eee45ddfb73a01349d2c5 Mon Sep 17 00:00:00 2001 From: LucientZ Date: Tue, 11 Jun 2024 17:59:29 -0500 Subject: [PATCH 032/198] Removed extra quotation mark --- .github/workflows/CITests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CITests.yml b/.github/workflows/CITests.yml index 583c2bef..906dc861 100644 --- a/.github/workflows/CITests.yml +++ b/.github/workflows/CITests.yml @@ -42,4 +42,4 @@ jobs: - name: Run tests - run: firebase emulators:exec \"yarn test\"" + run: firebase emulators:exec \"yarn test\" From 027feb0533967cedd819bb8fd6ece6c7226b0157 Mon Sep 17 00:00:00 2001 From: LucientZ Date: Tue, 11 Jun 2024 19:03:47 -0500 Subject: [PATCH 033/198] Added java dependency and fixed emulator execution --- .github/workflows/CITests.yml | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/CITests.yml b/.github/workflows/CITests.yml index 906dc861..e6a3927c 100644 --- a/.github/workflows/CITests.yml +++ b/.github/workflows/CITests.yml @@ -13,6 +13,7 @@ jobs: strategy: matrix: node-version: [ 18.x ] + java-version: [ 21.x ] steps: - uses: actions/checkout@v3 @@ -21,6 +22,11 @@ jobs: uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} + + - name: Use Java ${{ matrix.java-version }} + uses: actions/setup-java@v1.3.0 + with: + java-version: ${{ matrix.java-version }} - name: Set Environment Variables env: @@ -35,11 +41,16 @@ jobs: FIREBASE_STORAGE_PORT: 9199 run: echo "Environment variables set" - - name: Install dependencies + - name: Install Dependencies run: | yarn install - npm install -g firebase-tools + yarn global add firebase-tools - - - name: Run tests - run: firebase emulators:exec \"yarn test\" + - name: Build Cloud Functions + working-directory: ./functions + run: | + npm install + npm run build + + - name: Run Tests + run: firebase emulators:exec 'yarn test' From e50614f16bd56bb81b29ac949861e26c3ea40578 Mon Sep 17 00:00:00 2001 From: LucientZ Date: Tue, 11 Jun 2024 19:12:43 -0500 Subject: [PATCH 034/198] Bumped setup-java version --- .github/workflows/CITests.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CITests.yml b/.github/workflows/CITests.yml index e6a3927c..4bec658d 100644 --- a/.github/workflows/CITests.yml +++ b/.github/workflows/CITests.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: node-version: [ 18.x ] - java-version: [ 21.x ] + java-version: [ 21 ] steps: - uses: actions/checkout@v3 @@ -24,8 +24,9 @@ jobs: node-version: ${{ matrix.node-version }} - name: Use Java ${{ matrix.java-version }} - uses: actions/setup-java@v1.3.0 + uses: actions/setup-java@v4 with: + distribution: 'zulu' java-version: ${{ matrix.java-version }} - name: Set Environment Variables From 5986c385410fd40b476c9a4809571d95c15b5a95 Mon Sep 17 00:00:00 2001 From: LucientZ Date: Tue, 11 Jun 2024 19:16:47 -0500 Subject: [PATCH 035/198] Put environment variables in correct place --- .github/workflows/CITests.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/CITests.yml b/.github/workflows/CITests.yml index 4bec658d..f16e60e1 100644 --- a/.github/workflows/CITests.yml +++ b/.github/workflows/CITests.yml @@ -54,4 +54,14 @@ jobs: npm run build - name: Run Tests + env: + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + FLICKER_USER_ID: ${{ secrets.FLICKER_USER_ID }} + FLICKER_API_KEY: ${{ secrets.FLICKER_API_KEY }} + GOOGLE_PLACES_API_KEY: ${{ secrets.GOOGLE_PLACES_API_KEY }} + FIREBASE_EMULATOR_ADDRESS: 127.0.0.1 + FIREBASE_AUTH_PORT: 9099 + FIREBASE_FIRESTORE_PORT: 8080 + FIREBASE_CLOUD_FUNCTIONS_PORT: 5001 + FIREBASE_STORAGE_PORT: 9199 run: firebase emulators:exec 'yarn test' From 3a319b8e72d000befcbfbe695706892b9938676b Mon Sep 17 00:00:00 2001 From: LucientZ Date: Tue, 11 Jun 2024 19:20:53 -0500 Subject: [PATCH 036/198] Removed firebase-emulator from CITests rules --- .github/workflows/CITests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CITests.yml b/.github/workflows/CITests.yml index f16e60e1..74e50581 100644 --- a/.github/workflows/CITests.yml +++ b/.github/workflows/CITests.yml @@ -2,7 +2,7 @@ name: Node CI Tests on: push: - branches: [ "main", "firebase-emulator" ] + branches: [ "main" ] pull_request: branches: [ "main", "dev" ] From 44878b7b2cadbb9048f769583640639b287c9e92 Mon Sep 17 00:00:00 2001 From: LucientZ Date: Tue, 11 Jun 2024 21:09:43 -0500 Subject: [PATCH 037/198] Adjusted emulator rules --- firebase-emulator/firestore.rules | 102 +----------------------------- 1 file changed, 3 insertions(+), 99 deletions(-) diff --git a/firebase-emulator/firestore.rules b/firebase-emulator/firestore.rules index 298cc2c4..3cb75a84 100644 --- a/firebase-emulator/firestore.rules +++ b/firebase-emulator/firestore.rules @@ -1,104 +1,8 @@ rules_version = '2'; +// Allows anyone to read/write to firestore. Do NOT use in production! service cloud.firestore { - match /databases/{database}/documents { - function isAdmin(request){ - return request.auth.token.admin == true; - } - - function isOfficer(request){ - return request.auth.token.officer == true; - } - - function isDeveloper(request){ - return request.auth.token.developer == true; - } - - function isSuperUser(request){ - return isAdmin(request) || isOfficer(request) || isDeveloper(request); - } - - match /events/{document=**} { - allow read: if true - allow create, write, delete, update: if request.auth.uid != null && isSuperUser(request); - } - - match /users/{userId} { - match /private/{document=**}{ - allow read, write, create, update, delete: if request.auth.uid != null && request.auth.uid == userId; - } - match /event-logs/{document=**}{ - allow read: if request.auth.uid != null; - allow write: if request.auth.uid != null && isSuperUser(request); - } - allow read: if request.auth.uid != null; - allow create, write, update, delete: if request.auth.uid != null && (request.auth.uid == userId || isSuperUser(request)); - } - - match /deleted-accounts/{userId}/{document=**} { - allow read, write, create, update, delete: if request.auth.uid != null && request.auth.uid == userId; - } - - - match /office-hours { - match /officer-log/{document=**} { - allow read, write, create, update, delete: if request.auth.uid != null && isSuperUser(request); - } - match /member-log/{document=**} { - allow read, write, create, update, delete: if request.auth.uid != null - } - match /officer-count { - allow read: if request.auth.uid != null; - allow write, create, update, delete: if request.auth.uid != null && isSuperUser(request); - } - match /officers-status/officers/{document=**} { - allow read, create, write, update, delete: if request.auth.uid != null && isSuperUser(request); - } - } - - match /committees/{document=**} { - allow read: if request.auth.uid != null; - allow write,create,update,delete: if request.auth.uid != null && isSuperUser(request); - } - - match /member-of-the-month/{document=**} { - allow read: if request.auth.uid != null; - allow write,create,update,delete: if request.auth.uid != null && isSuperUser(request); - } - - match /restrictions/{document=**}{ - allow read: if request.auth.uid != null; - allow write, create, update, delete: if request.auth.uid != null && isSuperUser(request); - } - - match /resumes/{document=**} { - allow read, write,create,update,delete: if request.auth.uid != null && isSuperUser(request); - } - - match /links/{document=**} { - allow read: if request.auth.uid != null; - allow write,create,update,delete: if request.auth.uid != null && isSuperUser(request); - } - - match /feedback/{document=**} { - allow read,write: if request.auth.uid != null - allow create,update,delete: if request.auth.uid != null; - } - - match /memberSHPE/{userId} { - allow read,write,create,update,delete: if request.auth.uid != null && (request.auth.uid == userId || isSuperUser(request)); - } - - match /shirt-sizes/{userId} { - allow read,write,create,update,delete: if request.auth.uid != null && (request.auth.uid == userId || isSuperUser(request)); - } - - match /resumeVerification/{userId} { - allow read,write,create,update,delete: if request.auth.uid != null && (request.auth.uid == userId || isSuperUser(request)); - } - - match /committeeVerification/{committee}/requests/{userId} { - allow read,write,create,update,delete: if request.auth.uid != null && (request.auth.uid == userId || isSuperUser(request)); - } + match /databases/{database}/documents/{document=**} { + allow read, create, write, delete, update: if true } } \ No newline at end of file From 8844da46f99b3748a0cf6613e14984de9983f29e Mon Sep 17 00:00:00 2001 From: LucientZ Date: Thu, 13 Jun 2024 17:05:18 -0500 Subject: [PATCH 038/198] Added user data tests --- src/api/__tests__/firebaseUtils.test.ts | 109 +++++++++++++++++++----- 1 file changed, 88 insertions(+), 21 deletions(-) diff --git a/src/api/__tests__/firebaseUtils.test.ts b/src/api/__tests__/firebaseUtils.test.ts index 87cbf34b..1b781587 100644 --- a/src/api/__tests__/firebaseUtils.test.ts +++ b/src/api/__tests__/firebaseUtils.test.ts @@ -1,31 +1,98 @@ import { signInAnonymously } from "firebase/auth"; -import { getPublicUserData, initializeCurrentUserData } from "../firebaseUtils"; -import { auth, storage } from "../../config/firebaseConfig"; +import { getPrivateUserData, getPublicUserData, getUser, getUserByEmail, initializeCurrentUserData, setPrivateUserData, setPublicUserData } from "../firebaseUtils"; +import { auth, db, storage } from "../../config/firebaseConfig"; +import { PrivateUserInfo, PublicUserInfo, User } from "../../types/user"; +import { validateTamuEmail } from "../../helpers"; +import { doc, setDoc } from "firebase/firestore"; -beforeAll(() => { - signInAnonymously(auth); -}); +beforeAll(async () => { + await signInAnonymously(auth); -describe("Testing environment is set up correctly", () => { - test("Environment variables are setup", () => { - expect(process.env.FIREBASE_AUTH_PORT).toBeDefined(); - expect(process.env.FIREBASE_CLOUD_FUNCTIONS_PORT).toBeDefined(); - expect(process.env.FIREBASE_FIRESTORE_PORT).toBeDefined(); - expect(process.env.FIREBASE_STORAGE_PORT).toBeDefined(); - - expect(Number(process.env.FIREBASE_AUTH_PORT)).not.toBeNaN(); - expect(Number(process.env.FIREBASE_CLOUD_FUNCTIONS_PORT)).not.toBeNaN(); - expect(Number(process.env.FIREBASE_FIRESTORE_PORT)).not.toBeNaN(); - expect(Number(process.env.FIREBASE_STORAGE_PORT)).not.toBeNaN(); - }); + // Check testing environment + expect(process.env.FIREBASE_EMULATOR_ADDRESS).toBeDefined(); + expect(process.env.FIREBASE_AUTH_PORT).toBeDefined(); + expect(process.env.FIREBASE_CLOUD_FUNCTIONS_PORT).toBeDefined(); + expect(process.env.FIREBASE_FIRESTORE_PORT).toBeDefined(); + expect(process.env.FIREBASE_STORAGE_PORT).toBeDefined(); + expect(Number(process.env.FIREBASE_AUTH_PORT)).not.toBeNaN(); + expect(Number(process.env.FIREBASE_CLOUD_FUNCTIONS_PORT)).not.toBeNaN(); + expect(Number(process.env.FIREBASE_FIRESTORE_PORT)).not.toBeNaN(); + expect(Number(process.env.FIREBASE_STORAGE_PORT)).not.toBeNaN(); }); +describe("User Info", () => { + test("Initializes correctly and can be modified", async () => { + expect((await getUserByEmail(auth.currentUser?.email!))).toBeNull(); + + const user = await initializeCurrentUserData(); + expect(user).toBeDefined(); + + const userData = await getUser(auth.currentUser?.uid!); + expect(userData).toBeDefined(); + expect(user).toMatchObject(userData!); + + expect(auth.currentUser?.uid).toBeDefined(); + expect(auth.currentUser?.email).toBeDefined(); + expect(auth.currentUser?.displayName).toBeDefined(); + expect(auth.currentUser?.photoURL).toBeDefined(); + + const publicUserData = await getPublicUserData(); + expect(publicUserData).toMatchObject({ + isStudent: validateTamuEmail(auth.currentUser?.email!), + displayName: auth.currentUser?.displayName!, + photoURL: auth.currentUser?.photoURL ?? "", + isEmailPublic: false, + }); + + const privateUserData = await getPrivateUserData(); + expect(privateUserData).toMatchObject({ + completedAccountSetup: false, + email: auth.currentUser!.email! + }); + + expect(publicUserData).toMatchObject(user.publicInfo!); + expect(privateUserData).toMatchObject(user.private?.privateInfo!); + + expect((await getUserByEmail(auth.currentUser?.email!))).toBeNull(); + + setPublicUserData({ + email: auth.currentUser?.email!, + isEmailPublic: true, + }); + + const updatedPublicUserData = await getPublicUserData(); + expect(updatedPublicUserData).not.toMatchObject(publicUserData!); + expect(updatedPublicUserData).toMatchObject({ + ...publicUserData, + email: auth.currentUser?.email, + isEmailPublic: true, + }); + + const emailUserData = await getUserByEmail(auth.currentUser?.email!); + expect(emailUserData).not.toBeFalsy(); + console.debug(emailUserData?.userData); + expect(emailUserData).toMatchObject({ + userData: { + ...publicUserData, + email: auth.currentUser?.email, + isEmailPublic: true, + }, + userUID: auth.currentUser?.uid + }); + + }, 10000); + + test("Can be seen by other users", async () => { + const otherUserUID = "1234567890"; + const otherUserData: PublicUserInfo = { + name: "Test", + email: "bob@tamu.edu", + }; -describe("User Profile", () => { - test("Initializes correctly", () => { - // initializeCurrentUserData(); + await setDoc(doc(db, "users", otherUserUID), otherUserData); - // const user = getPublicUserData(); + const publicUserData = await getPublicUserData(otherUserUID); + expect(publicUserData).toMatchObject(otherUserData); }); }); From 8b680ad49be5ad7252b0d5ed2ae0bb4184ccaab7 Mon Sep 17 00:00:00 2001 From: LucientZ Date: Thu, 13 Jun 2024 20:58:19 -0500 Subject: [PATCH 039/198] Added signOut function to auth mocks --- src/__mocks__/@firebase/auth.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/__mocks__/@firebase/auth.ts b/src/__mocks__/@firebase/auth.ts index bb4d8a4f..ce09c729 100644 --- a/src/__mocks__/@firebase/auth.ts +++ b/src/__mocks__/@firebase/auth.ts @@ -50,11 +50,8 @@ class MockAuth implements Auth { this.languageCode = "en"; } - signOut(): Promise { - return new Promise((resolve) => { - this.currentUser = null; - resolve() - }); + async signOut(): Promise { + this.currentUser = null; } constructor() { @@ -75,7 +72,7 @@ class MockAuth implements Auth { this.settings = { appVerificationDisabledForTesting: true }; - + this.app = initializeApp(firebaseConfig); } } @@ -196,11 +193,16 @@ const signInAnonymously = jest.fn(signIn); const deleteUser = jest.fn(async (user: User): Promise => { }); +const signOut = jest.fn(async (auth: Auth): Promise => { + auth.signOut(); +}); + export { getAuth, initializeAuth, signInWithPopup, signInAnonymously, + signOut, connectAuthEmulator, deleteUser }; \ No newline at end of file From da4904bafc61b009ecfd08ae4624cea14ada48a9 Mon Sep 17 00:00:00 2001 From: LucientZ Date: Fri, 14 Jun 2024 15:17:11 -0500 Subject: [PATCH 040/198] Added firebase edge case testing --- .../__tests__/firebaseUtilsEdgeCases.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/api/__tests__/firebaseUtilsEdgeCases.test.ts diff --git a/src/api/__tests__/firebaseUtilsEdgeCases.test.ts b/src/api/__tests__/firebaseUtilsEdgeCases.test.ts new file mode 100644 index 00000000..4a997ab1 --- /dev/null +++ b/src/api/__tests__/firebaseUtilsEdgeCases.test.ts @@ -0,0 +1,17 @@ +import { auth } from "../../config/firebaseConfig"; +import { getPrivateUserData, getPublicUserData, setPrivateUserData, setPublicUserData } from "../firebaseUtils"; + + +test("Getter functions throw errors when user is unauthenticated", async () => { + expect(auth.currentUser).toBeNull(); + + await expect(getPublicUserData).rejects.toThrow(); + await expect(getPrivateUserData).rejects.toThrow(); +}); + +test("Setter functions throw errors when user is unauthenticated", async () => { + expect(auth.currentUser).toBeNull(); + + await expect(setPublicUserData({})).rejects.toThrow(); + await expect(setPrivateUserData({})).rejects.toThrow(); +}); \ No newline at end of file From f0e0a4290faf638fa5bd3000d42551d8405a2e74 Mon Sep 17 00:00:00 2001 From: LucientZ Date: Fri, 14 Jun 2024 16:06:09 -0500 Subject: [PATCH 041/198] setPrivateUserData now throws if unauthenticated --- src/api/firebaseUtils.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/api/firebaseUtils.ts b/src/api/firebaseUtils.ts index 82fd4807..19761788 100644 --- a/src/api/firebaseUtils.ts +++ b/src/api/firebaseUtils.ts @@ -89,8 +89,11 @@ export const getPrivateUserData = async (uid: string = ""): Promise { + if (!auth.currentUser?.uid) { + throw new Error("Authentication Error", { cause: "Current user uid is undefined" }); + } + await setDoc(doc(db, `users/${auth.currentUser?.uid!}/private`, "privateInfo"), data, { merge: true }) - .catch(err => console.error(err)); }; From 09b6dcaf69eedf3090c335ba8edd2401d30e2cbd Mon Sep 17 00:00:00 2001 From: LucientZ Date: Fri, 14 Jun 2024 16:09:13 -0500 Subject: [PATCH 042/198] Added type to getUserForMemberList --- src/api/firebaseUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/firebaseUtils.ts b/src/api/firebaseUtils.ts index 19761788..8afe35e5 100644 --- a/src/api/firebaseUtils.ts +++ b/src/api/firebaseUtils.ts @@ -158,7 +158,7 @@ type FetchMembersOptions = { filter: UserFilter, }; -export const getUserForMemberList = async (options: FetchMembersOptions) => { +export const getUserForMemberList = async (options: FetchMembersOptions): Promise<{ members: QueryDocumentSnapshot[]; lastSnapshot: QueryDocumentSnapshot; hasMoreUser: boolean; } | { members: never[]; lastSnapshot: null; hasMoreUser: boolean; }> => { const { lastUserSnapshot, numLimit = null, From 6585c1e63453f4c002c59b3a284bd96fbae750c8 Mon Sep 17 00:00:00 2001 From: LucientZ Date: Fri, 14 Jun 2024 16:13:15 -0500 Subject: [PATCH 043/198] Simplified getUserForMemberList type --- src/api/firebaseUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/firebaseUtils.ts b/src/api/firebaseUtils.ts index 8afe35e5..91cdf80d 100644 --- a/src/api/firebaseUtils.ts +++ b/src/api/firebaseUtils.ts @@ -158,7 +158,7 @@ type FetchMembersOptions = { filter: UserFilter, }; -export const getUserForMemberList = async (options: FetchMembersOptions): Promise<{ members: QueryDocumentSnapshot[]; lastSnapshot: QueryDocumentSnapshot; hasMoreUser: boolean; } | { members: never[]; lastSnapshot: null; hasMoreUser: boolean; }> => { +export const getUserForMemberList = async (options: FetchMembersOptions): Promise<{ members: QueryDocumentSnapshot[] | never[]; lastSnapshot: QueryDocumentSnapshot | null; hasMoreUser: boolean; }> => { const { lastUserSnapshot, numLimit = null, From 461a0c29b93920b9c97a28a7b13eaa4fbc809605 Mon Sep 17 00:00:00 2001 From: LucientZ Date: Fri, 14 Jun 2024 23:21:51 -0500 Subject: [PATCH 044/198] Added guards to setCommitteeData --- src/api/firebaseUtils.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/api/firebaseUtils.ts b/src/api/firebaseUtils.ts index 91cdf80d..af1d61cb 100644 --- a/src/api/firebaseUtils.ts +++ b/src/api/firebaseUtils.ts @@ -350,6 +350,14 @@ export const getCommittee = async (firebaseDocName: string): Promise { + if (committeeData.head && (await getPublicUserData(committeeData.head)) === undefined) { + throw new Error("Bad Head UID", { cause: `Invalid head UID: ${committeeData.head}. This user likely does not exist.` }); + } + + if (!committeeData.firebaseDocName) { + throw new Error("Bad Document Name", { cause: `Invalid firebaseDocName passed: '${committeeData.firebaseDocName}'. Name is falsy` }); + } + try { await setDoc(doc(db, `committees/${committeeData.firebaseDocName}`), { name: committeeData.name || "", From 4672cf5c36123150696d08a8c7d77eb2ef30d544 Mon Sep 17 00:00:00 2001 From: LucientZ Date: Fri, 14 Jun 2024 23:22:19 -0500 Subject: [PATCH 045/198] Added test user information json --- src/api/__tests__/test_data/users.json | 98 ++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/api/__tests__/test_data/users.json diff --git a/src/api/__tests__/test_data/users.json b/src/api/__tests__/test_data/users.json new file mode 100644 index 00000000..b6576ddb --- /dev/null +++ b/src/api/__tests__/test_data/users.json @@ -0,0 +1,98 @@ +[ + { + "publicInfo": { + "uid": "TESTUSER1", + "classYear": "2025", + "major": "CPSC", + "roles": { + "admin": true, + "secretary": false + } + }, + "private": { + "privateInfo": { + "settings": { + "darkMode": false + } + }, + "moderationData": {} + } + }, + { + "publicInfo": { + "uid": "TESTUSER2", + "classYear": "2025", + "major": "CSCE", + "roles": { + "reader": true, + "officer": false, + "admin": false, + "developer": false, + "lead": false, + "representative": false + } + }, + "private": { + "privateInfo": { + "settings": { + "darkMode": false + } + }, + "moderationData": {} + } + }, + { + "publicInfo": { + "uid": "TESTUSER3", + "classYear": "2025", + "major": "CSCE", + "roles": { + "admin": true, + "secretary": false + } + }, + "private": { + "privateInfo": { + "settings": { + "darkMode": false + } + }, + "moderationData": {} + } + }, + { + "publicInfo": { + "uid": "TESTUSER4", + "classYear": "2025", + "major": "CSCE", + "roles": { + "admin": true, + "secretary": false, + "officer": true + } + }, + "private": { + "privateInfo": { + "settings": { + "darkMode": false + } + }, + "moderationData": {} + } + }, + { + "publicInfo": { + "uid": "MALFORMEDUSER", + "classYear": null, + "major": 5, + "tomato": "sauce" + }, + "private": { + "privateInfo": { + "settings": { + "bananas": 80 + } + } + } + } +] \ No newline at end of file From 9e31511be7f8806c7f656ae16bee520a87b5fe8a Mon Sep 17 00:00:00 2001 From: LucientZ Date: Fri, 14 Jun 2024 23:22:37 -0500 Subject: [PATCH 046/198] Added committee data tests --- src/__mocks__/@firebase/auth.ts | 7 ++- src/api/__tests__/firebaseUtils.test.ts | 58 ++++++++++++++++--- .../__tests__/firebaseUtilsEdgeCases.test.ts | 46 ++++++++++++++- 3 files changed, 100 insertions(+), 11 deletions(-) diff --git a/src/__mocks__/@firebase/auth.ts b/src/__mocks__/@firebase/auth.ts index ce09c729..40fc9299 100644 --- a/src/__mocks__/@firebase/auth.ts +++ b/src/__mocks__/@firebase/auth.ts @@ -103,7 +103,10 @@ class MockUser implements User { signInProvider: null, signInSecondFactor: null, token: this.refreshToken, - claims: {} + claims: { + admin: true, + officer: true, + } } } @@ -139,7 +142,7 @@ class MockUser implements User { this.phoneNumber = null; this.photoURL = null; this.providerId = "TEST"; - this.uid = "ABCD1234"; + this.uid = "CurrentUser"; } } diff --git a/src/api/__tests__/firebaseUtils.test.ts b/src/api/__tests__/firebaseUtils.test.ts index 1b781587..b1495aa7 100644 --- a/src/api/__tests__/firebaseUtils.test.ts +++ b/src/api/__tests__/firebaseUtils.test.ts @@ -1,13 +1,14 @@ -import { signInAnonymously } from "firebase/auth"; -import { getPrivateUserData, getPublicUserData, getUser, getUserByEmail, initializeCurrentUserData, setPrivateUserData, setPublicUserData } from "../firebaseUtils"; +import { signInAnonymously, signOut } from "firebase/auth"; +import { deleteCommittee, getCommittee, getPrivateUserData, getPublicUserData, getUser, getUserByEmail, getUserForMemberList, initializeCurrentUserData, setCommitteeData, setPrivateUserData, setPublicUserData } from "../firebaseUtils"; import { auth, db, storage } from "../../config/firebaseConfig"; import { PrivateUserInfo, PublicUserInfo, User } from "../../types/user"; import { validateTamuEmail } from "../../helpers"; import { doc, setDoc } from "firebase/firestore"; +import { Committee } from "../../types/committees"; -beforeAll(async () => { - await signInAnonymously(auth); +const testUserDataList: User[] = require("./test_data/users.json"); +beforeAll(async () => { // Check testing environment expect(process.env.FIREBASE_EMULATOR_ADDRESS).toBeDefined(); expect(process.env.FIREBASE_AUTH_PORT).toBeDefined(); @@ -18,12 +19,25 @@ beforeAll(async () => { expect(Number(process.env.FIREBASE_CLOUD_FUNCTIONS_PORT)).not.toBeNaN(); expect(Number(process.env.FIREBASE_FIRESTORE_PORT)).not.toBeNaN(); expect(Number(process.env.FIREBASE_STORAGE_PORT)).not.toBeNaN(); + + await signInAnonymously(auth); + + // Create fake user data + for (const user of testUserDataList) { + await setDoc(doc(db, "users", user.publicInfo!.uid!), user.publicInfo); + await setDoc(doc(db, `users/${user.publicInfo!.uid!}/private`, "privateInfo"), user.private?.privateInfo); + } +}); + +afterAll(async () => { + await signOut(auth); }); describe("User Info", () => { test("Initializes correctly and can be modified", async () => { expect((await getUserByEmail(auth.currentUser?.email!))).toBeNull(); + // Creaate user data and ensure it initializes const user = await initializeCurrentUserData(); expect(user).toBeDefined(); @@ -55,7 +69,8 @@ describe("User Info", () => { expect((await getUserByEmail(auth.currentUser?.email!))).toBeNull(); - setPublicUserData({ + // Modify user data and re-fetch data + await setPublicUserData({ email: auth.currentUser?.email!, isEmailPublic: true, }); @@ -70,7 +85,6 @@ describe("User Info", () => { const emailUserData = await getUserByEmail(auth.currentUser?.email!); expect(emailUserData).not.toBeFalsy(); - console.debug(emailUserData?.userData); expect(emailUserData).toMatchObject({ userData: { ...publicUserData, @@ -79,7 +93,6 @@ describe("User Info", () => { }, userUID: auth.currentUser?.uid }); - }, 10000); test("Can be seen by other users", async () => { @@ -96,3 +109,34 @@ describe("User Info", () => { }); }); +describe("Committee Info", () => { + test("Can be created and queried", async () => { + const committeeData: Committee = { + name: "Test Committee", + color: "#FF0000", + description: "Test Description", + head: "TESTUSER1", + firebaseDocName: "QUERYINGCOMMITTEE", + } + + expect(await setCommitteeData(committeeData)).toBe(true); + + const obtainedCommitteeData = await getCommittee(committeeData.firebaseDocName!); + expect(obtainedCommitteeData).toMatchObject(committeeData); + }); + + test("Can be deleted", async () => { + const committeeData: Committee = { + head: "TESTUSER1", + firebaseDocName: "DELETINGCOMMITTEE", + name: "Bad Committee", + } + + await setCommitteeData(committeeData); + expect((await getCommittee(committeeData.firebaseDocName!))).not.toBeNull(); + + await deleteCommittee(committeeData.name!); + expect((await getCommittee(committeeData.name!))).toBeNull(); + }); + +}); \ No newline at end of file diff --git a/src/api/__tests__/firebaseUtilsEdgeCases.test.ts b/src/api/__tests__/firebaseUtilsEdgeCases.test.ts index 4a997ab1..c3fd545e 100644 --- a/src/api/__tests__/firebaseUtilsEdgeCases.test.ts +++ b/src/api/__tests__/firebaseUtilsEdgeCases.test.ts @@ -1,6 +1,28 @@ -import { auth } from "../../config/firebaseConfig"; -import { getPrivateUserData, getPublicUserData, setPrivateUserData, setPublicUserData } from "../firebaseUtils"; +import { doc, setDoc } from "firebase/firestore"; +import { auth, db } from "../../config/firebaseConfig"; +import { getPrivateUserData, getPublicUserData, setCommitteeData, setPrivateUserData, setPublicUserData } from "../firebaseUtils"; +import { User } from "../../types/user"; +const testUserDataList: User[] = require("./test_data/users.json"); + +beforeAll(async () => { + // Check testing environment + expect(process.env.FIREBASE_EMULATOR_ADDRESS).toBeDefined(); + expect(process.env.FIREBASE_AUTH_PORT).toBeDefined(); + expect(process.env.FIREBASE_CLOUD_FUNCTIONS_PORT).toBeDefined(); + expect(process.env.FIREBASE_FIRESTORE_PORT).toBeDefined(); + expect(process.env.FIREBASE_STORAGE_PORT).toBeDefined(); + expect(Number(process.env.FIREBASE_AUTH_PORT)).not.toBeNaN(); + expect(Number(process.env.FIREBASE_CLOUD_FUNCTIONS_PORT)).not.toBeNaN(); + expect(Number(process.env.FIREBASE_FIRESTORE_PORT)).not.toBeNaN(); + expect(Number(process.env.FIREBASE_STORAGE_PORT)).not.toBeNaN(); + + // Create fake user data + for (const user of testUserDataList) { + await setDoc(doc(db, "users", user.publicInfo!.uid!), user.publicInfo); + await setDoc(doc(db, `users/${user.publicInfo!.uid!}/private`, "privateInfo"), user.private?.privateInfo); + } +}); test("Getter functions throw errors when user is unauthenticated", async () => { expect(auth.currentUser).toBeNull(); @@ -14,4 +36,24 @@ test("Setter functions throw errors when user is unauthenticated", async () => { await expect(setPublicUserData({})).rejects.toThrow(); await expect(setPrivateUserData({})).rejects.toThrow(); +}); + +describe("Committee Data", () => { + test("Throws with invalid head id", async () => { + await expect(setCommitteeData({ + head: "NOTREAL0987654321", + firebaseDocName: "cool-committee" + })).rejects.toThrow(); + }); + + test("Throws when not given a firebaseDocName", async () => { + await expect(setCommitteeData({ + head: "TESTUSER1", + })).rejects.toThrow(); + + await expect(setCommitteeData({ + head: "TESTUSER1", + firebaseDocName: "", + })).rejects.toThrow(); + }); }); \ No newline at end of file From 372477d341c9671941a795c69eabd3fa60f6246e Mon Sep 17 00:00:00 2001 From: LucientZ Date: Sat, 15 Jun 2024 00:03:13 -0500 Subject: [PATCH 047/198] Added committee reset test --- src/api/__tests__/firebaseUtils.test.ts | 39 +++++++++++++++++++++++-- src/api/__tests__/test_data/users.json | 6 +++- src/api/firebaseUtils.ts | 3 +- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/api/__tests__/firebaseUtils.test.ts b/src/api/__tests__/firebaseUtils.test.ts index b1495aa7..4784ab0b 100644 --- a/src/api/__tests__/firebaseUtils.test.ts +++ b/src/api/__tests__/firebaseUtils.test.ts @@ -1,5 +1,5 @@ import { signInAnonymously, signOut } from "firebase/auth"; -import { deleteCommittee, getCommittee, getPrivateUserData, getPublicUserData, getUser, getUserByEmail, getUserForMemberList, initializeCurrentUserData, setCommitteeData, setPrivateUserData, setPublicUserData } from "../firebaseUtils"; +import { deleteCommittee, getCommittee, getPrivateUserData, getPublicUserData, getUser, getUserByEmail, getUserForMemberList, initializeCurrentUserData, resetCommittee, setCommitteeData, setPrivateUserData, setPublicUserData } from "../firebaseUtils"; import { auth, db, storage } from "../../config/firebaseConfig"; import { PrivateUserInfo, PublicUserInfo, User } from "../../types/user"; import { validateTamuEmail } from "../../helpers"; @@ -41,10 +41,14 @@ describe("User Info", () => { const user = await initializeCurrentUserData(); expect(user).toBeDefined(); + // Initializing again should not modify user + expect((await initializeCurrentUserData())).toMatchObject(user); + const userData = await getUser(auth.currentUser?.uid!); expect(userData).toBeDefined(); expect(user).toMatchObject(userData!); + expect(auth.currentUser?.uid).toBeDefined(); expect(auth.currentUser?.email).toBeDefined(); expect(auth.currentUser?.displayName).toBeDefined(); @@ -130,13 +134,42 @@ describe("Committee Info", () => { head: "TESTUSER1", firebaseDocName: "DELETINGCOMMITTEE", name: "Bad Committee", + memberApplicationLink: "https://www.test.asdf/notreal", + leadApplicationLink: "https://coolwebsite.co.uk" } await setCommitteeData(committeeData); expect((await getCommittee(committeeData.firebaseDocName!))).not.toBeNull(); - await deleteCommittee(committeeData.name!); - expect((await getCommittee(committeeData.name!))).toBeNull(); + await deleteCommittee(committeeData.firebaseDocName!); + expect((await getCommittee(committeeData.firebaseDocName!))).toBeNull(); + }); + + test("Can be reset", async () => { + const committeeData: Committee = { + head: "TESTUSER2", + firebaseDocName: "RESETTINGCOMMITTEE", + name: "Resetting Committee", + memberCount: 2, + } + + // Check that users.json has not been changed + let committeeMemberInfo = await getPublicUserData("TESTUSER1"); + expect(typeof committeeMemberInfo?.committees).toBe("object"); + expect(committeeMemberInfo?.committees!).toContain("QUERYINGCOMMITTEE"); + expect(committeeMemberInfo?.committees!).toContain(committeeData.firebaseDocName); + + await setCommitteeData(committeeData); + await resetCommittee(committeeData.firebaseDocName!); + + const obtainedCommitteeData = await getCommittee(committeeData.firebaseDocName!); + expect(obtainedCommitteeData?.memberCount).toBe(0); + + // Expect user to be removed from reset committee and stay in other committees + committeeMemberInfo = await getPublicUserData("TESTUSER1"); + expect(typeof committeeMemberInfo?.committees).toBe("object"); + expect(committeeMemberInfo?.committees!).toContain("QUERYINGCOMMITTEE"); + expect(committeeMemberInfo?.committees!).not.toContain(committeeData.firebaseDocName); }); }); \ No newline at end of file diff --git a/src/api/__tests__/test_data/users.json b/src/api/__tests__/test_data/users.json index b6576ddb..ace660b8 100644 --- a/src/api/__tests__/test_data/users.json +++ b/src/api/__tests__/test_data/users.json @@ -7,7 +7,11 @@ "roles": { "admin": true, "secretary": false - } + }, + "committees": [ + "QUERYINGCOMMITTEE", + "RESETTINGCOMMITTEE" + ] }, "private": { "privateInfo": { diff --git a/src/api/firebaseUtils.ts b/src/api/firebaseUtils.ts index af1d61cb..2b509909 100644 --- a/src/api/firebaseUtils.ts +++ b/src/api/firebaseUtils.ts @@ -350,7 +350,8 @@ export const getCommittee = async (firebaseDocName: string): Promise { - if (committeeData.head && (await getPublicUserData(committeeData.head)) === undefined) { + const headDocRef = doc(db, "users", committeeData.head ?? ""); + if (committeeData.head && !(await getDoc(headDocRef)).exists()) { throw new Error("Bad Head UID", { cause: `Invalid head UID: ${committeeData.head}. This user likely does not exist.` }); } From abbfc66eb6164cf044f95e7590157a72bc8fbb67 Mon Sep 17 00:00:00 2001 From: LucientZ Date: Sat, 15 Jun 2024 00:07:53 -0500 Subject: [PATCH 048/198] Added test user to committee that's deleted --- src/api/__tests__/test_data/users.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/api/__tests__/test_data/users.json b/src/api/__tests__/test_data/users.json index ace660b8..08490747 100644 --- a/src/api/__tests__/test_data/users.json +++ b/src/api/__tests__/test_data/users.json @@ -10,7 +10,8 @@ }, "committees": [ "QUERYINGCOMMITTEE", - "RESETTINGCOMMITTEE" + "RESETTINGCOMMITTEE", + "DELETINGCOMMITTEE" ] }, "private": { From ad1bdcb80d3bf27d0056398e631b7b71024c341c Mon Sep 17 00:00:00 2001 From: LucientZ Date: Sat, 15 Jun 2024 00:25:08 -0500 Subject: [PATCH 049/198] Removed shaky unit test --- src/helpers/__tests__/unitTestUtils.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/helpers/__tests__/unitTestUtils.test.ts b/src/helpers/__tests__/unitTestUtils.test.ts index 9f3ce500..77157ef9 100644 --- a/src/helpers/__tests__/unitTestUtils.test.ts +++ b/src/helpers/__tests__/unitTestUtils.test.ts @@ -18,7 +18,6 @@ describe("Test random string generation", () => { test("Create strings of invalid lengths with randomStr()", () => { expect(() => unitTestUtils.randomStr(-1)).toThrow(); expect(() => unitTestUtils.randomStr(-255)).toThrow(); - expect(() => unitTestUtils.randomStr(Math.random() * -1 * 255)).toThrow(); }); test("Create random strings from range of lengths with randomStrRange()", () => { From c4be1f0f1f6b7bb38899212b141dc6c82a57cb31 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 15 Jun 2024 13:21:25 -0500 Subject: [PATCH 050/198] add sign in/out out of range feedback --- functions/src/types/events.ts | 2 ++ shpe-app-web/app/types/events.ts | 2 ++ src/api/firebaseUtils.ts | 4 ++++ src/screens/events/EventVerification.tsx | 5 +++++ src/types/events.ts | 6 ++++-- 5 files changed, 17 insertions(+), 2 deletions(-) diff --git a/functions/src/types/events.ts b/functions/src/types/events.ts index 69f5d591..fc097ab4 100644 --- a/functions/src/types/events.ts +++ b/functions/src/types/events.ts @@ -397,6 +397,7 @@ export enum EventLogStatus { EVENT_NOT_FOUND, ALREADY_LOGGED, NOT_A_STUDENT, + OUT_OF_RANGE, ERROR, } @@ -414,6 +415,7 @@ export const getStatusMessage = (status: EventLogStatus): string => { [EventLogStatus.EVENT_NOT_FOUND]: "The event was not found.", [EventLogStatus.ALREADY_LOGGED]: "You have already signed in/out.", [EventLogStatus.NOT_A_STUDENT]: "Only student can sign in/out of events..", + [EventLogStatus.OUT_OF_RANGE]: "You are not close enough to the event to sign in/out.", [EventLogStatus.ERROR]: "An internal error occurred. Please try again.", }; diff --git a/shpe-app-web/app/types/events.ts b/shpe-app-web/app/types/events.ts index a66335d8..8ca86f81 100644 --- a/shpe-app-web/app/types/events.ts +++ b/shpe-app-web/app/types/events.ts @@ -400,6 +400,7 @@ export enum EventLogStatus { EVENT_NOT_FOUND, ALREADY_LOGGED, NOT_A_STUDENT, + OUT_OF_RANGE, ERROR, } @@ -417,6 +418,7 @@ export const getStatusMessage = (status: EventLogStatus): string => { [EventLogStatus.EVENT_NOT_FOUND]: "The event was not found.", [EventLogStatus.ALREADY_LOGGED]: "You have already signed in/out.", [EventLogStatus.NOT_A_STUDENT]: "Only student can sign in/out of events..", + [EventLogStatus.OUT_OF_RANGE]: "You are not close enough to the event to sign in/out.", [EventLogStatus.ERROR]: "An internal error occurred. Please try again.", }; diff --git a/src/api/firebaseUtils.ts b/src/api/firebaseUtils.ts index 82fd4807..0c238279 100644 --- a/src/api/firebaseUtils.ts +++ b/src/api/firebaseUtils.ts @@ -701,6 +701,8 @@ export const signInToEvent = async (eventID: string, uid?: string): Promise = ({ route, haptic: Haptics.NotificationFeedbackType.Error, bgColor: "bg-dark-navy" }, + [EventLogStatus.OUT_OF_RANGE]: { + animation: require("../../../assets/red_x_animation.json"), + haptic: Haptics.NotificationFeedbackType.Error, + bgColor: "bg-dark-navy" + }, // Default case for missing EventLogStatus.EVENT_ONGOING [EventLogStatus.EVENT_ONGOING]: { animation: require("../../../assets/red_x_animation.json"), diff --git a/src/types/events.ts b/src/types/events.ts index 658a3162..53bbb72a 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -396,10 +396,11 @@ export enum EventLogStatus { SUCCESS, EVENT_OVER, EVENT_ONGOING, - EVENT_NOT_STARTED, EVENT_NOT_FOUND, ALREADY_LOGGED, NOT_A_STUDENT, + EVENT_NOT_STARTED, + OUT_OF_RANGE, ERROR, } @@ -413,10 +414,11 @@ export const getStatusMessage = (status: EventLogStatus): string => { [EventLogStatus.SUCCESS]: "Successfully signed in/out.", [EventLogStatus.EVENT_OVER]: "The event is already over.", [EventLogStatus.EVENT_ONGOING]: "The event is ongoing.", - [EventLogStatus.EVENT_NOT_STARTED]: "The event has not started yet.", [EventLogStatus.EVENT_NOT_FOUND]: "The event was not found.", [EventLogStatus.ALREADY_LOGGED]: "You have already signed in/out.", [EventLogStatus.NOT_A_STUDENT]: "Only student can sign in/out of events..", + [EventLogStatus.EVENT_NOT_STARTED]: "The event has not started yet.", + [EventLogStatus.OUT_OF_RANGE]: "You are not close enough to the event to sign in/out.", [EventLogStatus.ERROR]: "An internal error occurred. Please try again.", }; From 3c6ccacb1543cdf520d8ce2502a75a750266d9ed Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 15 Jun 2024 13:39:42 -0500 Subject: [PATCH 051/198] move settings screens to home screen --- src/navigation/HomeStack.tsx | 28 ++++++++++++++++++++++- src/navigation/UserProfileStack.tsx | 10 +------- src/screens/home/Home.tsx | 9 +++++++- src/screens/userProfile/PublicProfile.tsx | 2 +- src/screens/userProfile/Settings.tsx | 21 ++++++++--------- src/types/navigation.ts | 17 +++++++------- 6 files changed, 56 insertions(+), 31 deletions(-) diff --git a/src/navigation/HomeStack.tsx b/src/navigation/HomeStack.tsx index cb459713..357fd14c 100644 --- a/src/navigation/HomeStack.tsx +++ b/src/navigation/HomeStack.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useContext } from "react"; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { HomeStackParams } from "../types/navigation" import PublicProfileScreen from "../screens/userProfile/PublicProfile"; @@ -19,9 +19,14 @@ import UpdateEvent from "../screens/events/UpdateEvent"; import QRCodeManager from "../screens/events/QRCodeManager"; import MemberSHPE from "../screens/home/MemberSHPE"; import Members from "../screens/home/Members"; +import { AboutSettingsScreen, AccountSettingsScreen, DisplaySettingsScreen, FAQSettingsScreen, FeedBackSettingsScreen, ProfileSettingsScreen, SettingsScreen } from "../screens/userProfile/Settings"; +import { UserContext } from "../context/UserContext"; const HomeStack = () => { const Stack = createNativeStackNavigator(); + const { userInfo } = useContext(UserContext)!; + const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + return ( @@ -53,6 +58,27 @@ const HomeStack = () => { + + {/* Settings Screens */} + + + + + + + + + + ); diff --git a/src/navigation/UserProfileStack.tsx b/src/navigation/UserProfileStack.tsx index 6d511021..d57a88be 100644 --- a/src/navigation/UserProfileStack.tsx +++ b/src/navigation/UserProfileStack.tsx @@ -17,7 +17,6 @@ const UserProfileStack = () => { - {/* Settings Screens */} { backgroundColor: darkMode ? "#2a2a2a" : "#FFF", }, headerTintColor: darkMode ? "#F2F2F2" : "#000", - }} > - - - - - - - + ); }; diff --git a/src/screens/home/Home.tsx b/src/screens/home/Home.tsx index c851714b..57279fe5 100644 --- a/src/screens/home/Home.tsx +++ b/src/screens/home/Home.tsx @@ -2,6 +2,7 @@ import { Image, ScrollView, Text, TouchableOpacity, View } from 'react-native'; import React, { useContext, useEffect } from 'react'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { StatusBar } from 'expo-status-bar'; +import { Octicons } from '@expo/vector-icons'; import manageNotificationPermissions from '../../helpers/pushNotification'; import { HomeStackParams } from "../../types/navigation" import MOTMCard from '../../components/MOTMCard'; @@ -33,13 +34,19 @@ const Home = ({ navigation, route }: NativeStackScreenProps) => {/* Header */} - + + navigation.navigate("SettingsScreen")} + > + + diff --git a/src/screens/userProfile/PublicProfile.tsx b/src/screens/userProfile/PublicProfile.tsx index 4046a153..74c2d2c7 100644 --- a/src/screens/userProfile/PublicProfile.tsx +++ b/src/screens/userProfile/PublicProfile.tsx @@ -231,7 +231,7 @@ const PublicProfileScreen: React.FC = ({ route, naviga {isCurrentUser && navigation.navigate("SettingsScreen")} + onPress={() => navigation.navigate("ProfileSettingsScreen")} className="rounded-md px-3 py-2" style={{ backgroundColor: 'rgba(0,0,0,0.3)' }} > diff --git a/src/screens/userProfile/Settings.tsx b/src/screens/userProfile/Settings.tsx index f3ef2ffc..886a2756 100644 --- a/src/screens/userProfile/Settings.tsx +++ b/src/screens/userProfile/Settings.tsx @@ -14,7 +14,7 @@ import { getBlobFromURI, selectFile, selectImage, uploadFile } from '../../api/f import { CommonMimeTypes, validateDisplayName, validateFileBlob, validateName, validateTamuEmail } from '../../helpers/validation'; import { handleLinkPress } from '../../helpers/links'; import { getBadgeColor, isMemberVerified } from '../../helpers/membership'; -import { UserProfileStackParams } from '../../types/navigation'; +import { HomeStackParams } from '../../types/navigation'; import { Committee } from '../../types/committees'; import { MAJORS, classYears } from '../../types/user'; import { Images } from '../../../assets'; @@ -22,7 +22,6 @@ import DownloadIcon from '../../../assets/arrow-down-solid.svg'; import UploadFileIcon from '../../../assets/file-arrow-up-solid-black.svg'; import { SettingsSectionTitle, SettingsButton, SettingsToggleButton, SettingsListItem, SettingsSaveButton, SettingsModal } from "../../components/SettingsComponents" import CustomDropDown from '../../components/CustomDropDown'; -import TwitterSvg from '../../components/TwitterSvg'; import { Circle, Svg } from 'react-native-svg'; import DismissibleModal from '../../components/DismissibleModal'; import * as Clipboard from 'expo-clipboard'; @@ -30,7 +29,7 @@ import * as Clipboard from 'expo-clipboard'; /** * Settings entrance screen which has a search function and paths to every other settings screen */ -const SettingsScreen = ({ navigation }: NativeStackScreenProps) => { +const SettingsScreen = ({ navigation }: NativeStackScreenProps) => { const { userInfo, signOutUser } = useContext(UserContext)!; const { name, roles, photoURL, chapterExpiration, nationalExpiration } = userInfo?.publicInfo ?? {}; @@ -56,7 +55,7 @@ const SettingsScreen = ({ navigation }: NativeStackScreenProps - + {/* navigation.navigate("ProfileSettingsScreen")} > @@ -78,7 +77,7 @@ const SettingsScreen = ({ navigation }: NativeStackScreenProps - + */} ) => { +const ProfileSettingsScreen = ({ navigation }: NativeStackScreenProps) => { const { userInfo, setUserInfo } = useContext(UserContext)!; const [loading, setLoading] = useState(false); const [image, setImage] = useState(null); @@ -641,7 +640,7 @@ const ProfileSettingsScreen = ({ navigation }: NativeStackScreenProps) => { +const DisplaySettingsScreen = ({ navigation }: NativeStackScreenProps) => { const { userInfo, setUserInfo } = useContext(UserContext)!; const [loading, setLoading] = useState(false); const [darkModeToggled, setDarkModeToggled] = useState(userInfo?.private?.privateInfo?.settings?.darkMode ?? false); @@ -700,7 +699,7 @@ const DisplaySettingsScreen = ({ navigation }: NativeStackScreenProps) => { +const AccountSettingsScreen = ({ navigation }: NativeStackScreenProps) => { const { userInfo, setUserInfo, signOutUser } = useContext(UserContext)!; const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; const deleteConfirmationText = "DELETECONFIRM"; @@ -838,7 +837,7 @@ const AccountSettingsScreen = ({ navigation }: NativeStackScreenProps) => { +const FeedBackSettingsScreen = ({ navigation }: NativeStackScreenProps) => { const [feedback, setFeedback] = useState(''); const { userInfo } = useContext(UserContext)!; @@ -879,7 +878,7 @@ const FeedBackSettingsScreen = ({ navigation }: NativeStackScreenProps) => { +const FAQSettingsScreen = ({ navigation }: NativeStackScreenProps) => { const [activeQuestion, setActiveQuestion] = useState(null); const { userInfo } = useContext(UserContext)!; @@ -961,7 +960,7 @@ const FAQSettingsScreen = ({ navigation }: NativeStackScreenProps) => { +const AboutSettingsScreen = ({ navigation }: NativeStackScreenProps) => { const pkg: any = require("../../../package.json"); const { userInfo } = useContext(UserContext)!; const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; diff --git a/src/types/navigation.ts b/src/types/navigation.ts index bfbaadd0..9f00c50b 100644 --- a/src/types/navigation.ts +++ b/src/types/navigation.ts @@ -58,6 +58,15 @@ export type HomeStackParams = { ShirtConfirm: undefined; InstagramPoints: undefined; + // Settings Screens + SettingsScreen: undefined; + ProfileSettingsScreen: undefined; + DisplaySettingsScreen: undefined; + AccountSettingsScreen: undefined; + FeedbackSettingsScreen: undefined; + FAQSettingsScreen: undefined; + AboutSettingsScreen: undefined; + PublicProfile: { uid: string; }; } @@ -104,15 +113,7 @@ export type CommitteesStackParams = { export type UserProfileStackParams = { PublicProfile: { uid: string; } PersonalEventLogScreen: undefined; - - // Settings Screens - SettingsScreen: undefined; ProfileSettingsScreen: undefined; - DisplaySettingsScreen: undefined; - AccountSettingsScreen: undefined; - FeedbackSettingsScreen: undefined; - FAQSettingsScreen: undefined; - AboutSettingsScreen: undefined; }; From 9e3e455d414fd502b905087091c9587692da872f Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 15 Jun 2024 13:41:01 -0500 Subject: [PATCH 052/198] delete comment --- src/screens/userProfile/Settings.tsx | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/src/screens/userProfile/Settings.tsx b/src/screens/userProfile/Settings.tsx index 886a2756..53e10f4d 100644 --- a/src/screens/userProfile/Settings.tsx +++ b/src/screens/userProfile/Settings.tsx @@ -55,30 +55,6 @@ const SettingsScreen = ({ navigation }: NativeStackScreenProps) > - {/* - navigation.navigate("ProfileSettingsScreen")} - > - - - - - - {name} - {(isOfficer || isVerified) && } - - - Edit Profile - - - - - */} - Date: Sat, 15 Jun 2024 13:52:46 -0500 Subject: [PATCH 053/198] filter out dup event in ishpe --- src/screens/home/Ishpe.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/screens/home/Ishpe.tsx b/src/screens/home/Ishpe.tsx index 896ed2fe..c7ebe435 100644 --- a/src/screens/home/Ishpe.tsx +++ b/src/screens/home/Ishpe.tsx @@ -42,16 +42,24 @@ const Ishpe: React.FC = ({ navigation }) => { getInterestsEvent(userInterests) as Promise ]); - // Merge and sort events by start date in ascending order - const mergedEvents = [...ishpeResponse, ...interestResponse]; + const eventIdSet = new Set(); + + const mergedEvents = [...ishpeResponse, ...interestResponse].filter(event => { + if (event.id && eventIdSet.has(event.id)) { + return false; + } else if (event.id) { + eventIdSet.add(event.id); + return true; + } else { + return false; + } + }); mergedEvents.sort((a, b) => (a.startTime?.toDate().getTime() || 0) - (b.startTime?.toDate().getTime() || 0)); setIshpeEvents(mergedEvents); - // Fetch generalEvents and filter out ishpeEvents and interestEvents const generalResponse = await getUpcomingEvents(); - const filteredGeneralEvents = generalResponse.filter(generalEvent => generalEvent.general === true); - setGeneralEvents(filteredGeneralEvents); + setGeneralEvents(generalResponse); } catch (error) { console.error("Error retrieving events:", error); From 75e25a135207d6cf6a7dcbff90ced8ccbd8aa081 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 15 Jun 2024 14:17:12 -0500 Subject: [PATCH 054/198] add membershpe screen to user profile --- src/navigation/UserProfileStack.tsx | 4 +++- src/screens/userProfile/PublicProfile.tsx | 15 +++++++++++++-- src/types/navigation.ts | 1 + 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/navigation/UserProfileStack.tsx b/src/navigation/UserProfileStack.tsx index d57a88be..274ae7dc 100644 --- a/src/navigation/UserProfileStack.tsx +++ b/src/navigation/UserProfileStack.tsx @@ -6,6 +6,7 @@ import { UserContext } from "../context/UserContext"; import { UserProfileStackParams } from "../types/navigation"; import { auth } from "../config/firebaseConfig"; import PersonalEventLog from "../screens/userProfile/PersonalEventLog"; +import MemberSHPE from "../screens/home/MemberSHPE"; const UserProfileStack = () => { const Stack = createNativeStackNavigator(); @@ -16,6 +17,7 @@ const UserProfileStack = () => { + { > - + ); }; diff --git a/src/screens/userProfile/PublicProfile.tsx b/src/screens/userProfile/PublicProfile.tsx index 74c2d2c7..331e9f9b 100644 --- a/src/screens/userProfile/PublicProfile.tsx +++ b/src/screens/userProfile/PublicProfile.tsx @@ -251,8 +251,6 @@ const PublicProfileScreen: React.FC = ({ route, naviga {name ?? "Name"} - {(isOfficer || isVerified) && } - @@ -275,6 +273,19 @@ const PublicProfileScreen: React.FC = ({ route, naviga } + {(isVerified && isCurrentUser) && + + + navigation.navigate("MemberSHPE")} + className="rounded-md px-3 py-2" + style={{ backgroundColor: 'rgba(0,0,0,0.3)', marginRight: 10 }} + > + MemberSHPE + + + + } diff --git a/src/types/navigation.ts b/src/types/navigation.ts index 9f00c50b..d60c04ea 100644 --- a/src/types/navigation.ts +++ b/src/types/navigation.ts @@ -114,6 +114,7 @@ export type UserProfileStackParams = { PublicProfile: { uid: string; } PersonalEventLogScreen: undefined; ProfileSettingsScreen: undefined; + MemberSHPE: undefined; }; From 4d850714ee1e40b6e2395f3d038c873586f424cc Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 15 Jun 2024 14:20:18 -0500 Subject: [PATCH 055/198] fix boolean for knock on wall --- src/screens/home/OfficeHours.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/screens/home/OfficeHours.tsx b/src/screens/home/OfficeHours.tsx index 9c7dd487..7ca8034a 100644 --- a/src/screens/home/OfficeHours.tsx +++ b/src/screens/home/OfficeHours.tsx @@ -63,7 +63,7 @@ const OfficeHours = () => { { - if (!userInfo?.publicInfo?.isStudent && officeCount > 0) { + if (userInfo?.publicInfo?.isStudent && officeCount > 0) { setConfirmVisible(!confirmVisible) } else { alert("You must be a student to knock on the wall.") From 2a460e2e1569b03e1d1bd73b6cea708272a626d6 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 15 Jun 2024 20:15:52 -0500 Subject: [PATCH 056/198] replace on-going event with today event, add qrcode btn to event card, add unique filtering --- src/components/EventsList.tsx | 18 +++- src/screens/events/Events.tsx | 176 +++++++++++----------------------- 2 files changed, 71 insertions(+), 123 deletions(-) diff --git a/src/components/EventsList.tsx b/src/components/EventsList.tsx index fc78dd87..4f0375d8 100644 --- a/src/components/EventsList.tsx +++ b/src/components/EventsList.tsx @@ -1,10 +1,11 @@ import { View, Text, ActivityIndicator, Image, TouchableOpacity } from 'react-native' -import React from 'react' +import React, { useContext } from 'react' import { Timestamp } from 'firebase/firestore'; import { Committee, getLogoComponent } from '../types/committees'; import { SHPEEvent } from '../types/events'; import { Images } from '../../assets'; import { monthNames } from '../helpers/timeUtils'; +import { UserContext } from '../context/UserContext'; const EventsList = ({ events, navigation, isLoading, showImage = true, onEventClick }: { events: SHPEEventWithCommitteeData[], @@ -13,6 +14,12 @@ const EventsList = ({ events, navigation, isLoading, showImage = true, onEventCl showImage?: boolean onEventClick?: () => void }) => { + const userContext = useContext(UserContext); + const { userInfo } = userContext!; + + + const hasPrivileges = (userInfo?.publicInfo?.roles?.admin?.valueOf() || userInfo?.publicInfo?.roles?.officer?.valueOf() || userInfo?.publicInfo?.roles?.developer?.valueOf()); + if (isLoading) { return ( @@ -91,6 +98,15 @@ const EventsList = ({ events, navigation, isLoading, showImage = true, onEventCl {formatStartTime(event?.startTime!)} + + {hasPrivileges && ( + { navigation.navigate("QRCode", { event: event }) }} + className='bg-gray-600 absolute right-0 top-0 p-2' + > + QRCode + + )} ); })} diff --git a/src/screens/events/Events.tsx b/src/screens/events/Events.tsx index 0e23c192..47bd561a 100644 --- a/src/screens/events/Events.tsx +++ b/src/screens/events/Events.tsx @@ -1,6 +1,6 @@ import { View, Text, TouchableOpacity, ActivityIndicator, ScrollView, Modal } from 'react-native' import React, { useCallback, useContext, useState } from 'react' -import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { useFocusEffect } from '@react-navigation/native'; import { Octicons } from '@expo/vector-icons'; @@ -13,20 +13,18 @@ import EventsList from '../../components/EventsList'; const Events = ({ navigation }: NativeStackScreenProps) => { const userContext = useContext(UserContext); const { userInfo } = userContext!; - const [currentEvents, setCurrentEvents] = useState([]); + + const [todayEvents, setTodayEvents] = useState([]); const [upcomingEvents, setUpcomingEvents] = useState([]); const [pastEvents, setPastEvents] = useState([]); - const [allPastEvents, setAllPastEvents] = useState([]); + const [selectedFilter, setSelectedFilter] = useState(null); + const [isLoading, setIsLoading] = useState(true); - const [pastEventModalVisible, setPastEventModalVisible] = useState(false); const [initialFetch, setInitialFetch] = useState(false); - const [selectedFilter, setSelectedFilter] = useState(null); const hasPrivileges = (userInfo?.publicInfo?.roles?.admin?.valueOf() || userInfo?.publicInfo?.roles?.officer?.valueOf() || userInfo?.publicInfo?.roles?.developer?.valueOf()); - const insets = useSafeAreaInsets(); - - const handleFilterSelect = (filter: EventType) => { + const handleFilterSelect = (filter: ExtendedEventType) => { if (selectedFilter === filter) { setSelectedFilter(null); } else { @@ -36,11 +34,21 @@ const Events = ({ navigation }: NativeStackScreenProps) => { const filteredEvents = (events: SHPEEvent[]) => { if (!selectedFilter) { - // By default, hide committee meetings because they may increase clutter - return events.filter(event => event.eventType !== EventType.COMMITTEE_MEETING && (event.hiddenEvent !== true)); + // By default, hide committee meetings unless they are labeled as general + return events.filter(event => (event.eventType !== EventType.COMMITTEE_MEETING || event.general) && (event.hiddenEvent !== true)); + } + if (selectedFilter === 'myEvents') { + return events.filter(event => + userInfo?.publicInfo?.committees?.includes(event.committee || '') || + userInfo?.publicInfo?.interests?.includes(event.eventType || '') + ); + } + if (selectedFilter === 'clubWide') { + return events.filter(event => event.general); } return events.filter(event => event.eventType === selectedFilter && (event.hiddenEvent !== true)); }; + useFocusEffect( useCallback(() => { const fetchEvents = async () => { @@ -48,30 +56,23 @@ const Events = ({ navigation }: NativeStackScreenProps) => { setIsLoading(true); const upcomingEventsData = await getUpcomingEvents(); - const pastEventsData = await getPastEvents(8); + const pastEventsData = await getPastEvents(5); - // Filter to separate current and upcoming events const currentTime = new Date(); - const currentEvents = upcomingEventsData.filter(event => { + const today = new Date(currentTime.getFullYear(), currentTime.getMonth(), currentTime.getDate()); + + const todayEvents = upcomingEventsData.filter(event => { const startTime = event.startTime ? event.startTime.toDate() : new Date(0); - const endTime = event.endTime ? event.endTime.toDate() : new Date(0); - return startTime <= currentTime && endTime >= currentTime; + return startTime >= today && startTime < new Date(today.getTime() + 24 * 60 * 60 * 1000); }); - const trueUpcomingEvents = upcomingEventsData.filter(event => { + const upcomingEvents = upcomingEventsData.filter(event => { const startTime = event.startTime ? event.startTime.toDate() : new Date(0); - return startTime > currentTime; + return startTime >= new Date(today.getTime() + 24 * 60 * 60 * 1000); }); - if (trueUpcomingEvents) { - setUpcomingEvents(trueUpcomingEvents); - } - - if (pastEventsData) { - setPastEvents(pastEventsData); - } - - // Assuming you have a state setter for current events - setCurrentEvents(currentEvents); + setTodayEvents(todayEvents); + setUpcomingEvents(upcomingEvents); + setPastEvents(pastEventsData); setIsLoading(false); } catch (error) { @@ -80,8 +81,6 @@ const Events = ({ navigation }: NativeStackScreenProps) => { } }; - // Only fetch events if initial fetch has not been done or if user has privileges - // A user with privileges will need to see the event they just created/edited if (!initialFetch || hasPrivileges) { fetchEvents(); setInitialFetch(true); @@ -98,21 +97,30 @@ const Events = ({ navigation }: NativeStackScreenProps) => { - {/* - {userInfo?.publicInfo?.isStudent && ( + {/* Filters */} + + + {selectedFilter && ( + setSelectedFilter(null)} + > + Reset + + )} navigation.navigate("QRCodeScanningScreen")} + className={`flex-row items-center justify-center border rounded-md py-2 px-4 mx-2 mb-2 ${selectedFilter === "myEvents" ? 'bg-pale-blue' : 'border-pale-blue'}`} + onPress={() => handleFilterSelect("myEvents")} > - - QRCode Scan + My Events - )} - */} - {/* Filters */} - - + handleFilterSelect("clubWide")} + > + Club Wide + {Object.values(EventType).map((type) => ( ) => { {type} ))} - setSelectedFilter(null)} - > - Reset Filter - @@ -140,17 +142,17 @@ const Events = ({ navigation }: NativeStackScreenProps) => { {/* Event Listings */} {!isLoading && ( - {filteredEvents(currentEvents).length === 0 && filteredEvents(upcomingEvents).length === 0 && filteredEvents(pastEvents).length === 0 ? ( + {filteredEvents(todayEvents).length === 0 && filteredEvents(upcomingEvents).length === 0 && filteredEvents(pastEvents).length === 0 ? ( No Events ) : ( - {filteredEvents(currentEvents).length !== 0 && ( + {filteredEvents(todayEvents).length !== 0 && ( - On-Going Events + Today Events @@ -180,28 +182,6 @@ const Events = ({ navigation }: NativeStackScreenProps) => { )} - {/* {(!isLoading && pastEvents.length >= 5) && - { - setPastEventModalVisible(true) - - if (initialPastFetch) { - return; - } - - setIsLoading(true); - const allPastEventsData = await getPastEvents(); - if (allPastEventsData) { - setAllPastEvents(allPastEventsData); - setIsLoading(false); - setInitialPastFetch(true); - } - }}> - View All Past Events - - } */} - @@ -213,58 +193,10 @@ const Events = ({ navigation }: NativeStackScreenProps) => { )} - - { - setPastEventModalVisible(false); - }} - > - - - - - All Past Events - - setPastEventModalVisible(false)} - > - - - - - - - {isLoading && - - - - } - - {(allPastEvents.length == 0 && !isLoading) && - - No Events - - } - - {(!isLoading && allPastEvents.length != 0) && - setPastEventModalVisible(false)} - /> - } - - - - ); }; +type ExtendedEventType = EventType | 'myEvents' | 'clubWide'; + export default Events; From 16c56649e1a22d742f8ac1c4a5cb5da9cef8272b Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 16 Jun 2024 14:07:29 -0500 Subject: [PATCH 057/198] fix warning for navigation --- src/navigation/MainStack.tsx | 36 ++++++++++++++---------------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/src/navigation/MainStack.tsx b/src/navigation/MainStack.tsx index ea0307ad..18ef5735 100644 --- a/src/navigation/MainStack.tsx +++ b/src/navigation/MainStack.tsx @@ -34,11 +34,11 @@ const HomeBottomTabs = () => { const BottomTabs = createBottomTabNavigator(); const TAB_ICON_CONFIG: Record = { - Home: 'home', - ResourcesStack: 'rows', - Events: "calendar", - Committees: 'stack', - Profile: 'person', + HomeTab: 'home', + ResourcesTab: 'rows', + EventsTab: "calendar", + CommitteesTab: 'stack', + ProfileTab: 'person', }; const activeIconColor = 'black'; @@ -46,7 +46,7 @@ const HomeBottomTabs = () => { const iconSize = 28; const generateTabIcon = (routeName: TabName, focused: boolean): JSX.Element => { - if (routeName == 'Profile') { + if (routeName == 'ProfileTab') { return ( { const iconName = TAB_ICON_CONFIG[routeName] || 'x-circle'; const iconColor = focused ? activeIconColor : inactiveIconColor; - let tabName: string = routeName; - if (tabName === 'ResourcesStack') { - tabName = 'Resources'; - } + let tabName: string = routeName.replace('Tab', ''); return ( @@ -72,10 +69,8 @@ const HomeBottomTabs = () => { ); }; - return ( - + ({ tabBarIcon: ({ focused }) => generateTabIcon(route.name as TabName, focused), @@ -85,20 +80,17 @@ const HomeBottomTabs = () => { tabBarShowLabel: false, })} > - - - - - + + + + + ); }; - - type OcticonIconName = React.ComponentProps['name']; -type TabName = 'Home' | 'ResourcesStack' | 'Committees' | 'Profile' | 'Events'; - +type TabName = 'HomeTab' | 'ResourcesTab' | 'CommitteesTab' | 'ProfileTab' | 'EventsTab'; export { MainStack }; From fa56e384b222f11375281e44e0e0aef1a139ced6 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 16 Jun 2024 14:08:03 -0500 Subject: [PATCH 058/198] temp remove user expiration --- src/navigation/index.tsx | 100 +++++++++++++++++++-------------------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/src/navigation/index.tsx b/src/navigation/index.tsx index ad7c222a..35b991ce 100644 --- a/src/navigation/index.tsx +++ b/src/navigation/index.tsx @@ -25,56 +25,56 @@ const RootNavigator = () => { * OLD IMPLEMENTATION - checkDataExpiration that is originally created to update a user data if it is expired * however user data is auto updated in home.tsx file. However this can still be used to check inactive users. */ - useEffect(() => { - const checkDataExpiration = async () => { - try { - const now = new Date(); - - // Use the existing userInfo state to check the expiration date - const expirationDateData = userInfo?.private?.privateInfo?.expirationDate; - - let expirationDate; - if (expirationDateData) { - try { - expirationDate = new Timestamp(expirationDateData.seconds, expirationDateData.nanoseconds).toDate(); - } catch (error) { - console.error("Error parsing expiration date:", error); - } - } - - if (!expirationDate || expirationDate < now) { - const newExpirationDate = new Date(); - newExpirationDate.setDate(newExpirationDate.getDate() + 7); - - const updatedPrivateData = { - ...userInfo?.private?.privateInfo, - expirationDate: Timestamp.fromDate(newExpirationDate), - }; - - await setPrivateUserData(updatedPrivateData); - - // Update the local user data instead of re-fetching - const updatedUserInfo = { - ...userInfo, - private: { - ...userInfo?.private, - privateInfo: updatedPrivateData, - } - }; - - // Update AsyncStorage and state - await AsyncStorage.setItem("@user", JSON.stringify(updatedUserInfo)); - setUserInfo(updatedUserInfo); - } - } catch (error) { - console.error("Error in checkDataExpiration:", error); - } - }; - - if (userInfo) { - checkDataExpiration(); - } - }, [userInfo]); + // useEffect(() => { + // const checkDataExpiration = async () => { + // try { + // const now = new Date(); + + // // Use the existing userInfo state to check the expiration date + // const expirationDateData = userInfo?.private?.privateInfo?.expirationDate; + + // let expirationDate; + // if (expirationDateData) { + // try { + // expirationDate = new Timestamp(expirationDateData.seconds, expirationDateData.nanoseconds).toDate(); + // } catch (error) { + // console.error("Error parsing expiration date:", error); + // } + // } + + // if (!expirationDate || expirationDate < now) { + // const newExpirationDate = new Date(); + // newExpirationDate.setDate(newExpirationDate.getDate() + 7); + + // const updatedPrivateData = { + // ...userInfo?.private?.privateInfo, + // expirationDate: Timestamp.fromDate(newExpirationDate), + // }; + + // await setPrivateUserData(updatedPrivateData); + + // // Update the local user data instead of re-fetching + // const updatedUserInfo = { + // ...userInfo, + // private: { + // ...userInfo?.private, + // privateInfo: updatedPrivateData, + // } + // }; + + // // Update AsyncStorage and state + // await AsyncStorage.setItem("@user", JSON.stringify(updatedUserInfo)); + // setUserInfo(updatedUserInfo); + // } + // } catch (error) { + // console.error("Error in checkDataExpiration:", error); + // } + // }; + + // if (userInfo) { + // checkDataExpiration(); + // } + // }, [userInfo]); useEffect(() => { From ef0994d25107fbb2a48e8ea2e16351a6df902682 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 16 Jun 2024 14:15:05 -0500 Subject: [PATCH 059/198] move uploadFile to fix require cycle warning --- src/api/fileSelection.ts | 59 ------------- src/api/firebaseUtils.ts | 85 ++++++++++++++----- src/screens/events/SetGeneralEventDetails.tsx | 3 +- src/screens/events/UpdateEvent.tsx | 4 +- src/screens/home/MemberSHPE.tsx | 4 +- src/screens/onboarding/ProfileSetup.tsx | 4 +- src/screens/resources/ResumeSubmit.tsx | 4 +- src/screens/userProfile/Settings.tsx | 4 +- 8 files changed, 76 insertions(+), 91 deletions(-) diff --git a/src/api/fileSelection.ts b/src/api/fileSelection.ts index 814acd7f..bb54b1b6 100644 --- a/src/api/fileSelection.ts +++ b/src/api/fileSelection.ts @@ -73,62 +73,3 @@ export const getBlobFromURI = async (uri: string): Promise => { return null; }); }; - - -export const uploadFile = async ( - blob: Blob, - validMimeTypes: string[] = [], - storagePath: string, - onSuccess: ((url: string) => Promise) | null = null, - onProgress: ((progress: number) => void) | null = null, - setLoading: ((load: boolean) => void) | null = null -) => { - if (validMimeTypes.length > 0 && !validateFileBlob(blob, validMimeTypes, true)) { - if (setLoading !== null) { - setLoading(false); - } - return; - } - - const uploadTask = uploadFileToFirebase(blob, storagePath); - - uploadTask.on("state_changed", - (snapshot) => { - const progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100; - if (onProgress !== null) { - onProgress(progress); - } - console.log(`Upload is ${progress}% done`); - }, - (error) => { - if (setLoading !== null) { - setLoading(false); - } - switch (error.code) { - case "storage/unauthorized": - alert("File could not be uploaded due to user permissions."); - break; - case "storage/canceled": - alert("File upload cancelled"); - break; - default: - alert("An unknown error has occurred"); - break; - } - }, - async () => { - try { - const URL = await getDownloadURL(uploadTask.snapshot.ref); - if (onSuccess !== null) { - await onSuccess(URL); - } - } catch (error) { - console.error("Error in uploadFile:", error); - } finally { - if (setLoading !== null) { - setLoading(false); - } - } - } - ); -}; diff --git a/src/api/firebaseUtils.ts b/src/api/firebaseUtils.ts index 87525151..115ff572 100644 --- a/src/api/firebaseUtils.ts +++ b/src/api/firebaseUtils.ts @@ -2,7 +2,7 @@ import { auth, db, functions, storage } from "../config/firebaseConfig"; import { ref, uploadBytesResumable, UploadTask, UploadMetadata, listAll, deleteObject, getDownloadURL, uploadBytes } from "firebase/storage"; import { doc, setDoc, getDoc, arrayUnion, collection, where, query, getDocs, orderBy, addDoc, updateDoc, deleteDoc, Timestamp, limit, startAfter, Query, DocumentData, CollectionReference, QueryDocumentSnapshot, increment, runTransaction, deleteField, GeoPoint, writeBatch, DocumentSnapshot } from "firebase/firestore"; import { HttpsCallableResult, httpsCallable } from "firebase/functions"; -import { validateTamuEmail } from "../helpers/validation"; +import { validateFileBlob, validateTamuEmail } from "../helpers/validation"; import { OfficerStatus, PrivateUserInfo, PublicUserInfo, Roles, User, UserFilter } from "../types/user"; import { Committee } from "../types/committees"; import { SHPEEvent, EventLogStatus, UserEventData } from "../types/events"; @@ -314,6 +314,65 @@ export const uploadFileToFirebase = (file: Uint8Array | ArrayBuffer | Blob, path }; +export const uploadFile = async ( + blob: Blob, + validMimeTypes: string[] = [], + storagePath: string, + onSuccess: ((url: string) => Promise) | null = null, + onProgress: ((progress: number) => void) | null = null, + setLoading: ((load: boolean) => void) | null = null +) => { + if (validMimeTypes.length > 0 && !validateFileBlob(blob, validMimeTypes, true)) { + if (setLoading !== null) { + setLoading(false); + } + return; + } + + const uploadTask = uploadFileToFirebase(blob, storagePath); + + uploadTask.on("state_changed", + (snapshot) => { + const progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100; + if (onProgress !== null) { + onProgress(progress); + } + console.log(`Upload is ${progress}% done`); + }, + (error) => { + if (setLoading !== null) { + setLoading(false); + } + switch (error.code) { + case "storage/unauthorized": + alert("File could not be uploaded due to user permissions."); + break; + case "storage/canceled": + alert("File upload cancelled"); + break; + default: + alert("An unknown error has occurred"); + break; + } + }, + async () => { + try { + const URL = await getDownloadURL(uploadTask.snapshot.ref); + if (onSuccess !== null) { + await onSuccess(URL); + } + } catch (error) { + console.error("Error in uploadFile:", error); + } finally { + if (setLoading !== null) { + setLoading(false); + } + } + } + ); +}; + + export const getCommittees = async (): Promise => { try { const committeeCollectionRef = collection(db, 'committees'); @@ -546,26 +605,16 @@ export const getEvent = async (eventID: string): Promise => { } } -type SHPEEventWithCommitteeData = SHPEEvent & { committeeData?: Committee | undefined }; - - export const getUpcomingEvents = async () => { const currentTime = new Date(); const eventsRef = collection(db, "events"); const q = query(eventsRef, where("endTime", ">", currentTime)); const querySnapshot = await getDocs(q); - const events: SHPEEventWithCommitteeData[] = []; + const events: SHPEEvent[] = []; for (const doc of querySnapshot.docs) { const eventData = doc.data(); - let committeeData: Committee | undefined; - - if (eventData.committee) { - committeeData = await getCommittee(eventData.committee) || undefined; - } - - - events.push({ id: doc.id, ...eventData, committeeData: committeeData }); + events.push({ id: doc.id, ...eventData }); } events.sort((a, b) => { @@ -590,17 +639,11 @@ export const getPastEvents = async (numLimit?: number) => { } const querySnapshot = await getDocs(q); - const events: SHPEEventWithCommitteeData[] = []; + const events: SHPEEvent[] = []; for (const doc of querySnapshot.docs) { const eventData = doc.data(); - let committeeData: Committee | undefined; - - if (eventData.committee) { - committeeData = await getCommittee(eventData.committee) || undefined; - } - - events.push({ id: doc.id, ...eventData, committeeData }); + events.push({ id: doc.id, ...eventData }); } // Events are already ordered by endTime due to the query, no need to sort again diff --git a/src/screens/events/SetGeneralEventDetails.tsx b/src/screens/events/SetGeneralEventDetails.tsx index 724540d6..d8c829cf 100644 --- a/src/screens/events/SetGeneralEventDetails.tsx +++ b/src/screens/events/SetGeneralEventDetails.tsx @@ -10,12 +10,13 @@ import { UserContext } from '../../context/UserContext'; import DateTimePicker from '@react-native-community/datetimepicker'; import { CommonMimeTypes, MillisecondTimes, validateFileBlob } from '../../helpers'; import { formatDate, formatTime } from '../../helpers/timeUtils'; -import { getBlobFromURI, selectImage, uploadFile } from '../../api/fileSelection'; +import { getBlobFromURI, selectImage } from '../../api/fileSelection'; import * as ImagePicker from "expo-image-picker"; import { auth } from '../../config/firebaseConfig'; import { UploadTask } from 'firebase/storage'; import ProgressBar from '../../components/ProgressBar'; import { StatusBar } from 'expo-status-bar'; +import { uploadFile } from '../../api/firebaseUtils'; const SetGeneralEventDetails = ({ navigation }: EventProps) => { const route = useRoute(); diff --git a/src/screens/events/UpdateEvent.tsx b/src/screens/events/UpdateEvent.tsx index 4e19ca2f..0e3f8ad7 100644 --- a/src/screens/events/UpdateEvent.tsx +++ b/src/screens/events/UpdateEvent.tsx @@ -4,7 +4,7 @@ import { EventProps, UpdateEventScreenRouteProp } from '../../types/navigation' import { useRoute } from '@react-navigation/core'; import { SafeAreaView } from 'react-native-safe-area-context'; import { CommitteeMeeting, EventType, GeneralMeeting, IntramuralEvent, CustomEvent, SHPEEvent, SocialEvent, StudyHours, VolunteerEvent, Workshop, WorkshopType } from '../../types/events'; -import { destroyEvent, getCommittees, setEvent } from '../../api/firebaseUtils'; +import { destroyEvent, getCommittees, setEvent, uploadFile } from '../../api/firebaseUtils'; import { Octicons } from '@expo/vector-icons'; import DateTimePicker from '@react-native-community/datetimepicker'; import { Images } from '../../../assets'; @@ -17,7 +17,7 @@ import * as ImagePicker from "expo-image-picker"; import { Committee } from '../../types/committees'; import CustomDropDownMenu, { CustomDropDownMethods } from '../../components/CustomDropDown'; import LocationPicker from '../../components/LocationPicker'; -import { getBlobFromURI, selectImage, uploadFile } from '../../api/fileSelection'; +import { getBlobFromURI, selectImage } from '../../api/fileSelection'; import { CommonMimeTypes, validateFileBlob } from '../../helpers'; import { auth } from '../../config/firebaseConfig'; diff --git a/src/screens/home/MemberSHPE.tsx b/src/screens/home/MemberSHPE.tsx index 8205b421..cda2b249 100644 --- a/src/screens/home/MemberSHPE.tsx +++ b/src/screens/home/MemberSHPE.tsx @@ -2,7 +2,7 @@ import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, Alert, Tex import React, { useContext, useEffect, useRef, useState } from 'react' import { UserContext } from '../../context/UserContext'; import { auth, db } from '../../config/firebaseConfig'; -import { getBlobFromURI, selectFile, uploadFile } from '../../api/fileSelection'; +import { getBlobFromURI, selectFile } from '../../api/fileSelection'; import { Timestamp, doc, onSnapshot, setDoc } from "firebase/firestore"; import { CommonMimeTypes } from '../../helpers/validation'; import { handleLinkPress } from '../../helpers/links'; @@ -13,7 +13,7 @@ import { darkMode } from '../../../tailwind.config'; import DismissibleModal from '../../components/DismissibleModal'; import { Pressable } from 'react-native'; import { LinkData } from '../../types/links'; -import { fetchLink } from '../../api/firebaseUtils'; +import { fetchLink, uploadFile } from '../../api/firebaseUtils'; import { SafeAreaView } from 'react-native-safe-area-context'; const linkIDs = ["6", "7"]; // ids reserved for TAMU and SHPE National links diff --git a/src/screens/onboarding/ProfileSetup.tsx b/src/screens/onboarding/ProfileSetup.tsx index de813938..f4c527a5 100644 --- a/src/screens/onboarding/ProfileSetup.tsx +++ b/src/screens/onboarding/ProfileSetup.tsx @@ -8,8 +8,8 @@ import { Octicons } from '@expo/vector-icons'; import { Circle, Svg } from 'react-native-svg'; import { UserContext } from '../../context/UserContext'; import { auth } from '../../config/firebaseConfig'; -import { getUser, setPrivateUserData, setPublicUserData } from '../../api/firebaseUtils'; -import { getBlobFromURI, selectFile, selectImage, uploadFile } from '../../api/fileSelection'; +import { getUser, setPrivateUserData, setPublicUserData, uploadFile } from '../../api/firebaseUtils'; +import { getBlobFromURI, selectFile, selectImage } from '../../api/fileSelection'; import { updateProfile } from 'firebase/auth'; import { CommonMimeTypes, validateName } from '../../helpers/validation'; import { handleLinkPress } from '../../helpers/links'; diff --git a/src/screens/resources/ResumeSubmit.tsx b/src/screens/resources/ResumeSubmit.tsx index 0750d696..63b2ed4a 100644 --- a/src/screens/resources/ResumeSubmit.tsx +++ b/src/screens/resources/ResumeSubmit.tsx @@ -4,8 +4,8 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { Octicons } from '@expo/vector-icons'; import { UserContext } from '../../context/UserContext' import { auth, db } from '../../config/firebaseConfig' -import { setPublicUserData } from '../../api/firebaseUtils' -import { getBlobFromURI, selectFile, uploadFile } from '../../api/fileSelection' +import { setPublicUserData, uploadFile } from '../../api/firebaseUtils' +import { getBlobFromURI, selectFile } from '../../api/fileSelection' import { deleteDoc, deleteField, doc, onSnapshot, setDoc, updateDoc } from 'firebase/firestore' import { CommonMimeTypes } from '../../helpers/validation' import { handleLinkPress } from '../../helpers/links'; diff --git a/src/screens/userProfile/Settings.tsx b/src/screens/userProfile/Settings.tsx index 53e10f4d..67f4ac3a 100644 --- a/src/screens/userProfile/Settings.tsx +++ b/src/screens/userProfile/Settings.tsx @@ -9,8 +9,8 @@ import { Octicons, FontAwesome } from '@expo/vector-icons'; import { UserContext } from '../../context/UserContext'; import { auth } from '../../config/firebaseConfig'; import { sendPasswordResetEmail, updateProfile } from 'firebase/auth'; -import { setPublicUserData, setPrivateUserData, getUser, getCommittees, submitFeedback, isUsernameUnique, deleteAccount } from '../../api/firebaseUtils'; -import { getBlobFromURI, selectFile, selectImage, uploadFile } from '../../api/fileSelection'; +import { setPublicUserData, setPrivateUserData, getUser, getCommittees, submitFeedback, isUsernameUnique, deleteAccount, uploadFile } from '../../api/firebaseUtils'; +import { getBlobFromURI, selectFile, selectImage } from '../../api/fileSelection'; import { CommonMimeTypes, validateDisplayName, validateFileBlob, validateName, validateTamuEmail } from '../../helpers/validation'; import { handleLinkPress } from '../../helpers/links'; import { getBadgeColor, isMemberVerified } from '../../helpers/membership'; From a164efbc3b0f77fac488fcb8931ce74393172e4b Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 16 Jun 2024 15:57:17 -0500 Subject: [PATCH 060/198] add placeholder image of events and new header for home screen --- assets/SHPE_NAVY.png | Bin 0 -> 43682 bytes assets/SHPE_NAVY_Header.png | Bin 0 -> 51251 bytes assets/SHPE_NAVY_Horizontal.png | Bin 0 -> 48487 bytes assets/index.ts | 7 +++++-- src/screens/events/EventInfo.tsx | 2 +- src/screens/events/FinalizeEvent.tsx | 2 +- src/screens/events/UpdateEvent.tsx | 4 ++-- src/screens/home/Home.tsx | 11 ++++++----- 8 files changed, 15 insertions(+), 11 deletions(-) create mode 100644 assets/SHPE_NAVY.png create mode 100644 assets/SHPE_NAVY_Header.png create mode 100644 assets/SHPE_NAVY_Horizontal.png diff --git a/assets/SHPE_NAVY.png b/assets/SHPE_NAVY.png new file mode 100644 index 0000000000000000000000000000000000000000..67e964ff77bfcacbddb8906f8b689fbf0fcdbb6c GIT binary patch literal 43682 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 literal 0 HcmV?d00001 diff --git a/assets/SHPE_NAVY_Header.png b/assets/SHPE_NAVY_Header.png new file mode 100644 index 0000000000000000000000000000000000000000..4262abaad4f942b5419377a5096d68d92e23376c GIT binary patch literal 51251 zcmb@ui93|<`#%1RU6L$~HHxxjP(-LuSrSwB42>-s*~uQJNQ#$d<;w{O(7u_viZ${2WI|WuE1}ulrih>%8XiHM9=Y;1l4MA+&@E-(}ki-XmiSRbkxd0Wk3QdFmFx=8K(1f6(r)*oc zj1cts%jHX&CjN-u+tprH=KbO>=T98vly}7ZezYUXaDnrav%!TWpMwr9{!~@E)cvf} zti_EyGb{`i_WM-nG=(Mk87(@nnmX5PE`?qQl;b=4>{LG~^|S7^QEUlsh)E}h7Mih*RA!}k4*g~ z-^J{%Z~s}_w((Q{_hNhz7a1x?H03&%T}8XmjOO#Y<;c}S0TnwFl_%{~Mn9v|!!|r? zcXm|*vFbbWz2{)5gP##?Aaepm>z%0AEtJ-L1ADkM)|}NkooDm--5BqF?y7&6;oCXE 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 ONJ(Dpb%m^H(EkAxsx1%z literal 0 HcmV?d00001 diff --git a/assets/SHPE_NAVY_Horizontal.png b/assets/SHPE_NAVY_Horizontal.png new file mode 100644 index 0000000000000000000000000000000000000000..3488663969e06ee87e9b753952d0712dc114df08 GIT binary patch literal 48487 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 literal 0 HcmV?d00001 diff --git a/assets/index.ts b/assets/index.ts index 64bf094c..6dd3f11b 100644 --- a/assets/index.ts +++ b/assets/index.ts @@ -1,4 +1,4 @@ -export const Images = { +export const Images = { LOGO_LIGHT: require("./logo_light.png"), DEFAULT_USER_PICTURE: require("./default_user_picture.png"), UPLOAD_ARROW: require("./upload_arrow.png"), @@ -15,11 +15,14 @@ export const Images = { GENEVA: require("./geneva.png"), INSTAGRAM: require("./Instagram-Logo.png"), FLICKER: require("./flicker.png"), - SHPE_LOGO_VERT:require("./SHPE_Logo_Vert.png"), + SHPE_LOGO_VERT: require("./SHPE_Logo_Vert.png"), COMMITTEE_1: require("./committee_1.png"), COMMITTEE_2: require("./committee_2.png"), COMMITTEE_3: require("./committee_3.png"), COMMITTEE_4: require("./committee_4.png"), COMMITTEE: require("./committee.png"), EVENT: require("./event.png"), + SHPE_NAVY: require("./SHPE_NAVY.png"), + SHPE_NAVY_HORIZ: require("./SHPE_NAVY_Horizontal.png"), + SHPE_NAVY_HEADER: require("./SHPE_NAVY_Header.png"), }; diff --git a/src/screens/events/EventInfo.tsx b/src/screens/events/EventInfo.tsx index 13faaeaa..aed30942 100644 --- a/src/screens/events/EventInfo.tsx +++ b/src/screens/events/EventInfo.tsx @@ -132,7 +132,7 @@ const EventInfo: React.FC = ({ route, navigation }) => { { { > ) => {/* Header */} - - - + + + Date: Sun, 16 Jun 2024 15:58:14 -0500 Subject: [PATCH 061/198] implement event screen ui --- src/components/EventsList.tsx | 65 +++------- src/helpers/timeUtils.ts | 9 +- src/screens/events/EventCard.tsx | 59 +++++++++ src/screens/events/Events.tsx | 208 ++++++++++++++++++++++++------- tailwind.config.js | 46 +++---- 5 files changed, 267 insertions(+), 120 deletions(-) create mode 100644 src/screens/events/EventCard.tsx diff --git a/src/components/EventsList.tsx b/src/components/EventsList.tsx index 4f0375d8..9cba2bc6 100644 --- a/src/components/EventsList.tsx +++ b/src/components/EventsList.tsx @@ -8,7 +8,7 @@ import { monthNames } from '../helpers/timeUtils'; import { UserContext } from '../context/UserContext'; const EventsList = ({ events, navigation, isLoading, showImage = true, onEventClick }: { - events: SHPEEventWithCommitteeData[], + events: SHPEEvent[], navigation?: any , isLoading?: boolean, showImage?: boolean @@ -17,7 +17,6 @@ const EventsList = ({ events, navigation, isLoading, showImage = true, onEventCl const userContext = useContext(UserContext); const { userInfo } = userContext!; - const hasPrivileges = (userInfo?.publicInfo?.roles?.admin?.valueOf() || userInfo?.publicInfo?.roles?.officer?.valueOf() || userInfo?.publicInfo?.roles?.developer?.valueOf()); if (isLoading) { @@ -30,14 +29,7 @@ const EventsList = ({ events, navigation, isLoading, showImage = true, onEventCl return ( - {events?.map((event: SHPEEventWithCommitteeData, index) => { - let LogoComponent, height, width, logo, color; - - if (event.committeeData) { - ({ logo, color } = event.committeeData); - ({ LogoComponent, height, width } = getLogoComponent(logo)); - } - + {events?.map((event: SHPEEvent, index) => { return ( - {/* If Committee is associated with event, then show Committee Logo */} - {LogoComponent && ( - - - {showImage && ( - - - - - - - - )} - - )} - {/* Display Cover Image if no committee is associated */} - {!LogoComponent && ( - - - {showImage && ( - - - - - + + + {showImage && ( + + + + - )} - - )} + + )} + + {/* Event Details */} @@ -139,8 +114,4 @@ const formatStartTime = (firestoreTimestamp: Timestamp) => { return `${formattedHours}:${formattedMinutes} ${amPm}`; } - -type SHPEEventWithCommitteeData = SHPEEvent & { committeeData?: Committee }; - - export default EventsList diff --git a/src/helpers/timeUtils.ts b/src/helpers/timeUtils.ts index 4ddda7c0..93393739 100644 --- a/src/helpers/timeUtils.ts +++ b/src/helpers/timeUtils.ts @@ -22,6 +22,7 @@ export const getNextHourMillis = (): number => { return currentTime + MillisecondTimes.HOUR - (currentTime % MillisecondTimes.HOUR); } +const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; export const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; /** @@ -30,12 +31,12 @@ export const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug * @returns Formatted date string */ export const formatDate = (date: Date): string => { + const dayOfWeek = dayNames[date.getDay()]; const day = date.getDate(); const month = monthNames[date.getMonth()]; - const year = date.getFullYear(); - return `${month} ${day}, ${year}`; -} + return `${dayOfWeek}, ${month} ${day}`; +}; /** * Constructs a readable string that represents the time of day of a given `Date` object @@ -46,7 +47,7 @@ export const formatTime = (date: Date): string => { const hour = date.getHours(); const minute = date.getMinutes(); - return `${hour % 12 == 0 ? 12 : hour % 12}:${minute.toString().padStart(2, '0')} ${hour > 11 ? "PM" : "AM"}` + return `${hour % 12 == 0 ? 12 : hour % 12}:${minute.toString().padStart(2, '0')}${hour > 11 ? "pm" : "am"}` } /** diff --git a/src/screens/events/EventCard.tsx b/src/screens/events/EventCard.tsx new file mode 100644 index 00000000..5ff4d49d --- /dev/null +++ b/src/screens/events/EventCard.tsx @@ -0,0 +1,59 @@ +import { View, Text, TouchableOpacity, Image } from 'react-native' +import React, { useContext } from 'react' +import { SHPEEvent } from '../../types/events' +import { Images } from '../../../assets' +import { formatDate } from '../../helpers/timeUtils' +import { UserContext } from '../../context/UserContext' +import { FontAwesome6 } from '@expo/vector-icons'; + +const EventCard = ({ event, navigation }: { event: SHPEEvent, navigation: any }) => { + const userContext = useContext(UserContext); + const { userInfo } = userContext!; + + const hasPrivileges = (userInfo?.publicInfo?.roles?.admin?.valueOf() || userInfo?.publicInfo?.roles?.officer?.valueOf() || userInfo?.publicInfo?.roles?.developer?.valueOf()); + + return ( + { navigation.navigate("EventInfo", { eventId: event.id! }) }} + > + + + + {event.name} + {event.locationName} + {formatDate(event.startTime?.toDate()!)} + + + {hasPrivileges && ( + + { navigation.navigate("QRCode", { event: event }) }} + className='absolute right-0 p-2 rounded-full' + style={{ backgroundColor: 'rgba(0,0,0,0.7)' }} + > + + + + )} + + ) +} + +export default EventCard \ No newline at end of file diff --git a/src/screens/events/Events.tsx b/src/screens/events/Events.tsx index 47bd561a..89a28b7d 100644 --- a/src/screens/events/Events.tsx +++ b/src/screens/events/Events.tsx @@ -1,14 +1,18 @@ -import { View, Text, TouchableOpacity, ActivityIndicator, ScrollView, Modal } from 'react-native' +import { View, Text, TouchableOpacity, ActivityIndicator, ScrollView, Image } from 'react-native' import React, { useCallback, useContext, useState } from 'react' import { SafeAreaView } from 'react-native-safe-area-context'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { useFocusEffect } from '@react-navigation/native'; import { Octicons } from '@expo/vector-icons'; +import { FontAwesome6 } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; import { UserContext } from '../../context/UserContext'; import { getUpcomingEvents, getPastEvents } from '../../api/firebaseUtils'; import { EventsStackParams } from '../../types/navigation'; import { EventType, SHPEEvent } from '../../types/events'; -import EventsList from '../../components/EventsList'; +import { Images } from '../../../assets'; +import { formatTime } from '../../helpers/timeUtils'; +import EventCard from './EventCard'; const Events = ({ navigation }: NativeStackScreenProps) => { const userContext = useContext(UserContext); @@ -32,11 +36,16 @@ const Events = ({ navigation }: NativeStackScreenProps) => { } }; - const filteredEvents = (events: SHPEEvent[]) => { + const filteredEvents = (events: SHPEEvent[]): SHPEEvent[] => { + // If no filter is selected, filter out hidden events if (!selectedFilter) { - // By default, hide committee meetings unless they are labeled as general - return events.filter(event => (event.eventType !== EventType.COMMITTEE_MEETING || event.general) && (event.hiddenEvent !== true)); + return events.filter(event => + (event.eventType !== EventType.COMMITTEE_MEETING || event.general) && + !event.hiddenEvent + ); } + + // Custom filter logic if (selectedFilter === 'myEvents') { return events.filter(event => userInfo?.publicInfo?.committees?.includes(event.committee || '') || @@ -46,7 +55,14 @@ const Events = ({ navigation }: NativeStackScreenProps) => { if (selectedFilter === 'clubWide') { return events.filter(event => event.general); } - return events.filter(event => event.eventType === selectedFilter && (event.hiddenEvent !== true)); + + // Show hidden events for "Custom Event" filter + if (selectedFilter === 'Custom Event') { + return events.filter(event => event.eventType === selectedFilter); + } + + // Filter other events, excluding hidden ones + return events.filter(event => event.eventType === selectedFilter && !event.hiddenEvent); }; useFocusEffect( @@ -56,8 +72,7 @@ const Events = ({ navigation }: NativeStackScreenProps) => { setIsLoading(true); const upcomingEventsData = await getUpcomingEvents(); - const pastEventsData = await getPastEvents(5); - + const pastEventsData = await getPastEvents(10); const currentTime = new Date(); const today = new Date(currentTime.getFullYear(), currentTime.getMonth(), currentTime.getDate()); @@ -81,6 +96,7 @@ const Events = ({ navigation }: NativeStackScreenProps) => { } }; + // Fetch events if user has privileges or if initial fetch has not been done if (!initialFetch || hasPrivileges) { fetchEvents(); setInitialFetch(true); @@ -89,92 +105,176 @@ const Events = ({ navigation }: NativeStackScreenProps) => { ); return ( - - - - - Events - + + + + Events {/* Filters */} - - + + {selectedFilter && ( setSelectedFilter(null)} > - Reset + )} + handleFilterSelect("myEvents")} > - My Events + My Events handleFilterSelect("clubWide")} > - Club Wide + Club Wide + {Object.values(EventType).map((type) => ( handleFilterSelect(type)} > - {type} + {type} ))} {isLoading && - - + + } {/* Event Listings */} {!isLoading && ( - + {filteredEvents(todayEvents).length === 0 && filteredEvents(upcomingEvents).length === 0 && filteredEvents(pastEvents).length === 0 ? ( - + No Events ) : ( + {/* Today's Events */} {filteredEvents(todayEvents).length !== 0 && ( - - Today Events - + + Today's Events + {filteredEvents(todayEvents)?.map((event: SHPEEvent, index) => { + return ( + 0 && "mt-8"}`} + onPress={() => { navigation.navigate("EventInfo", { eventId: event.id! }) }} + > + + + + {event.name} + {event.locationName} + {formatTime(event.startTime?.toDate()!)} + + + {hasPrivileges && ( + { navigation.navigate("QRCode", { event: event }) }} + className='absolute right-0 top-0 p-2 m-2 rounded-full' + style={{ backgroundColor: 'rgba(0,0,0,0.7)' }} + > + + + )} + + ); + })} )} + {/* Upcoming Events */} {filteredEvents(upcomingEvents).length !== 0 && ( - - Upcoming Events - + + Upcoming Events + {filteredEvents(upcomingEvents)?.map((event: SHPEEvent, index) => { + return ( + 0 && "mt-8"}`}> + + + ); + })} )} + {/* Past Events */} {filteredEvents(pastEvents).length !== 0 && ( - - Past Events - + + Past Events + {filteredEvents(pastEvents)?.map((event: SHPEEvent, index) => { + return ( + 0 && "mt-8"}`}> + + + ); + })} )} @@ -182,12 +282,24 @@ const Events = ({ navigation }: NativeStackScreenProps) => { )} - + + {/* Create Event */} {hasPrivileges && ( navigation.navigate("CreateEvent")} > diff --git a/tailwind.config.js b/tailwind.config.js index 1b608728..7c06b809 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,25 +1,29 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - content: ["./App.{js,jsx,ts,tsx}", "./src/**/*.{js,jsx,ts,tsx}"], - theme: { - extend: { - colors: { - offwhite: "#FAF9F6", - maroon: "#500000", - navy: "#001F5B", - orange: "#FD652F", - "red-orange": "#C24E3A", - "pale-blue": "#72A9BE", - "dark-navy": "#191740", - "pale-orange": "#EF9260", - "continue-dark": "#ED652F", - "continue-light": "#C24E3A", - "primary-bg-dark": "#121212", - "primary-bg-light": "#FAF9F6", - "secondary-bg-dark": "#2a2a2a", - "secondary-bg-light": "#FFFFFF", - }, - }, + content: ["./App.{js,jsx,ts,tsx}", "./src/**/*.{js,jsx,ts,tsx}"], + theme: { + extend: { + colors: { + offwhite: "#FAF9F6", + maroon: "#500000", + navy: "#001F5B", + orange: "#FD652F", + "red-orange": "#C24E3A", + "pale-blue": "#72A9BE", + "dark-navy": "#191740", + "pale-orange": "#EF9260", + "continue-dark": "#ED652F", + "continue-light": "#C24E3A", + "primary-bg-dark": "#121212", + "primary-bg-light": "#FAF9F6", + "secondary-bg-dark": "#2a2a2a", + "secondary-bg-light": "#FFFFFF", + "red-1": "#FF0000", + "primary-blue": "#1870B8", + "secondary-blue-1": "#468DC6", + "secondary-blue-2": "#E8F1F8", + }, }, - plugins: [], + }, + plugins: [], }; From ae952b7b08b909c8761c58a4e2d6cf0aaa19738a Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 16 Jun 2024 17:14:08 -0500 Subject: [PATCH 062/198] add event test for events --- src/api/firebaseUtils.ts | 126 +++++----- .../events/__tests__/eventUtils.test.ts | 235 ++++++++++++++++++ 2 files changed, 301 insertions(+), 60 deletions(-) create mode 100644 src/screens/events/__tests__/eventUtils.test.ts diff --git a/src/api/firebaseUtils.ts b/src/api/firebaseUtils.ts index 115ff572..9c9bc90f 100644 --- a/src/api/firebaseUtils.ts +++ b/src/api/firebaseUtils.ts @@ -550,20 +550,7 @@ export const isUserInBlacklist = async (uid: string): Promise => { } }; -/** - * Creates a new SHPE event document in firestore - * @param event Object with event details - * @returns Document name in firestore. Null if error occurred - */ -export const createEvent = async (event: SHPEEvent): Promise => { - try { - const docRef = await addDoc(collection(db, "events"), { ...event }); - return docRef.id; - } catch (error) { - console.error("Error adding document: ", error); - return null; - } -}; + /** * Updates a given event @@ -605,52 +592,6 @@ export const getEvent = async (eventID: string): Promise => { } } -export const getUpcomingEvents = async () => { - const currentTime = new Date(); - const eventsRef = collection(db, "events"); - const q = query(eventsRef, where("endTime", ">", currentTime)); - const querySnapshot = await getDocs(q); - const events: SHPEEvent[] = []; - - for (const doc of querySnapshot.docs) { - const eventData = doc.data(); - events.push({ id: doc.id, ...eventData }); - } - - events.sort((a, b) => { - const dateA = a.startTime ? a.startTime.toDate() : undefined; - const dateB = b.startTime ? b.startTime.toDate() : undefined; - - return dateA && dateB ? dateA.getTime() - dateB.getTime() : -1; - }); - - return events; -}; - -export const getPastEvents = async (numLimit?: number) => { - const currentTime = new Date(); - const eventsRef = collection(db, "events"); - let q; - - if (numLimit !== undefined) { - q = query(eventsRef, where("endTime", "<", currentTime), orderBy("endTime", "desc"), limit(numLimit)); - } else { - q = query(eventsRef, where("endTime", "<", currentTime), orderBy("endTime", "desc")); - } - - const querySnapshot = await getDocs(q); - const events: SHPEEvent[] = []; - - for (const doc of querySnapshot.docs) { - const eventData = doc.data(); - events.push({ id: doc.id, ...eventData }); - } - - // Events are already ordered by endTime due to the query, no need to sort again - return events; -}; - - export const destroyEvent = async (eventID: string) => { try { const eventRef = doc(db, "events", eventID); @@ -1473,4 +1414,69 @@ export const fetchEventByName = async (eventName: string): Promise { + const currentTime = new Date(); + const eventsRef = collection(db, "events"); + const q = query(eventsRef, where("endTime", ">", currentTime)); + const querySnapshot = await getDocs(q); + const events: SHPEEvent[] = []; + + for (const doc of querySnapshot.docs) { + const eventData = doc.data(); + events.push({ id: doc.id, ...eventData }); + } + + events.sort((a, b) => { + const dateA = a.startTime ? a.startTime.toDate() : undefined; + const dateB = b.startTime ? b.startTime.toDate() : undefined; + + return dateA && dateB ? dateA.getTime() - dateB.getTime() : -1; + }); + + return events; +}; + +export const getPastEvents = async (numLimit?: number) => { + const currentTime = new Date(); + const eventsRef = collection(db, "events"); + let q; + + if (numLimit !== undefined) { + q = query(eventsRef, where("endTime", "<", currentTime), orderBy("endTime", "desc"), limit(numLimit)); + } else { + q = query(eventsRef, where("endTime", "<", currentTime), orderBy("endTime", "desc")); + } + + const querySnapshot = await getDocs(q); + const events: SHPEEvent[] = []; + + for (const doc of querySnapshot.docs) { + const eventData = doc.data(); + events.push({ id: doc.id, ...eventData }); + } + + // Events are already ordered by endTime due to the query, no need to sort again + return events; +}; + +/** + * Creates a new SHPE event document in firestore + * @param event Object with event details + * @returns Document name in firestore. Null if error occurred + */ +export const createEvent = async (event: SHPEEvent): Promise => { + try { + const docRef = await addDoc(collection(db, "events"), { ...event }); + return docRef.id; + } catch (error) { + console.error("Error adding document: ", error); + return null; + } }; \ No newline at end of file diff --git a/src/screens/events/__tests__/eventUtils.test.ts b/src/screens/events/__tests__/eventUtils.test.ts new file mode 100644 index 00000000..d548ed84 --- /dev/null +++ b/src/screens/events/__tests__/eventUtils.test.ts @@ -0,0 +1,235 @@ +import { Timestamp, GeoPoint, deleteDoc, doc, collection, getDocs } from "firebase/firestore"; +import { signInAnonymously, signOut } from "firebase/auth"; +import { auth, db } from "../../../config/firebaseConfig"; +import { createEvent, getUpcomingEvents, getPastEvents } from "../../../api/firebaseUtils"; +import { EventType, SHPEEvent } from "../../../types/events"; + +const generateTestEvent = (overrides: Partial = {}): SHPEEvent => { + const currentTime = new Date(); + const startTime = Timestamp.fromDate(currentTime); + const endTime = Timestamp.fromDate(new Date(currentTime.getTime() + 3600 * 1000)); + + return { + committee: "app-devs", + coverImageURI: null, + creator: "sampleUID", + description: "Test Description", + endTime: endTime, + endTimeBuffer: 600000, + eventType: EventType.INTRAMURAL_EVENT, + general: true, + geofencingRadius: 100, + geolocation: new GeoPoint(30.621160236499136, -96.3403560168198), + hiddenEvent: false, + locationName: "Test", + name: "Test Event", + nationalConventionEligible: true, + notificationSent: true, + signInPoints: 3, + startTime: startTime, + startTimeBuffer: 600000, + ...overrides + }; +}; + + +beforeAll(async () => { + expect(process.env.FIREBASE_EMULATOR_ADDRESS).toBeDefined(); + expect(process.env.FIREBASE_AUTH_PORT).toBeDefined(); + expect(process.env.FIREBASE_CLOUD_FUNCTIONS_PORT).toBeDefined(); + expect(process.env.FIREBASE_FIRESTORE_PORT).toBeDefined(); + expect(process.env.FIREBASE_STORAGE_PORT).toBeDefined(); + expect(Number(process.env.FIREBASE_AUTH_PORT)).not.toBeNaN(); + expect(Number(process.env.FIREBASE_CLOUD_FUNCTIONS_PORT)).not.toBeNaN(); + expect(Number(process.env.FIREBASE_FIRESTORE_PORT)).not.toBeNaN(); + expect(Number(process.env.FIREBASE_STORAGE_PORT)).not.toBeNaN(); + + await signInAnonymously(auth); + + // Clear events collection + const eventsRef = collection(db, "events"); + const eventsSnapshot = await getDocs(eventsRef); + for (const doc of eventsSnapshot.docs) { + await deleteDoc(doc.ref); + } +}); + +afterAll(async () => { + await signOut(auth); +}); + +describe("Event Utils", () => { + test("Handle empty events collection", async () => { + const upcomingEvents = await getUpcomingEvents(); + expect(upcomingEvents.length).toBe(0); + + const pastEvents = await getPastEvents(); + expect(pastEvents.length).toBe(0); + }); + + test("Create event with invalid data", async () => { + const invalidEvent: Partial = { + name: "Invalid Event", + description: "This event is has incorrect fields", + startTimeBuffer: "600000" as any, + endTimeBuffer: -600000, + signInPoints: "three" as any, + geolocation: { latitude: 30.621160236499136, longitude: -96.3403560168198 } as any + }; + + try { + const eventId = await createEvent(invalidEvent as SHPEEvent); + expect(eventId).toBeNull(); + } catch (error) { + expect(error).toBeDefined(); + } + }); + + test("Create and fetch upcoming events", async () => { + const event = generateTestEvent(); + const eventId = await createEvent(event as SHPEEvent); + expect(eventId).not.toBeNull(); + + const upcomingEvents = await getUpcomingEvents(); + expect(upcomingEvents.length).toBeGreaterThan(0); + + const createdEvent = upcomingEvents.find(e => e.id === eventId); + expect(createdEvent).toBeDefined(); + expect(createdEvent).toMatchObject(event); + + await deleteDoc(doc(db, "events", eventId!)); + }); + + test("Fetch past events with limit", async () => { + const event = generateTestEvent({ + startTime: Timestamp.fromDate(new Date(Date.now() - 7200 * 1000)), + endTime: Timestamp.fromDate(new Date(Date.now() - 3600 * 1000)), + description: "Past Event Description", + locationName: "Past Event Location", + name: "Past Event", + }); + + const eventId = await createEvent(event as SHPEEvent); + expect(eventId).not.toBeNull(); + + const pastEvents = await getPastEvents(1); + expect(pastEvents.length).toBe(1); + + const fetchedEvent = pastEvents.find(e => e.id === eventId); + expect(fetchedEvent).toBeDefined(); + expect(fetchedEvent).toMatchObject(event); + + await deleteDoc(doc(db, "events", eventId!)); + }); + + test("Fetch past events without limit", async () => { + const event = generateTestEvent({ + startTime: Timestamp.fromDate(new Date(Date.now() - 7200 * 1000)), + endTime: Timestamp.fromDate(new Date(Date.now() - 3600 * 1000)), + description: "Past Event Description", + locationName: "Past Event Location", + name: "Past Event", + }); + + const eventId = await createEvent(event as SHPEEvent); + expect(eventId).not.toBeNull(); + + const pastEvents = await getPastEvents(); + expect(pastEvents.length).toBeGreaterThan(0); + + const fetchedEvent = pastEvents.find(e => e.id === eventId); + expect(fetchedEvent).toBeDefined(); + expect(fetchedEvent).toMatchObject(event); + + await deleteDoc(doc(db, "events", eventId!)); + }); + + test("Multiple events and sorting", async () => { + const event1 = generateTestEvent({ + startTime: Timestamp.fromDate(new Date(Date.now() + 3600 * 1000)), + endTime: Timestamp.fromDate(new Date(Date.now() + 7200 * 1000)), + description: "Event 1 Description", + name: "Event 1", + locationName: "Event 1 Location" + }); + + const event2 = generateTestEvent({ + startTime: Timestamp.fromDate(new Date(Date.now() + 10800 * 1000)), + endTime: Timestamp.fromDate(new Date(Date.now() + 14400 * 1000)), + description: "Event 2 Description", + name: "Event 2", + locationName: "Event 2 Location" + }); + + const event1Id = await createEvent(event1 as SHPEEvent); + const event2Id = await createEvent(event2 as SHPEEvent); + + const upcomingEvents = await getUpcomingEvents(); + expect(upcomingEvents.length).toBeGreaterThan(1); + + const event1Index = upcomingEvents.findIndex(e => e.id === event1Id); + const event2Index = upcomingEvents.findIndex(e => e.id === event2Id); + expect(event1Index).toBeLessThan(event2Index); + + await deleteDoc(doc(db, "events", event1Id!)); + await deleteDoc(doc(db, "events", event2Id!)); + }); + + test("Events with different time frames", async () => { + const pastEvent = generateTestEvent({ + startTime: Timestamp.fromDate(new Date(Date.now() - 7200 * 1000)), + endTime: Timestamp.fromDate(new Date(Date.now() - 3600 * 1000)), + description: "Past Event", + name: "Past Event", + locationName: "Past Location" + }); + + const upcomingEvent = generateTestEvent({ + startTime: Timestamp.fromDate(new Date()), + endTime: Timestamp.fromDate(new Date(Date.now() + 3600 * 1000)), + description: "Upcoming Event", + name: "Upcoming Event", + locationName: "Upcoming Location" + }); + + const pastEventId = await createEvent(pastEvent as SHPEEvent); + const upcomingEventId = await createEvent(upcomingEvent as SHPEEvent); + + const upcomingEvents = await getUpcomingEvents(); + expect(upcomingEvents.length).toBeGreaterThan(0); + expect(upcomingEvents.find(e => e.id === upcomingEventId)).toBeDefined(); + expect(upcomingEvents.find(e => e.id === pastEventId)).toBeUndefined(); + + const pastEvents = await getPastEvents(); + expect(pastEvents.length).toBeGreaterThan(0); + expect(pastEvents.find(e => e.id === pastEventId)).toBeDefined(); + expect(pastEvents.find(e => e.id === upcomingEventId)).toBeUndefined(); + + await deleteDoc(doc(db, "events", pastEventId!)); + await deleteDoc(doc(db, "events", upcomingEventId!)); + }); + + test("Handle large number of events", async () => { + const currentTime = new Date(); + const eventsToCreate: SHPEEvent[] = []; + + for (let i = 0; i < 100; i++) { + const event = generateTestEvent({ + description: `Event ${i}`, + endTime: Timestamp.fromDate(new Date(currentTime.getTime() + (i + 1) * 3600 * 1000)), + locationName: `Location ${i}`, + name: `Event ${i}`, + startTime: Timestamp.fromDate(new Date(currentTime.getTime() + i * 3600 * 1000)), + }); + eventsToCreate.push(event); + } + + const eventIds = await Promise.all(eventsToCreate.map(event => createEvent(event as SHPEEvent))); + expect(eventIds).toHaveLength(100); + + const upcomingEvents = await getUpcomingEvents(); + expect(upcomingEvents.length).toBeGreaterThanOrEqual(100); + + await Promise.all(eventIds.map(eventId => deleteDoc(doc(db, "events", eventId!)))); + }); +}); \ No newline at end of file From d35fb7a87b102ba75ae9fd41faa5f26ed9cb7d36 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 16 Jun 2024 17:30:31 -0500 Subject: [PATCH 063/198] remove reset filter btn --- src/screens/events/Events.tsx | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/src/screens/events/Events.tsx b/src/screens/events/Events.tsx index 89a28b7d..64d4a8e9 100644 --- a/src/screens/events/Events.tsx +++ b/src/screens/events/Events.tsx @@ -114,26 +114,6 @@ const Events = ({ navigation }: NativeStackScreenProps) => { {/* Filters */} - {selectedFilter && ( - setSelectedFilter(null)} - > - - - )} - Date: Sun, 16 Jun 2024 19:05:03 -0500 Subject: [PATCH 064/198] add past event with infinite scroll, small ui update, and update test cases --- src/api/firebaseUtils.ts | 21 +++-- src/navigation/EventsStack.tsx | 2 + src/screens/events/EventCard.tsx | 15 ++- src/screens/events/Events.tsx | 11 ++- src/screens/events/PastEvents.tsx | 91 +++++++++++++++++++ .../events/__tests__/eventUtils.test.ts | 9 +- src/types/navigation.ts | 1 + 7 files changed, 130 insertions(+), 20 deletions(-) create mode 100644 src/screens/events/PastEvents.tsx diff --git a/src/api/firebaseUtils.ts b/src/api/firebaseUtils.ts index 9c9bc90f..1f003d35 100644 --- a/src/api/firebaseUtils.ts +++ b/src/api/firebaseUtils.ts @@ -1443,27 +1443,30 @@ export const getUpcomingEvents = async () => { return events; }; -export const getPastEvents = async (numLimit?: number) => { +export const getPastEvents = async (numLimit: number, startAfterDoc: any, setEndOfData?: (endOfData: boolean) => void) => { const currentTime = new Date(); const eventsRef = collection(db, "events"); let q; - if (numLimit !== undefined) { - q = query(eventsRef, where("endTime", "<", currentTime), orderBy("endTime", "desc"), limit(numLimit)); + if (startAfterDoc) { + q = query(eventsRef, where("endTime", "<", currentTime), orderBy("endTime", "desc"), startAfter(startAfterDoc), limit(numLimit)); } else { - q = query(eventsRef, where("endTime", "<", currentTime), orderBy("endTime", "desc")); + q = query(eventsRef, where("endTime", "<", currentTime), orderBy("endTime", "desc"), limit(numLimit)); } const querySnapshot = await getDocs(q); const events: SHPEEvent[] = []; - for (const doc of querySnapshot.docs) { - const eventData = doc.data(); - events.push({ id: doc.id, ...eventData }); + querySnapshot.forEach(doc => { + events.push({ id: doc.id, ...doc.data() } as SHPEEvent); + }); + + if (setEndOfData && querySnapshot.docs.length < numLimit) { + setEndOfData(true); } - // Events are already ordered by endTime due to the query, no need to sort again - return events; + const lastVisibleDoc = querySnapshot.docs[querySnapshot.docs.length - 1]; + return { events, lastVisibleDoc }; }; /** diff --git a/src/navigation/EventsStack.tsx b/src/navigation/EventsStack.tsx index 76a46293..943bae1b 100644 --- a/src/navigation/EventsStack.tsx +++ b/src/navigation/EventsStack.tsx @@ -12,12 +12,14 @@ import FinalizeEvent from "../screens/events/FinalizeEvent"; import SetLocationEventDetails from "../screens/events/SetLocationEventDetails"; import EventVerification from "../screens/events/EventVerification"; import PublicProfileScreen from "../screens/userProfile/PublicProfile"; +import PastEvents from "../screens/events/PastEvents"; const EventsStack = () => { const Stack = createNativeStackNavigator(); return ( + diff --git a/src/screens/events/EventCard.tsx b/src/screens/events/EventCard.tsx index 5ff4d49d..63dbbd7c 100644 --- a/src/screens/events/EventCard.tsx +++ b/src/screens/events/EventCard.tsx @@ -29,15 +29,17 @@ const EventCard = ({ event, navigation }: { event: SHPEEvent, navigation: any }) onPress={() => { navigation.navigate("EventInfo", { eventId: event.id! }) }} > - {event.name} - {event.locationName} + {truncateStringWithEllipsis(event.name!)} + {event.locationName ? ( + {truncateStringWithEllipsis(event.locationName)} + ) : null} {formatDate(event.startTime?.toDate()!)} @@ -56,4 +58,11 @@ const EventCard = ({ event, navigation }: { event: SHPEEvent, navigation: any }) ) } +const truncateStringWithEllipsis = (name: string, limit = 22) => { + if (name.length > limit) { + return `${name.substring(0, limit)}...`; + } + return name; +}; + export default EventCard \ No newline at end of file diff --git a/src/screens/events/Events.tsx b/src/screens/events/Events.tsx index 64d4a8e9..34066a93 100644 --- a/src/screens/events/Events.tsx +++ b/src/screens/events/Events.tsx @@ -72,7 +72,7 @@ const Events = ({ navigation }: NativeStackScreenProps) => { setIsLoading(true); const upcomingEventsData = await getUpcomingEvents(); - const pastEventsData = await getPastEvents(10); + const pastEventsData = await getPastEvents(3, null); const currentTime = new Date(); const today = new Date(currentTime.getFullYear(), currentTime.getMonth(), currentTime.getDate()); @@ -87,7 +87,7 @@ const Events = ({ navigation }: NativeStackScreenProps) => { setTodayEvents(todayEvents); setUpcomingEvents(upcomingEvents); - setPastEvents(pastEventsData); + setPastEvents(pastEventsData.events); setIsLoading(false); } catch (error) { @@ -211,7 +211,9 @@ const Events = ({ navigation }: NativeStackScreenProps) => { > {event.name} - {event.locationName} + {event.locationName ? ( + {event.locationName} + ) : null} {formatTime(event.startTime?.toDate()!)} @@ -257,6 +259,9 @@ const Events = ({ navigation }: NativeStackScreenProps) => { })} )} + navigation.navigate("PastEvents")}> + View all past events + )} diff --git a/src/screens/events/PastEvents.tsx b/src/screens/events/PastEvents.tsx new file mode 100644 index 00000000..c8768355 --- /dev/null +++ b/src/screens/events/PastEvents.tsx @@ -0,0 +1,91 @@ +import { View, Text, ScrollView, TouchableOpacity, NativeSyntheticEvent, NativeScrollEvent, ActivityIndicator } from 'react-native' +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { SafeAreaView } from 'react-native-safe-area-context' +import { Octicons } from '@expo/vector-icons'; +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { EventsStackParams } from '../../types/navigation'; +import { SHPEEvent } from '../../types/events'; +import { getPastEvents } from '../../api/firebaseUtils'; +import EventCard from './EventCard'; + +const PastEvents = ({ navigation }: NativeStackScreenProps) => { + const [pastEvents, setPastEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [endOfData, setEndOfData] = useState(false); + const lastVisibleRef = useRef(null); + + const loadMoreEvents = async () => { + if (loading || endOfData) return; + setLoading(true); + const { events, lastVisibleDoc } = await getPastEvents(8, lastVisibleRef.current, setEndOfData); + setPastEvents([...pastEvents, ...events]); + lastVisibleRef.current = lastVisibleDoc; + setLoading(false); + }; + + useEffect(() => { + const fetchInitialEvents = async () => { + setLoading(true); + const { events, lastVisibleDoc } = await getPastEvents(8, null, setEndOfData); + setPastEvents(events); + lastVisibleRef.current = lastVisibleDoc; + setLoading(false); + }; + + fetchInitialEvents(); + }, []); + + const handleScroll = useCallback(({ nativeEvent }: NativeSyntheticEvent) => { + const isCloseToBottom = ({ layoutMeasurement, contentOffset, contentSize }: NativeScrollEvent) => { + const paddingToBottom = 20; + return layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom; + }; + + if (!isCloseToBottom(nativeEvent) || endOfData) return; + loadMoreEvents(); + }, [loading, endOfData, setPastEvents]); + + return ( + + + + + Past Events + + navigation.goBack()} className='py-1 px-4'> + + + + + + {pastEvents.map((event: SHPEEvent, index) => { + return ( + + + + ); + })} + {loading && ( + + + + )} + + {endOfData && !loading && ( + + No more events + + )} + + + + + + ) +} + +export default PastEvents; \ No newline at end of file diff --git a/src/screens/events/__tests__/eventUtils.test.ts b/src/screens/events/__tests__/eventUtils.test.ts index d548ed84..b45fb9f9 100644 --- a/src/screens/events/__tests__/eventUtils.test.ts +++ b/src/screens/events/__tests__/eventUtils.test.ts @@ -63,7 +63,7 @@ describe("Event Utils", () => { const upcomingEvents = await getUpcomingEvents(); expect(upcomingEvents.length).toBe(0); - const pastEvents = await getPastEvents(); + const { events: pastEvents } = await getPastEvents(10, null); expect(pastEvents.length).toBe(0); }); @@ -112,7 +112,7 @@ describe("Event Utils", () => { const eventId = await createEvent(event as SHPEEvent); expect(eventId).not.toBeNull(); - const pastEvents = await getPastEvents(1); + const { events: pastEvents } = await getPastEvents(1, null); expect(pastEvents.length).toBe(1); const fetchedEvent = pastEvents.find(e => e.id === eventId); @@ -134,7 +134,7 @@ describe("Event Utils", () => { const eventId = await createEvent(event as SHPEEvent); expect(eventId).not.toBeNull(); - const pastEvents = await getPastEvents(); + const { events: pastEvents } = await getPastEvents(10, null); expect(pastEvents.length).toBeGreaterThan(0); const fetchedEvent = pastEvents.find(e => e.id === eventId); @@ -143,7 +143,6 @@ describe("Event Utils", () => { await deleteDoc(doc(db, "events", eventId!)); }); - test("Multiple events and sorting", async () => { const event1 = generateTestEvent({ startTime: Timestamp.fromDate(new Date(Date.now() + 3600 * 1000)), @@ -200,7 +199,7 @@ describe("Event Utils", () => { expect(upcomingEvents.find(e => e.id === upcomingEventId)).toBeDefined(); expect(upcomingEvents.find(e => e.id === pastEventId)).toBeUndefined(); - const pastEvents = await getPastEvents(); + const { events: pastEvents } = await getPastEvents(10, null); expect(pastEvents.length).toBeGreaterThan(0); expect(pastEvents.find(e => e.id === pastEventId)).toBeDefined(); expect(pastEvents.find(e => e.id === upcomingEventId)).toBeUndefined(); diff --git a/src/types/navigation.ts b/src/types/navigation.ts index d60c04ea..6e0f3134 100644 --- a/src/types/navigation.ts +++ b/src/types/navigation.ts @@ -81,6 +81,7 @@ export type ResourcesStackParams = { export type EventsStackParams = { EventsScreen: undefined; + PastEvents: undefined; UpdateEvent: { event: SHPEEvent }; EventInfo: { eventId: string }; QRCode: { event: SHPEEvent }; From d382aa1268956d6c79a0085feac93045cd460f87 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 16 Jun 2024 19:20:10 -0500 Subject: [PATCH 065/198] add manual refresh events for officers --- src/screens/events/Events.tsx | 109 +++++++++++++++++++++------------- 1 file changed, 67 insertions(+), 42 deletions(-) diff --git a/src/screens/events/Events.tsx b/src/screens/events/Events.tsx index 34066a93..990e0a50 100644 --- a/src/screens/events/Events.tsx +++ b/src/screens/events/Events.tsx @@ -1,8 +1,8 @@ import { View, Text, TouchableOpacity, ActivityIndicator, ScrollView, Image } from 'react-native' -import React, { useCallback, useContext, useState } from 'react' +import React, { useCallback, useContext, useEffect, useState } from 'react' import { SafeAreaView } from 'react-native-safe-area-context'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { useFocusEffect } from '@react-navigation/native'; +import { Ionicons } from '@expo/vector-icons'; import { Octicons } from '@expo/vector-icons'; import { FontAwesome6 } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; @@ -28,6 +28,39 @@ const Events = ({ navigation }: NativeStackScreenProps) => { const hasPrivileges = (userInfo?.publicInfo?.roles?.admin?.valueOf() || userInfo?.publicInfo?.roles?.officer?.valueOf() || userInfo?.publicInfo?.roles?.developer?.valueOf()); + const fetchEvents = async () => { + try { + setIsLoading(true); + + const upcomingEventsData = await getUpcomingEvents(); + const pastEventsData = await getPastEvents(3, null); + const currentTime = new Date(); + const today = new Date(currentTime.getFullYear(), currentTime.getMonth(), currentTime.getDate()); + + const todayEvents = upcomingEventsData.filter(event => { + const startTime = event.startTime ? event.startTime.toDate() : new Date(0); + return startTime >= today && startTime < new Date(today.getTime() + 24 * 60 * 60 * 1000); + }); + const upcomingEvents = upcomingEventsData.filter(event => { + const startTime = event.startTime ? event.startTime.toDate() : new Date(0); + return startTime >= new Date(today.getTime() + 24 * 60 * 60 * 1000); + }); + + setTodayEvents(todayEvents); + setUpcomingEvents(upcomingEvents); + setPastEvents(pastEventsData.events); + + setIsLoading(false); + } catch (error) { + console.error('An error occurred while fetching events:', error); + setIsLoading(false); + } + }; + + useEffect(() => { + fetchEvents(); + }, []) + const handleFilterSelect = (filter: ExtendedEventType) => { if (selectedFilter === filter) { setSelectedFilter(null); @@ -65,50 +98,31 @@ const Events = ({ navigation }: NativeStackScreenProps) => { return events.filter(event => event.eventType === selectedFilter && !event.hiddenEvent); }; - useFocusEffect( - useCallback(() => { - const fetchEvents = async () => { - try { - setIsLoading(true); - - const upcomingEventsData = await getUpcomingEvents(); - const pastEventsData = await getPastEvents(3, null); - const currentTime = new Date(); - const today = new Date(currentTime.getFullYear(), currentTime.getMonth(), currentTime.getDate()); - - const todayEvents = upcomingEventsData.filter(event => { - const startTime = event.startTime ? event.startTime.toDate() : new Date(0); - return startTime >= today && startTime < new Date(today.getTime() + 24 * 60 * 60 * 1000); - }); - const upcomingEvents = upcomingEventsData.filter(event => { - const startTime = event.startTime ? event.startTime.toDate() : new Date(0); - return startTime >= new Date(today.getTime() + 24 * 60 * 60 * 1000); - }); - - setTodayEvents(todayEvents); - setUpcomingEvents(upcomingEvents); - setPastEvents(pastEventsData.events); - - setIsLoading(false); - } catch (error) { - console.error('An error occurred while fetching events:', error); - setIsLoading(false); - } - }; - - // Fetch events if user has privileges or if initial fetch has not been done - if (!initialFetch || hasPrivileges) { - fetchEvents(); - setInitialFetch(true); - } - }, [hasPrivileges, initialFetch]) - ); - return ( - + Events + + {(!isLoading && hasPrivileges) && ( + fetchEvents()} + > + + + )} {/* Filters */} @@ -197,6 +211,17 @@ const Events = ({ navigation }: NativeStackScreenProps) => { 0 && "mt-8"}`} + style={{ + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + + elevation: 5, + }} onPress={() => { navigation.navigate("EventInfo", { eventId: event.id! }) }} > Date: Sun, 16 Jun 2024 20:23:12 -0500 Subject: [PATCH 066/198] fix spacing and add comment --- src/screens/events/EventCard.tsx | 8 ++++---- src/screens/events/Events.tsx | 11 +++++------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/screens/events/EventCard.tsx b/src/screens/events/EventCard.tsx index 63dbbd7c..2a21f63a 100644 --- a/src/screens/events/EventCard.tsx +++ b/src/screens/events/EventCard.tsx @@ -1,10 +1,10 @@ import { View, Text, TouchableOpacity, Image } from 'react-native' import React, { useContext } from 'react' -import { SHPEEvent } from '../../types/events' -import { Images } from '../../../assets' -import { formatDate } from '../../helpers/timeUtils' -import { UserContext } from '../../context/UserContext' import { FontAwesome6 } from '@expo/vector-icons'; +import { UserContext } from '../../context/UserContext' +import { formatDate } from '../../helpers/timeUtils' +import { Images } from '../../../assets' +import { SHPEEvent } from '../../types/events' const EventCard = ({ event, navigation }: { event: SHPEEvent, navigation: any }) => { const userContext = useContext(UserContext); diff --git a/src/screens/events/Events.tsx b/src/screens/events/Events.tsx index 990e0a50..73d19005 100644 --- a/src/screens/events/Events.tsx +++ b/src/screens/events/Events.tsx @@ -1,10 +1,8 @@ import { View, Text, TouchableOpacity, ActivityIndicator, ScrollView, Image } from 'react-native' -import React, { useCallback, useContext, useEffect, useState } from 'react' +import React, { useContext, useEffect, useState } from 'react' import { SafeAreaView } from 'react-native-safe-area-context'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { Ionicons } from '@expo/vector-icons'; -import { Octicons } from '@expo/vector-icons'; -import { FontAwesome6 } from '@expo/vector-icons'; +import { Ionicons, Octicons, FontAwesome6 } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import { UserContext } from '../../context/UserContext'; import { getUpcomingEvents, getPastEvents } from '../../api/firebaseUtils'; @@ -24,7 +22,6 @@ const Events = ({ navigation }: NativeStackScreenProps) => { const [selectedFilter, setSelectedFilter] = useState(null); const [isLoading, setIsLoading] = useState(true); - const [initialFetch, setInitialFetch] = useState(false); const hasPrivileges = (userInfo?.publicInfo?.roles?.admin?.valueOf() || userInfo?.publicInfo?.roles?.officer?.valueOf() || userInfo?.publicInfo?.roles?.developer?.valueOf()); @@ -34,9 +31,9 @@ const Events = ({ navigation }: NativeStackScreenProps) => { const upcomingEventsData = await getUpcomingEvents(); const pastEventsData = await getPastEvents(3, null); + const currentTime = new Date(); const today = new Date(currentTime.getFullYear(), currentTime.getMonth(), currentTime.getDate()); - const todayEvents = upcomingEventsData.filter(event => { const startTime = event.startTime ? event.startTime.toDate() : new Date(0); return startTime >= today && startTime < new Date(today.getTime() + 24 * 60 * 60 * 1000); @@ -101,6 +98,7 @@ const Events = ({ navigation }: NativeStackScreenProps) => { return ( + {/* Header */} Events @@ -284,6 +282,7 @@ const Events = ({ navigation }: NativeStackScreenProps) => { })} )} + navigation.navigate("PastEvents")}> View all past events From f0eecce5ba91509c6ac116bc286c02af9a268bb7 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 16 Jun 2024 20:42:21 -0500 Subject: [PATCH 067/198] add dark mode to events and past event list screen --- src/screens/events/EventCard.tsx | 9 +++++---- src/screens/events/Events.tsx | 25 +++++++++++++------------ src/screens/events/PastEvents.tsx | 13 +++++++++---- tailwind.config.js | 8 ++++---- 4 files changed, 31 insertions(+), 24 deletions(-) diff --git a/src/screens/events/EventCard.tsx b/src/screens/events/EventCard.tsx index 2a21f63a..e50f00a6 100644 --- a/src/screens/events/EventCard.tsx +++ b/src/screens/events/EventCard.tsx @@ -9,12 +9,13 @@ import { SHPEEvent } from '../../types/events' const EventCard = ({ event, navigation }: { event: SHPEEvent, navigation: any }) => { const userContext = useContext(UserContext); const { userInfo } = userContext!; + const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; const hasPrivileges = (userInfo?.publicInfo?.roles?.admin?.valueOf() || userInfo?.publicInfo?.roles?.officer?.valueOf() || userInfo?.publicInfo?.roles?.developer?.valueOf()); return ( - {truncateStringWithEllipsis(event.name!)} + {truncateStringWithEllipsis(event.name!)} {event.locationName ? ( - {truncateStringWithEllipsis(event.locationName)} + {truncateStringWithEllipsis(event.locationName)} ) : null} - {formatDate(event.startTime?.toDate()!)} + {formatDate(event.startTime?.toDate()!)} {hasPrivileges && ( diff --git a/src/screens/events/Events.tsx b/src/screens/events/Events.tsx index 73d19005..e9034097 100644 --- a/src/screens/events/Events.tsx +++ b/src/screens/events/Events.tsx @@ -15,6 +15,7 @@ import EventCard from './EventCard'; const Events = ({ navigation }: NativeStackScreenProps) => { const userContext = useContext(UserContext); const { userInfo } = userContext!; + const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; const [todayEvents, setTodayEvents] = useState([]); const [upcomingEvents, setUpcomingEvents] = useState([]); @@ -96,11 +97,11 @@ const Events = ({ navigation }: NativeStackScreenProps) => { }; return ( - + {/* Header */} - Events + Events {(!isLoading && hasPrivileges) && ( ) => { ) => { }} onPress={() => handleFilterSelect("myEvents")} > - My Events + My Events ) => { }} onPress={() => handleFilterSelect("clubWide")} > - Club Wide + Club Wide {Object.values(EventType).map((type) => ( ) => { }} onPress={() => handleFilterSelect(type)} > - {type} + {type} ))} @@ -196,14 +197,14 @@ const Events = ({ navigation }: NativeStackScreenProps) => { {filteredEvents(todayEvents).length === 0 && filteredEvents(upcomingEvents).length === 0 && filteredEvents(pastEvents).length === 0 ? ( - No Events + No Events ) : ( {/* Today's Events */} {filteredEvents(todayEvents).length !== 0 && ( - Today's Events + Today's Events {filteredEvents(todayEvents)?.map((event: SHPEEvent, index) => { return ( ) => { {/* Upcoming Events */} {filteredEvents(upcomingEvents).length !== 0 && ( - Upcoming Events + Upcoming Events {filteredEvents(upcomingEvents)?.map((event: SHPEEvent, index) => { return ( 0 && "mt-8"}`}> @@ -272,7 +273,7 @@ const Events = ({ navigation }: NativeStackScreenProps) => { {/* Past Events */} {filteredEvents(pastEvents).length !== 0 && ( - Past Events + Past Events {filteredEvents(pastEvents)?.map((event: SHPEEvent, index) => { return ( 0 && "mt-8"}`}> diff --git a/src/screens/events/PastEvents.tsx b/src/screens/events/PastEvents.tsx index c8768355..d0cfce60 100644 --- a/src/screens/events/PastEvents.tsx +++ b/src/screens/events/PastEvents.tsx @@ -1,5 +1,5 @@ import { View, Text, ScrollView, TouchableOpacity, NativeSyntheticEvent, NativeScrollEvent, ActivityIndicator } from 'react-native' -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useCallback, useContext, useEffect, useRef, useState } from 'react' import { SafeAreaView } from 'react-native-safe-area-context' import { Octicons } from '@expo/vector-icons'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; @@ -7,8 +7,13 @@ import { EventsStackParams } from '../../types/navigation'; import { SHPEEvent } from '../../types/events'; import { getPastEvents } from '../../api/firebaseUtils'; import EventCard from './EventCard'; +import { UserContext } from '../../context/UserContext'; const PastEvents = ({ navigation }: NativeStackScreenProps) => { + const userContext = useContext(UserContext); + const { userInfo } = userContext!; + const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const [pastEvents, setPastEvents] = useState([]); const [loading, setLoading] = useState(false); const [endOfData, setEndOfData] = useState(false); @@ -46,7 +51,7 @@ const PastEvents = ({ navigation }: NativeStackScreenProps) = }, [loading, endOfData, setPastEvents]); return ( - + ) = > - Past Events + Past Events navigation.goBack()} className='py-1 px-4'> - + diff --git a/tailwind.config.js b/tailwind.config.js index 7c06b809..32c8e43c 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -14,10 +14,10 @@ module.exports = { "pale-orange": "#EF9260", "continue-dark": "#ED652F", "continue-light": "#C24E3A", - "primary-bg-dark": "#121212", - "primary-bg-light": "#FAF9F6", - "secondary-bg-dark": "#2a2a2a", - "secondary-bg-light": "#FFFFFF", + "primary-bg-dark": "#000000", + "primary-bg-light": "#FFFFFF", + "secondary-bg-dark": "#262626", + "secondary-bg-light": "#FAF9F6", "red-1": "#FF0000", "primary-blue": "#1870B8", "secondary-blue-1": "#468DC6", From 2cbb836beb2abbd34b928c0beacd5f1b646a25f7 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 16 Jun 2024 20:56:36 -0500 Subject: [PATCH 068/198] fix color --- src/screens/events/PastEvents.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/screens/events/PastEvents.tsx b/src/screens/events/PastEvents.tsx index d0cfce60..db2cf434 100644 --- a/src/screens/events/PastEvents.tsx +++ b/src/screens/events/PastEvents.tsx @@ -62,7 +62,7 @@ const PastEvents = ({ navigation }: NativeStackScreenProps) = Past Events navigation.goBack()} className='py-1 px-4'> - + From b6258aabbeaaeb42e33747ef10041bfd36c49aa2 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 16 Jun 2024 21:27:52 -0500 Subject: [PATCH 069/198] add dark mode status bar --- src/screens/events/Events.tsx | 2 ++ src/screens/events/PastEvents.tsx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/screens/events/Events.tsx b/src/screens/events/Events.tsx index e9034097..98f9cea5 100644 --- a/src/screens/events/Events.tsx +++ b/src/screens/events/Events.tsx @@ -11,6 +11,7 @@ import { EventType, SHPEEvent } from '../../types/events'; import { Images } from '../../../assets'; import { formatTime } from '../../helpers/timeUtils'; import EventCard from './EventCard'; +import { StatusBar } from 'expo-status-bar'; const Events = ({ navigation }: NativeStackScreenProps) => { const userContext = useContext(UserContext); @@ -98,6 +99,7 @@ const Events = ({ navigation }: NativeStackScreenProps) => { return ( + {/* Header */} diff --git a/src/screens/events/PastEvents.tsx b/src/screens/events/PastEvents.tsx index db2cf434..e4773e31 100644 --- a/src/screens/events/PastEvents.tsx +++ b/src/screens/events/PastEvents.tsx @@ -8,6 +8,7 @@ import { SHPEEvent } from '../../types/events'; import { getPastEvents } from '../../api/firebaseUtils'; import EventCard from './EventCard'; import { UserContext } from '../../context/UserContext'; +import { StatusBar } from 'expo-status-bar'; const PastEvents = ({ navigation }: NativeStackScreenProps) => { const userContext = useContext(UserContext); @@ -52,6 +53,7 @@ const PastEvents = ({ navigation }: NativeStackScreenProps) = return ( + Date: Sun, 16 Jun 2024 21:36:37 -0500 Subject: [PATCH 070/198] update create event screen ui --- src/screens/events/CreateEvent.tsx | 77 ++++++++++++++++++++---------- tailwind.config.js | 4 +- 2 files changed, 56 insertions(+), 25 deletions(-) diff --git a/src/screens/events/CreateEvent.tsx b/src/screens/events/CreateEvent.tsx index 60c18816..8db75b26 100644 --- a/src/screens/events/CreateEvent.tsx +++ b/src/screens/events/CreateEvent.tsx @@ -1,4 +1,4 @@ -import { View, Text, TouchableOpacity, Image, ScrollView, Alert, Platform } from 'react-native' +import { View, Text, TouchableOpacity, Alert } from 'react-native' import React, { useContext, useState } from 'react' import { CommitteeMeeting, CustomEvent, EventType, GeneralMeeting, IntramuralEvent, SHPEEvent, SocialEvent, StudyHours, VolunteerEvent, Workshop } from '../../types/events' import { SafeAreaView } from 'react-native-safe-area-context' @@ -18,70 +18,100 @@ import { UserContext } from '../../context/UserContext'; import { StatusBar } from 'expo-status-bar'; const CreateEvent = ({ navigation }: NativeStackScreenProps) => { - const [selectedEventType, setSelectedEventType] = useState(); - const { userInfo } = useContext(UserContext)!; - + const userContext = useContext(UserContext); + const { userInfo } = userContext!; const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const [selectedEventType, setSelectedEventType] = useState(); + const EventTypeButton = ({ eventType, label, Image }: { eventType: EventType, label: string, Image?: React.FC> }) => { return ( - + setSelectedEventType(eventType)} + className={`w-[100%] flex-row px-2 py-4 items-center rounded-lg border border-grey-dark bg-secondary-bg-light`} + style={{ + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + + elevation: 5, + }} + onPress={() => { + if (selectedEventType == eventType) { + setSelectedEventType(undefined); + return; + } + setSelectedEventType(eventType) + }} > - - + + {Image && } - {label} + {label} ) } return ( - + {/* Header */} - - - Create Event + + + Create Event - navigation.goBack()} > + navigation.goBack()} className='py-1 px-4'> {/* Form */} - - - Choose the event type that you want to create + + + Choose the event type - - + + + + + + + + + + + + + + {selectedEventType && ( { let newEvent: SHPEEvent | undefined = undefined; @@ -122,8 +152,7 @@ const CreateEvent = ({ navigation }: NativeStackScreenProps) }} /> )} - - + ) diff --git a/tailwind.config.js b/tailwind.config.js index 32c8e43c..a3776d19 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -18,10 +18,12 @@ module.exports = { "primary-bg-light": "#FFFFFF", "secondary-bg-dark": "#262626", "secondary-bg-light": "#FAF9F6", - "red-1": "#FF0000", + "grey-dark": "#808080", + "grey-light": "#B4B4B4", "primary-blue": "#1870B8", "secondary-blue-1": "#468DC6", "secondary-blue-2": "#E8F1F8", + "red-1": "#FF0000", }, }, }, From 6fc6252de7c4c17a446a9e9aed26b4b7d0f4fd3f Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 17 Jun 2024 12:14:42 -0500 Subject: [PATCH 071/198] remove steps on event creation screens --- .../events/SetLocationEventDetails.tsx | 32 ++----------- .../events/SetSpecificEventDetails.tsx | 45 +++++++++---------- 2 files changed, 23 insertions(+), 54 deletions(-) diff --git a/src/screens/events/SetLocationEventDetails.tsx b/src/screens/events/SetLocationEventDetails.tsx index 2e05baf3..9ebae07a 100644 --- a/src/screens/events/SetLocationEventDetails.tsx +++ b/src/screens/events/SetLocationEventDetails.tsx @@ -1,12 +1,11 @@ import { View, Text, TouchableOpacity, TextInput } from 'react-native' -import React, { useContext, useEffect, useState } from 'react' +import React, { useContext, useState } from 'react' import { EventProps, UpdateEventScreenRouteProp } from '../../types/navigation' import { useRoute } from '@react-navigation/core'; import { UserContext } from '../../context/UserContext'; import { StatusBar } from 'expo-status-bar'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Octicons } from '@expo/vector-icons'; -import { GooglePlaceDetail } from 'react-native-google-places-autocomplete'; import { GeoPoint } from 'firebase/firestore'; import LocationPicker from '../../components/LocationPicker'; import InteractButton from '../../components/InteractButton'; @@ -15,7 +14,8 @@ import InteractButton from '../../components/InteractButton'; const SetLocationEventDetails = ({ navigation }: EventProps) => { const route = useRoute(); const { event } = route.params; - const { userInfo } = useContext(UserContext)!; + const userContext = useContext(UserContext); + const { userInfo } = userContext!; const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; const [locationName, setLocationName] = useState(event.locationName ?? undefined); @@ -35,32 +35,6 @@ const SetLocationEventDetails = ({ navigation }: EventProps) => { - - - - - - General - - - - - - - - - Specific - - - - - - - Location - - - - Location Name { const route = useRoute(); const { event } = route.params; - const { userInfo } = useContext(UserContext)!; + const userContext = useContext(UserContext); + const { userInfo } = userContext!; const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const [selectableCommittees, setSelectableCommittees] = useState([]); const dropDownRefCommittee = useRef(null); const [openDropdown, setOpenDropdown] = useState(null); @@ -32,6 +34,8 @@ const SetSpecificEventDetails = ({ navigation }: EventProps) => { const [startTimeBuffer, setStartTimeBuffer] = useState(event.startTimeBuffer ?? undefined); const [endTimeBuffer, setEndTimeBuffer] = useState(event.endTimeBuffer ?? undefined); const [hiddenEvent, setHiddenEvent] = useState(event.hiddenEvent ?? undefined); + const [isGeneral, setIsGeneral] = useState(event.general ?? false); + const eventTypeNotification = ["Study Hours", "Workshop", "Volunteer Event", "Social Event", "Intramural Event"] @@ -66,30 +70,6 @@ const SetSpecificEventDetails = ({ navigation }: EventProps) => { - - - - - - General - - - - - - - Specific - - - - - - - Location - - - - Associated Committee @@ -245,6 +225,20 @@ const SetSpecificEventDetails = ({ navigation }: EventProps) => { )} + {/* toggle to make events appear on general tab*/} + + Event Scope + + Club-Wide Event + setIsGeneral(previousState => !previousState)} + value={isGeneral} + /> + + Hidden Event All Hidden event will not be display in events screent @@ -294,6 +288,7 @@ const SetSpecificEventDetails = ({ navigation }: EventProps) => { startTimeBuffer, endTimeBuffer, hiddenEvent, + general: isGeneral }); navigation.navigate("setLocationEventDetails", { event: event }); } From ae45458915d31008af2a4d0b3ed84d135b2f0bf4 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 17 Jun 2024 13:20:37 -0500 Subject: [PATCH 072/198] add keyboard avoiding view for scrollview --- package.json | 2 ++ yarn.lock | 23 ++++++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index fb15107f..a4c6e30f 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "dev-client-sim": "eas build --profile development-sim" }, "dependencies": { + "@pietile-native-kit/keyboard-aware-scrollview": "^1.5.0", + "@pietile-native-kit/keyboard-aware-srollview": "^1.3.3", "@react-native-async-storage/async-storage": "1.23.1", "@react-native-community/datetimepicker": "8.0.1", "@react-native-community/slider": "4.5.2", diff --git a/yarn.lock b/yarn.lock index d3d4f8ed..934b3380 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3048,6 +3048,18 @@ resolved "https://registry.npmjs.org/@oclif/screen/-/screen-3.0.4.tgz" integrity sha512-IMsTN1dXEXaOSre27j/ywGbBjrzx0FNd1XmuhCWCB9NTPrhWI1Ifbz+YLSEcstfQfocYsrbrIessxXb2oon4lA== +"@pietile-native-kit/keyboard-aware-scrollview@^1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@pietile-native-kit/keyboard-aware-scrollview/-/keyboard-aware-scrollview-1.5.0.tgz#c411380e57800e6f165cabd43e645a36b6cf78b3" + integrity sha512-a+IcOr2GwHM6x/j8AUcEaT43Paj/nD3rWqLFeh4MOZ6wMZ+Ye3slm6ctuLospMkJe+NCzsEmdd3+VvuIS3J4PA== + +"@pietile-native-kit/keyboard-aware-srollview@^1.3.3": + version "1.3.3" + resolved "https://registry.yarnpkg.com/@pietile-native-kit/keyboard-aware-srollview/-/keyboard-aware-srollview-1.3.3.tgz#a687d44f3dcc97f786c0a5d9d7f5fe1fb891c0ae" + integrity sha512-OxT1JR5iNOBWWIuCPlYhAiCyT74WOPOIPkLYl024uOsc/tGXwCIsHBUCuNwALvuRFVyqoZF82WJ0ig9AiflceQ== + dependencies: + prop-types "~15.7.0" + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -9265,6 +9277,15 @@ prop-types@^15.6.0, prop-types@^15.7.2, prop-types@^15.8.0, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" +prop-types@~15.7.0: + version "15.7.2" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" + integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.8.1" + protobufjs@^7.2.4: version "7.2.5" resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.5.tgz" @@ -9396,7 +9417,7 @@ react-freeze@^1.0.0: resolved "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== -react-is@^16.13.0, react-is@^16.13.1, react-is@^16.7.0: +react-is@^16.13.0, react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.1: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== From 34e0b362711ce75329b36c657db718e3d1c9fa35 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 17 Jun 2024 14:09:43 -0500 Subject: [PATCH 073/198] implement updated general event detail screen --- src/screens/events/SetGeneralEventDetails.tsx | 654 ++++++++---------- 1 file changed, 299 insertions(+), 355 deletions(-) diff --git a/src/screens/events/SetGeneralEventDetails.tsx b/src/screens/events/SetGeneralEventDetails.tsx index d8c829cf..2f523209 100644 --- a/src/screens/events/SetGeneralEventDetails.tsx +++ b/src/screens/events/SetGeneralEventDetails.tsx @@ -1,9 +1,10 @@ -import { View, Text, TouchableOpacity, ScrollView, TextInput, Alert, TouchableHighlight, KeyboardAvoidingView, Platform, Image, Switch } from 'react-native'; +import { View, Text, TouchableOpacity, TextInput, Alert, TouchableHighlight, Platform, Image } from 'react-native'; import React, { useContext, useState } from 'react'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Octicons, FontAwesome } from '@expo/vector-icons'; -import { EventProps, UpdateEventScreenRouteProp } from '../../types/navigation'; import { useRoute } from '@react-navigation/core'; +import { KeyboardAwareScrollView } from '@pietile-native-kit/keyboard-aware-scrollview'; +import { EventProps, UpdateEventScreenRouteProp } from '../../types/navigation'; import { Timestamp } from 'firebase/firestore'; import InteractButton from '../../components/InteractButton'; import { UserContext } from '../../context/UserContext'; @@ -13,7 +14,6 @@ import { formatDate, formatTime } from '../../helpers/timeUtils'; import { getBlobFromURI, selectImage } from '../../api/fileSelection'; import * as ImagePicker from "expo-image-picker"; import { auth } from '../../config/firebaseConfig'; -import { UploadTask } from 'firebase/storage'; import ProgressBar from '../../components/ProgressBar'; import { StatusBar } from 'expo-status-bar'; import { uploadFile } from '../../api/firebaseUtils'; @@ -21,7 +21,9 @@ import { uploadFile } from '../../api/firebaseUtils'; const SetGeneralEventDetails = ({ navigation }: EventProps) => { const route = useRoute(); const { event } = route.params; - const { userInfo } = useContext(UserContext)!; + + const userContext = useContext(UserContext); + const { userInfo } = userContext!; const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; // UI Hooks @@ -34,7 +36,6 @@ const SetGeneralEventDetails = ({ navigation }: EventProps) => { const [uploadProgress, setUploadProgress] = useState(0); const [bytesTransferred, setBytesTransferred] = useState(); const [totalBytes, setTotalBytes] = useState(); - const [currentUploadTask, setCurrentUploadTask] = useState(); // Form Data Hooks const [name, setName] = useState(""); @@ -42,7 +43,6 @@ const SetGeneralEventDetails = ({ navigation }: EventProps) => { const [endTime, setEndTime] = useState(event.endTime ?? undefined); const [description, setDescription] = useState(""); const [coverImageURI, setCoverImageURI] = useState(); - const [isGeneral, setIsGeneral] = useState(event.general ?? false); const selectCoverImage = async () => { const result = await selectImage({ @@ -62,13 +62,11 @@ const SetGeneralEventDetails = ({ navigation }: EventProps) => { CommonMimeTypes.IMAGE_FILES, `events/cover-images/${auth.currentUser?.uid.toString()}${Date.now().toString()}`, onImageUploadSuccess, - setUploadProgress ); } } } - const onImageUploadSuccess = async (URL: string) => { setCoverImageURI(URL); setIsUploading(false); @@ -76,7 +74,7 @@ const SetGeneralEventDetails = ({ navigation }: EventProps) => { if (!event) return ( - An issue occured while trying to load this page + An issue ocurred while trying to load this page navigation.goBack()} @@ -86,7 +84,297 @@ const SetGeneralEventDetails = ({ navigation }: EventProps) => { return ( - + + + + {/* Header */} + + + General Details + + navigation.goBack()} className='py-1 px-4'> + + + + + {/* Form */} + + {/* Cover Image */} + {localImageURI === undefined ? + selectCoverImage()} + > + + + + UPLOAD + + + + : + selectCoverImage()}> + + + { + setLocalImageURI(undefined); + setCoverImageURI(undefined); + }} + > + + + + } + + {isUploading && + + + + {`${((bytesTransferred ?? 0) / 1000000).toFixed(2)} / ${((totalBytes ?? 0) / 1000000).toFixed(2)} MB`} + + + } + + {/* Event Name */} + + + Event Name* + + + setName(text)} + keyboardType='ascii-capable' + enterKeyHint='enter' + /> + + + {/* Start Time Selection Buttons */} + + + + Start Date* + + + + {Platform.OS == 'android' && + + setShowStartDatePicker(true)} + className={`flex flex-row justify-between p-2 mr-4 rounded ${darkMode ? "text-white bg-secondary-bg-dark" : "text-black bg-secondary-bg-light"}`} + > + {startTime ? formatDate(startTime.toDate()) : "No date picked"} + + + setShowStartTimePicker(true)} + className={`flex flex-row justify-between p-2 rounded ${darkMode ? "text-white bg-zinc-700" : "text-black bg-zinc-200"}`} + > + <> + {startTime ? formatTime(startTime.toDate()) : "No date picked"} + + + + + } + + {Platform.OS == 'ios' && + + { + if (!date) { + console.warn("Date picked is undefined.") + } + else { + setStartTime(Timestamp.fromDate(date)); + if (endTime && date.valueOf() > endTime?.toDate().valueOf()) { + setEndTime(Timestamp.fromMillis(date.getTime() + MillisecondTimes.HOUR)); + } + } + setShowStartDatePicker(false); + }} + /> + + { + if (isUploading) { + Alert.alert("Image upload in progress.", "Please wait for image to finish uploading.") + } + else if (!date) { + console.warn("Date picked is undefined.") + } + else if (endTime && date.valueOf() > endTime?.toDate().valueOf()) { + Alert.alert("Invalid Start Time", "Event cannot start after end time.") + } + else { + setStartTime(Timestamp.fromDate(date)); + } + }} + /> + + } + + + + + + {/* End Time Selection Buttons */} + + + + End Date* + + + + + {Platform.OS == 'android' && + + setShowEndDatePicker(true)} + className={`flex flex-row justify-between p-2 mr-4 rounded ${darkMode ? "text-white bg-zinc-700" : "text-black bg-zinc-200"}`} + > + {endTime ? formatDate(endTime.toDate()) : "No date picked"} + + + setShowEndTimePicker(true)} + className={`flex flex-row justify-between p-2 rounded ${darkMode ? "text-white bg-zinc-700" : "text-black bg-zinc-200"}`} + > + <> + {endTime ? formatTime(endTime.toDate()) : "No date picked"} + + + + + } + + {Platform.OS == 'ios' && + + { + if (!date) { + console.warn("Date picked is undefined.") + } + else if (startTime && date.valueOf() < startTime?.toDate().valueOf()) { + Alert.alert("Invalid End Date", "Event cannot end before start date.") + } + else { + setEndTime(Timestamp.fromDate(date)); + } + }} + /> + + { + if (!date) { + console.warn("Date picked is undefined.") + } + else if (startTime && date.valueOf() < startTime?.toDate().valueOf()) { + Alert.alert("Invalid End Time", "Event cannot end before start time.") + } + else { + setEndTime(Timestamp.fromDate(date)); + } + setShowEndTimePicker(false); + }} + /> + + } + + + + + {/* Description */} + + Description + { + if (text.length <= 250) + setDescription(text) + }} + numberOfLines={2} + keyboardType='ascii-capable' + autoCapitalize='sentences' + multiline + style={{ textAlignVertical: 'top' }} + enterKeyHint='enter' + /> + + + + { + if (!name) { + Alert.alert("Empty Name", "Event must have a name!") + } + else if (!startTime || !endTime) { + Alert.alert("Empty Start Time or End Time", "Event MUST have start and end times.") + } + else if (startTime.toMillis() > endTime.toMillis()) { + Alert.alert("Event ends before start time", "Event cannot end before it starts.") + } + else if (event.copyFromObject) { + let modifiedDescription = description.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim(); // Remove all newlines and extra spaces + + event.copyFromObject({ + name, + startTime, + endTime, + description: modifiedDescription, + coverImageURI, + creator: auth.currentUser?.uid, + }); + navigation.navigate("SetSpecificEventDetails", { event }) + } + else { + Alert.alert("Something has gone wrong", "Event data is malformed."); + console.error("copsyFromObject() doe not exist on given event object. This means the given SHPEEvent object may be malformed. Please ensure that the object passed into parameters is an instance of a template class SHPEEvent."); + } + }} + /> + + + + + {/* Start Date Pickers */} {Platform.OS == 'android' && showStartDatePicker && { console.warn("Date picked is undefined.") } else if (endTime && date.valueOf() > endTime?.toDate().valueOf()) { - Alert.alert("Invalid Start Time", "Event cannot stard after end time.") + Alert.alert("Invalid Start Time", "Event cannot start after end time.") } else { setStartTime(Timestamp.fromDate(date)); @@ -172,350 +460,6 @@ const SetGeneralEventDetails = ({ navigation }: EventProps) => { }} /> } - - - {/* Header */} - - - {event.eventType} Info - - navigation.goBack()} > - - - - - {/* Steps */} - - - - General - - - - - - - Specific - - - - - - - Location - - - - {/* Form */} - - - Enter the basic details of your event - - - Event Name * - setName(text)} - keyboardType='ascii-capable' - enterKeyHint='enter' - /> - - - {/* Start Time Selection Buttons */} - - - Start Date * - {Platform.OS == 'android' && - setShowStartDatePicker(true)} - className={`flex flex-row justify-between p-2 mr-4 rounded ${darkMode ? "text-white bg-zinc-700" : "text-black bg-zinc-200"}`} - > - <> - {startTime ? formatDate(startTime.toDate()) : "No date picked"} - - - - } - {Platform.OS == 'ios' && - - - { - if (!date) { - console.warn("Date picked is undefined.") - } - else { - setStartTime(Timestamp.fromDate(date)); - if (endTime && date.valueOf() > endTime?.toDate().valueOf()) { - setEndTime(Timestamp.fromMillis(date.getTime() + MillisecondTimes.HOUR)); - } - } - setShowStartDatePicker(false); - }} - /> - - } - - - Start Time * - {Platform.OS == 'android' && - setShowStartTimePicker(true)} - className={`flex flex-row justify-between p-2 rounded ${darkMode ? "text-white bg-zinc-700" : "text-black bg-zinc-200"}`} - > - <> - {startTime ? formatTime(startTime.toDate()) : "No date picked"} - - - - } - {Platform.OS == 'ios' && - - - { - if (isUploading) { - Alert.alert("Image upload in progress.", "Please wait for image to finish uploading.") - } - else if (!date) { - console.warn("Date picked is undefined.") - } - else if (endTime && date.valueOf() > endTime?.toDate().valueOf()) { - Alert.alert("Invalid Start Time", "Event cannot stard after end time.") - } - else { - setStartTime(Timestamp.fromDate(date)); - } - }} - /> - - } - - - - {/* End Time Selection Buttons */} - - - End Date * - {Platform.OS == 'android' && - setShowEndDatePicker(true)} - className={`flex flex-row justify-between p-2 mr-4 rounded ${darkMode ? "text-white bg-zinc-700" : "text-black bg-zinc-200"}`} - > - <> - {endTime ? formatDate(endTime.toDate()) : "No date picked"} - - - - } - {Platform.OS == 'ios' && - - - { - if (!date) { - console.warn("Date picked is undefined.") - } - else if (startTime && date.valueOf() < startTime?.toDate().valueOf()) { - Alert.alert("Invalid End Date", "Event cannot end before start date.") - } - else { - setEndTime(Timestamp.fromDate(date)); - } - }} - /> - - } - - - End Time * - {Platform.OS == 'android' && - setShowEndTimePicker(true)} - className={`flex flex-row justify-between p-2 rounded ${darkMode ? "text-white bg-zinc-700" : "text-black bg-zinc-200"}`} - > - <> - {endTime ? formatTime(endTime.toDate()) : "No date picked"} - - - - } - {Platform.OS == 'ios' && - - - { - if (!date) { - console.warn("Date picked is undefined.") - } - else if (startTime && date.valueOf() < startTime?.toDate().valueOf()) { - Alert.alert("Invalid End Time", "Event cannot end before start time.") - } - else { - setEndTime(Timestamp.fromDate(date)); - } - setShowEndTimePicker(false); - }} - /> - - } - - - - {/* toggle to make events appear on general tab*/} - - Event Scope - - Club-Wide Event - setIsGeneral(previousState => !previousState)} - value={isGeneral} - /> - - - - - Description - { - if (text.length <= 250) - setDescription(text) - }} - numberOfLines={2} - keyboardType='ascii-capable' - autoCapitalize='sentences' - multiline - style={{ textAlignVertical: 'top' }} - enterKeyHint='enter' - /> - - - Cover Image - {localImageURI === undefined ? - selectCoverImage()} - > - - - - UPLOAD - - - - : - selectCoverImage()} - > - - - { - setLocalImageURI(undefined); - setCoverImageURI(undefined); - }} - > - - - - - - } - - - {isUploading && - - - - {`${((bytesTransferred ?? 0) / 1000000).toFixed(2)} / ${((totalBytes ?? 0) / 1000000).toFixed(2)} MB`} - - - } - - { - if (!name) { - Alert.alert("Empty Name", "Event must have a name!") - } - else if (!startTime || !endTime) { - Alert.alert("Empty Start Time or End Time", "Event MUST have start and end times.") - } - else if (startTime.toMillis() > endTime.toMillis()) { - Alert.alert("Event ends before start time", "Event cannot end before it starts.") - } - else if (event.copyFromObject) { - let modifiedDescription = description.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim(); // Remove all newlines and extra spaces - - event.copyFromObject({ - name, - startTime, - endTime, - description: modifiedDescription, - coverImageURI, - creator: auth.currentUser?.uid, - general: isGeneral - }); - navigation.navigate("SetSpecificEventDetails", { event }) - } - else { - Alert.alert("Something has gone wrong", "Event data is malformed."); - console.error("copyFromObject() does not exist on given event object. This means the given SHPEEvent object may be malformed. Please ensure that the object passed into parameters is an instance of a template class SHPEEvent."); - } - }} - /> - - ); }; From 1c33fd8b60cdd51a24cbff4586e5c327fa697b9c Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 17 Jun 2024 16:30:57 -0500 Subject: [PATCH 074/198] add updated specific detail screen --- src/screens/events/SetGeneralEventDetails.tsx | 4 +- .../events/SetSpecificEventDetails.tsx | 621 +++++++++++------- 2 files changed, 396 insertions(+), 229 deletions(-) diff --git a/src/screens/events/SetGeneralEventDetails.tsx b/src/screens/events/SetGeneralEventDetails.tsx index 2f523209..5fed8403 100644 --- a/src/screens/events/SetGeneralEventDetails.tsx +++ b/src/screens/events/SetGeneralEventDetails.tsx @@ -102,7 +102,7 @@ const SetGeneralEventDetails = ({ navigation }: EventProps) => { {/* Cover Image */} {localImageURI === undefined ? selectCoverImage()} > { : - selectCoverImage()}> + selectCoverImage()}> { const route = useRoute(); const { event } = route.params; + const userContext = useContext(UserContext); const { userInfo } = userContext!; const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const insets = useSafeAreaInsets(); + const [selectableCommittees, setSelectableCommittees] = useState([]); + const [advanceOptionsModal, setAdvanceOptionsModal] = useState(false); const dropDownRefCommittee = useRef(null); const [openDropdown, setOpenDropdown] = useState(null); @@ -31,8 +37,8 @@ const SetSpecificEventDetails = ({ navigation }: EventProps) => { const [pointsPerHour, setPointsPerHour] = useState(event.pointsPerHour ?? undefined); const [nationalConventionEligible, setNationalConventionEligible] = useState(event.nationalConventionEligible ?? undefined); const [notificationSent, setNotificationSent] = useState(event.notificationSent ?? undefined); - const [startTimeBuffer, setStartTimeBuffer] = useState(event.startTimeBuffer ?? undefined); - const [endTimeBuffer, setEndTimeBuffer] = useState(event.endTimeBuffer ?? undefined); + const [startTimeBuffer, setStartTimeBuffer] = useState(event.startTimeBuffer ?? 20 * 60000); + const [endTimeBuffer, setEndTimeBuffer] = useState(event.endTimeBuffer ?? 20 * 60000); const [hiddenEvent, setHiddenEvent] = useState(event.hiddenEvent ?? undefined); const [isGeneral, setIsGeneral] = useState(event.general ?? false); @@ -57,249 +63,418 @@ const SetSpecificEventDetails = ({ navigation }: EventProps) => { }; return ( - - - - {/* Header */} - - - {event.eventType} Info + + + + + {/* Header */} + + + Specific Details + + navigation.goBack()} className='py-1 px-4'> + + - navigation.goBack()} > - - - - - Associated Committee - - setCommittee(item.iso)} - searchKey="committee" - label="Select Committee" - isOpen={openDropdown === 'committee'} - onToggle={() => toggleDropdown('committee')} - displayType='value' - ref={dropDownRefCommittee} - disableSearch - /> - - - {event.signInPoints !== undefined && - - Points for Signing In * - setSignInPoints(Number(item.iso))} - searchKey="point" - label="Select Points" - isOpen={openDropdown === 'pointSignIn'} - onToggle={() => toggleDropdown('pointSignIn')} - displayType='iso' - disableSearch - /> - - } - {event.signOutPoints !== undefined && - - Points for Signing Out * - setSignOutPoints(Number(item.iso))} - searchKey="point" - label="Select Points" - isOpen={openDropdown === 'pointSignOut'} - onToggle={() => toggleDropdown('pointSignOut')} - displayType='iso' - disableSearch - /> - - } - {event.pointsPerHour !== undefined && - - Hourly Points* - setPointsPerHour(Number(item.iso))} - searchKey="point" - label="Select Points" - isOpen={openDropdown === 'pointPerHour'} - onToggle={() => toggleDropdown('pointPerHour')} - displayType='iso' - disableSearch - /> - - } - + {/* Form */} + + {/* Point Selection */} + + Points Selection - - - Start Time Buffer (Min) - setStartTimeBuffer(Number(item.iso) * 60000)} // Convert Minute to Milliseconds - searchKey="time" - label="Select Time" - isOpen={openDropdown === 'startTimeBuffer'} - onToggle={() => toggleDropdown('startTimeBuffer')} - displayType='iso' - disableSearch - /> + + {event.signInPoints !== undefined && ( + + Sign In + + + {points.map((point) => ( + { + if (signInPoints === point) { + setSignInPoints(undefined); + } else { + setSignInPoints(point); + } + }} + > + +{point} + + ))} + + + )} + + {event.signOutPoints !== undefined && ( + + Sign Out + + + {points.map((point) => ( + { + if (signOutPoints === point) { + setSignOutPoints(undefined); + } else { + setSignOutPoints(point); + } + }} + > + +{point} + + ))} + + + )} + + {event.pointsPerHour !== undefined && ( + + Hourly + + + {points.map((point) => ( + { + if (pointsPerHour === point) { + setPointsPerHour(undefined); + } else { + setPointsPerHour(point); + } + }} + > + +{point} + + ))} + + + )} + + + {/* Event Scope (Club-Wide, Associated Committees, Notifications)*/} + + Event Scope - - End Time Buffer (Min) - setEndTimeBuffer(Number(item.iso) * 60000)} // Convert Minute to Milliseconds - searchKey="time" - label="Select Time" - isOpen={openDropdown === 'endTimeBuffer'} - onToggle={() => toggleDropdown('endTimeBuffer')} - displayType='iso' - disableSearch - /> + + + Club-Wide + setIsGeneral(previousState => !previousState)} + value={isGeneral} + /> + + + + Associated Committee + + setCommittee(item.iso)} + searchKey="committee" + label="Select Committee" + isOpen={openDropdown === 'committee'} + onToggle={() => toggleDropdown('committee')} + displayType='value' + ref={dropDownRefCommittee} + disableSearch + /> + + + + + + + Notifications + setNotificationSent(previousState => !previousState)} + value={!notificationSent} + /> + + + {!notificationSent && ( + + {isGeneral ? ( + All Members will be notified + ) : ( + <> + {committee && committee !== "" && eventTypeNotification.includes(event.eventType!) ? ( + This will notify {reverseFormattedFirebaseName(committee)} members and those interested in {event.eventType} + ) : ( + + {committee && committee !== "" && ( + Member in {reverseFormattedFirebaseName(committee)} will be notified + )} + + {eventTypeNotification.includes(event.eventType!) && ( + Members interested in {event.eventType} will be notified + )} + + )} + + )} + + )} + + - - {event.workshopType !== undefined && - - Workshop Type * - { - setWorkshopType(item.iso as WorkshopType); - switch (item.iso) { - case "Academic": - setSignInPoints(2); - case "Professional": - setSignInPoints(3); + + setAdvanceOptionsModal(true)} + > + Advanced Options + + + { + if (workshopType == 'None') { + Alert.alert("Workshop type is 'None'", "The workshop type must be selected."); + } + else if (event.copyFromObject) { + event.copyFromObject({ + signInPoints, + signOutPoints, + pointsPerHour, + committee, + nationalConventionEligible, + notificationSent, + startTimeBuffer, + endTimeBuffer, + hiddenEvent, + general: isGeneral + }); + navigation.navigate("setLocationEventDetails", { event: event }); } + }} - searchKey="workshopType" - label="Select Workshop Type" - isOpen={openDropdown === 'workshopType'} - onToggle={() => toggleDropdown('workshopType')} - displayType='value' - disableSearch /> + + Specific details can not be changed later* + - } + + + {openDropdown && ( + + )} + - {/* When notification is set to off. Then the event's notificationSent will be set to true */} - - Notification Settings - - Notification + + + {/* Header */} + + + Advanced Options + + setAdvanceOptionsModal(false)} className='py-1 px-4'> + + + + + {/* Advance Options */} + + + Eligible for National Convention setNotificationSent(previousState => !previousState)} - value={!notificationSent} + onValueChange={() => setNationalConventionEligible(previousState => !previousState)} + value={nationalConventionEligible} /> - {!notificationSent && ( - - {event.general ? ( - Event Notification will be sent to all users - ) : ( - <> - {(committee && committee !== "") && ( - - The following committee will be notified: {committee} - - )} - {eventTypeNotification.includes(event.eventType!) && ( - - The following interest will be notified for {event.eventType} - - )} - - )} - - )} - - {/* toggle to make events appear on general tab*/} - - Event Scope - - Club-Wide Event - setIsGeneral(previousState => !previousState)} - value={isGeneral} - /> - - - Hidden Event - All Hidden event will not be display in events screent - - Hide Event + + Hidden Event setHiddenEvent(previousState => !previousState)} value={hiddenEvent} /> - - - National Convention - setNationalConventionEligible(!nationalConventionEligible)} + - - {nationalConventionEligible && ( - - )} + Start Buffer (mins) + + setStartTimeBuffer(Number(item.iso) * 60000)} // Convert Minute to Milliseconds + searchKey="time" + label="Select Time" + isOpen={openDropdown === 'startTimeBuffer'} + onToggle={() => toggleDropdown('startTimeBuffer')} + displayType='iso' + disableSearch + selectedItemProp={startTimeBuffer ? { value: ((startTimeBuffer / 60000).toFixed(0)).toString(), iso: ((startTimeBuffer / 60000).toFixed(0)).toString() } : null} + /> - Eligible for National Convention - - + + + { - if (workshopType == 'None') { - Alert.alert("Workshop type is 'None'", "The workshop type must be selected."); - } - else if (event.copyFromObject) { - event.copyFromObject({ - signInPoints, - signOutPoints, - pointsPerHour, - committee, - nationalConventionEligible, - notificationSent, - startTimeBuffer, - endTimeBuffer, - hiddenEvent, - general: isGeneral - }); - navigation.navigate("setLocationEventDetails", { event: event }); - } - - }} - /> - - + elevation: 5, + }} + > + End Buffer (mins) + + setEndTimeBuffer(Number(item.iso) * 60000)} // Convert Minute to Milliseconds + searchKey="time" + label="Select Time" + isOpen={openDropdown === 'endTimeBuffer'} + onToggle={() => toggleDropdown('endTimeBuffer')} + displayType='iso' + disableSearch + selectedItemProp={startTimeBuffer ? { value: ((startTimeBuffer / 60000).toFixed(0)).toString(), iso: ((startTimeBuffer / 60000).toFixed(0)).toString() } : null} + /> + + + - - + + ); }; @@ -310,15 +485,7 @@ const createCommitteeList = (committees: Committee[]) => { })); }; -const POINTS = [ - { point: "0", iso: "0" }, - { point: "1", iso: "1" }, - { point: "2", iso: "2" }, - { point: "3", iso: "3" }, - { point: "4", iso: "4" }, - { point: "5", iso: "5" }, -] - +const points = [0, 1, 2, 3, 4]; const TIMES = [ { time: "0", iso: "0" }, From f9e55e17e19958d244a6ccbd7e3110a70e97fbea Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 17 Jun 2024 16:35:56 -0500 Subject: [PATCH 075/198] temp remove setting workshopType? not being used for anything --- src/screens/events/SetSpecificEventDetails.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/screens/events/SetSpecificEventDetails.tsx b/src/screens/events/SetSpecificEventDetails.tsx index 5b02f690..43314cb5 100644 --- a/src/screens/events/SetSpecificEventDetails.tsx +++ b/src/screens/events/SetSpecificEventDetails.tsx @@ -318,10 +318,11 @@ const SetSpecificEventDetails = ({ navigation }: EventProps) => { textClassName='text-center text-white text-2xl font-bold' label='Next' onPress={() => { - if (workshopType == 'None') { - Alert.alert("Workshop type is 'None'", "The workshop type must be selected."); - } - else if (event.copyFromObject) { + // if (workshopType == 'None') { + // Alert.alert("Workshop type is 'None'", "The workshop type must be selected."); + // } + // else + if (event.copyFromObject) { event.copyFromObject({ signInPoints, signOutPoints, From da54725427896266dfc2d6717599134ab7083a75 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 17 Jun 2024 18:24:10 -0500 Subject: [PATCH 076/198] update location pick and fix set end buffer dropdown --- src/components/LocationPicker.tsx | 72 ++++++----- .../events/SetLocationEventDetails.tsx | 30 +++-- .../events/SetSpecificEventDetails.tsx | 117 ++++++++++-------- 3 files changed, 122 insertions(+), 97 deletions(-) diff --git a/src/components/LocationPicker.tsx b/src/components/LocationPicker.tsx index 06f00210..d64fd5ae 100644 --- a/src/components/LocationPicker.tsx +++ b/src/components/LocationPicker.tsx @@ -71,9 +71,9 @@ const LocationPicker = ({ onLocationChange, initialCoordinate = zacharyCoords }: )} - {/* Search Box for to search using Google Places */} - - + + + {/* Search Box for to search using Google Places */} console.error(error)} /> - - - { if (userLocation?.coords.latitude && userLocation?.coords.longitude) { setDraggableMarkerCoord({ @@ -129,44 +126,55 @@ const LocationPicker = ({ onLocationChange, initialCoordinate = zacharyCoords }: }} > + + + + {!geofencingEnabled && ( + + Area Restriction + { + setGeofencingEnabled(true); + setRadius(initialRadius); + }}> + Enable + + + )} + {geofencingEnabled && ( - + { - setInitialRadius(value); setRadius(value) + if (value === 0) { + setRadius(undefined); + setGeofencingEnabled(false); + } }} - minimumTrackTintColor="#72A9BE" + minimumTrackTintColor="#1870B8" /> + + {radius?.toFixed(0)}m )} - - - - {/* Radius Adjustment Slider */} - - - Area Restriction - { - setGeofencingEnabled(value) - if (value) { - setRadius(initialRadius); - } else { - setRadius(undefined); - } - }} - /> - ); diff --git a/src/screens/events/SetLocationEventDetails.tsx b/src/screens/events/SetLocationEventDetails.tsx index 9ebae07a..76c4a9da 100644 --- a/src/screens/events/SetLocationEventDetails.tsx +++ b/src/screens/events/SetLocationEventDetails.tsx @@ -14,6 +14,7 @@ import InteractButton from '../../components/InteractButton'; const SetLocationEventDetails = ({ navigation }: EventProps) => { const route = useRoute(); const { event } = route.params; + const userContext = useContext(UserContext); const { userInfo } = userContext!; const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; @@ -23,22 +24,26 @@ const SetLocationEventDetails = ({ navigation }: EventProps) => { const [geofencingRadius, setGeofencingRadius] = useState(event.geofencingRadius ?? undefined); return ( - + {/* Header */} - - - {event.eventType} Info + + + Location Details - navigation.goBack()} > + navigation.goBack()} className='py-1 px-4'> - - Location Name + {/* Event Name */} + + + Location Name* + + { }} /> - + + { if (event.copyFromObject) { event.copyFromObject({ diff --git a/src/screens/events/SetSpecificEventDetails.tsx b/src/screens/events/SetSpecificEventDetails.tsx index 43314cb5..01c84a57 100644 --- a/src/screens/events/SetSpecificEventDetails.tsx +++ b/src/screens/events/SetSpecificEventDetails.tsx @@ -306,7 +306,7 @@ const SetSpecificEventDetails = ({ navigation }: EventProps) => { - + setAdvanceOptionsModal(true)} > @@ -341,7 +341,7 @@ const SetSpecificEventDetails = ({ navigation }: EventProps) => { }} /> - Specific details can not be changed later* + Specific details can not be changed later* @@ -415,61 +415,72 @@ const SetSpecificEventDetails = ({ navigation }: EventProps) => { /> - - Start Buffer (mins) - - setStartTimeBuffer(Number(item.iso) * 60000)} // Convert Minute to Milliseconds - searchKey="time" - label="Select Time" - isOpen={openDropdown === 'startTimeBuffer'} - onToggle={() => toggleDropdown('startTimeBuffer')} - displayType='iso' - disableSearch - selectedItemProp={startTimeBuffer ? { value: ((startTimeBuffer / 60000).toFixed(0)).toString(), iso: ((startTimeBuffer / 60000).toFixed(0)).toString() } : null} - /> + + + Start Buffer (mins) + + setStartTimeBuffer(Number(item.iso) * 60000)} // Convert Minute to Milliseconds + searchKey="time" + label="Select Time" + isOpen={openDropdown === 'startTimeBuffer'} + onToggle={() => toggleDropdown('startTimeBuffer')} + displayType='iso' + disableSearch + selectedItemProp={startTimeBuffer ? { value: ((startTimeBuffer / 60000).toFixed(0)).toString(), iso: ((startTimeBuffer / 60000).toFixed(0)).toString() } : null} + /> + + + + Allow to scan QRCode {startTimeBuffer && ((startTimeBuffer / 60000).toFixed(0)).toString()} mins before event starts - + + End Buffer (mins) + + setEndTimeBuffer(Number(item.iso) * 60000)} // Convert Minute to Milliseconds + searchKey="time" + label="Select Time" + isOpen={openDropdown === 'endTimeBuffer'} + onToggle={() => toggleDropdown('endTimeBuffer')} + displayType='iso' + disableSearch + selectedItemProp={endTimeBuffer ? { value: ((endTimeBuffer / 60000).toFixed(0)).toString(), iso: ((endTimeBuffer / 60000).toFixed(0)).toString() } : null} + /> + + - elevation: 5, - }} - > - End Buffer (mins) - - setEndTimeBuffer(Number(item.iso) * 60000)} // Convert Minute to Milliseconds - searchKey="time" - label="Select Time" - isOpen={openDropdown === 'endTimeBuffer'} - onToggle={() => toggleDropdown('endTimeBuffer')} - displayType='iso' - disableSearch - selectedItemProp={startTimeBuffer ? { value: ((startTimeBuffer / 60000).toFixed(0)).toString(), iso: ((startTimeBuffer / 60000).toFixed(0)).toString() } : null} - /> + + Allow to scan QRCode {endTimeBuffer && ((endTimeBuffer / 60000).toFixed(0)).toString()} mins after event ends From 9ae17bfc7ba3ea9eac9838c9c72a802ce35f803e Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 17 Jun 2024 18:35:47 -0500 Subject: [PATCH 077/198] add text truncate and fix description box --- src/screens/events/Events.tsx | 14 +++++++++++--- src/screens/events/SetGeneralEventDetails.tsx | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/screens/events/Events.tsx b/src/screens/events/Events.tsx index 98f9cea5..086cf6ef 100644 --- a/src/screens/events/Events.tsx +++ b/src/screens/events/Events.tsx @@ -236,9 +236,9 @@ const Events = ({ navigation }: NativeStackScreenProps) => { className='absolute bottom-0 h-[70%] w-full rounded-b-lg justify-center' > - {event.name} + {truncateStringWithEllipsis(event.name!, 20)} {event.locationName ? ( - {event.locationName} + {truncateStringWithEllipsis(event.locationName, 24)} ) : null} {formatTime(event.startTime?.toDate()!)} @@ -287,7 +287,7 @@ const Events = ({ navigation }: NativeStackScreenProps) => { )} navigation.navigate("PastEvents")}> - View all past events + View more )} @@ -323,4 +323,12 @@ const Events = ({ navigation }: NativeStackScreenProps) => { type ExtendedEventType = EventType | 'myEvents' | 'clubWide'; +const truncateStringWithEllipsis = (name: string, limit = 22) => { + if (name.length > limit) { + return `${name.substring(0, limit)}...`; + } + return name; +}; + + export default Events; diff --git a/src/screens/events/SetGeneralEventDetails.tsx b/src/screens/events/SetGeneralEventDetails.tsx index 5fed8403..8cf32696 100644 --- a/src/screens/events/SetGeneralEventDetails.tsx +++ b/src/screens/events/SetGeneralEventDetails.tsx @@ -319,7 +319,7 @@ const SetGeneralEventDetails = ({ navigation }: EventProps) => { Description Date: Tue, 18 Jun 2024 13:19:38 -0500 Subject: [PATCH 078/198] small ui change to event creation screen --- src/screens/events/SetGeneralEventDetails.tsx | 7 +++++-- src/screens/events/SetLocationEventDetails.tsx | 9 ++++++++- src/screens/events/SetSpecificEventDetails.tsx | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/screens/events/SetGeneralEventDetails.tsx b/src/screens/events/SetGeneralEventDetails.tsx index 8cf32696..51612850 100644 --- a/src/screens/events/SetGeneralEventDetails.tsx +++ b/src/screens/events/SetGeneralEventDetails.tsx @@ -110,7 +110,7 @@ const SetGeneralEventDetails = ({ navigation }: EventProps) => { style={{ borderStyle: 'dashed' }} > - + UPLOAD @@ -336,7 +336,7 @@ const SetGeneralEventDetails = ({ navigation }: EventProps) => { /> - + { } }} /> + + Location details can be changed later + diff --git a/src/screens/events/SetLocationEventDetails.tsx b/src/screens/events/SetLocationEventDetails.tsx index 76c4a9da..9acaa83a 100644 --- a/src/screens/events/SetLocationEventDetails.tsx +++ b/src/screens/events/SetLocationEventDetails.tsx @@ -36,7 +36,7 @@ const SetLocationEventDetails = ({ navigation }: EventProps) => { - {/* Event Name */} + {/* Location Name */} Location Name* @@ -84,6 +84,13 @@ const SetLocationEventDetails = ({ navigation }: EventProps) => { navigation.navigate("FinalizeEvent", { event: event }); }} /> + + Location details can be changed later + + ); diff --git a/src/screens/events/SetSpecificEventDetails.tsx b/src/screens/events/SetSpecificEventDetails.tsx index 01c84a57..ad3feb2c 100644 --- a/src/screens/events/SetSpecificEventDetails.tsx +++ b/src/screens/events/SetSpecificEventDetails.tsx @@ -340,7 +340,7 @@ const SetSpecificEventDetails = ({ navigation }: EventProps) => { }} /> - + Specific details can not be changed later* From d4e2b8376a8fbc005970fd49993a993c9f406aeb Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 18 Jun 2024 13:19:54 -0500 Subject: [PATCH 079/198] add initial radius param in location picker --- src/components/LocationPicker.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/components/LocationPicker.tsx b/src/components/LocationPicker.tsx index d64fd5ae..d8ac6efc 100644 --- a/src/components/LocationPicker.tsx +++ b/src/components/LocationPicker.tsx @@ -11,17 +11,19 @@ import { Octicons } from '@expo/vector-icons'; const zacharyCoords = { latitude: 30.621160236499136, longitude: -96.3403560168198 } const initialMapDelta = { latitudeDelta: 0.0922, longitudeDelta: 0.0421 } // Size of map view -const LocationPicker = ({ onLocationChange, initialCoordinate = zacharyCoords }: { +const LocationPicker = ({ onLocationChange, initialCoordinate = zacharyCoords, initialRadius, containerClassName = "" }: { onLocationChange: (locationDetails: GooglePlaceDetail | undefined | null, radius: number | undefined) => void - initialCoordinate?: LatLng + initialCoordinate?: LatLng, + initialRadius?: number, + containerClassName?: string }) => { const [userLocation, setUserLocation] = useState(); const [locationDetails, setLocationDetails] = useState(); const [draggableMarkerCoord, setDraggableMarkerCoord] = useState(initialCoordinate); const [mapRegion, setMapRegion] = useState({ ...initialCoordinate, ...initialMapDelta }); - const [initialRadius, setInitialRadius] = useState(100); - const [radius, setRadius] = useState(); - const [geofencingEnabled, setGeofencingEnabled] = useState(false); + const [defaultRadius, setDefaultRadius] = useState(100); + const [radius, setRadius] = useState(initialRadius); + const [geofencingEnabled, setGeofencingEnabled] = useState(initialRadius ? true : false); useEffect(() => { Location.requestForegroundPermissionsAsync() @@ -71,7 +73,7 @@ const LocationPicker = ({ onLocationChange, initialCoordinate = zacharyCoords }: )} - + {/* Search Box for to search using Google Places */} Area Restriction { setGeofencingEnabled(true); - setRadius(initialRadius); + setRadius(defaultRadius); }}> Enable From 3e596e6dbff76f2e7d70af1b24a8f7fe63555544 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 18 Jun 2024 13:36:16 -0500 Subject: [PATCH 080/198] add update event screen ui and add test cases for updating events --- src/api/firebaseUtils.ts | 66 +- src/screens/events/UpdateEvent.tsx | 1003 ++++++----------- .../events/__tests__/eventUtils.test.ts | 74 +- 3 files changed, 448 insertions(+), 695 deletions(-) diff --git a/src/api/firebaseUtils.ts b/src/api/firebaseUtils.ts index 1f003d35..c7244b70 100644 --- a/src/api/firebaseUtils.ts +++ b/src/api/firebaseUtils.ts @@ -551,26 +551,6 @@ export const isUserInBlacklist = async (uid: string): Promise => { }; - -/** - * Updates a given event - * @param id Name of event document in firestore - * @param event Object to replace firestore document - * @returns Document name firebase or null if an issue occurred - */ -export const setEvent = async (id: string, event: SHPEEvent): Promise => { - try { - const docRef = doc(db, "events", id); - await updateDoc(docRef, { - ...event - }); - return id; - } catch (error) { - console.error("Error updating document: ", error); - return null; - } -} - /** * Fetches a given event document from firestore * @param eventID Document name of event in firestore @@ -618,31 +598,6 @@ export const destroyEvent = async (eventID: string) => { } }; - -const getEventStatus = async (eventId: string): Promise => { - try { - const eventDoc = doc(db, `events/${eventId}`); - const eventDocRef = await getDoc(eventDoc); - if (eventDocRef.exists()) { - const eventData = eventDocRef.data(); - const eventEndDate = eventData?.endDate; - if (eventEndDate) { - const eventEndTime = (eventEndDate as Timestamp).toDate().getTime(); - const currentTime = new Date().getTime(); - - if (currentTime > eventEndTime) { - return EventLogStatus.EVENT_OVER; - } else { - return EventLogStatus.EVENT_ONGOING; - } - } - } - } catch (error) { - console.error("Error checking event active status: ", error); - } - return EventLogStatus.ERROR; -}; - export const getAttendanceNumber = async (eventId: string): Promise => { try { const logsRef = collection(db, `events/${eventId}/logs`); @@ -1482,4 +1437,23 @@ export const createEvent = async (event: SHPEEvent): Promise => { console.error("Error adding document: ", error); return null; } -}; \ No newline at end of file +}; + +/** + * Updates a given event + * @param id Name of event document in firestore + * @param event Object to replace firestore document + * @returns Document name firebase or null if an issue occurred + */ +export const setEvent = async (id: string, event: SHPEEvent): Promise => { + try { + const docRef = doc(db, "events", id); + await updateDoc(docRef, { + ...event + }); + return id; + } catch (error) { + console.error("Error updating document: ", error); + return null; + } +} \ No newline at end of file diff --git a/src/screens/events/UpdateEvent.tsx b/src/screens/events/UpdateEvent.tsx index 3c39eda5..10574855 100644 --- a/src/screens/events/UpdateEvent.tsx +++ b/src/screens/events/UpdateEvent.tsx @@ -1,22 +1,22 @@ -import { View, Text, TouchableOpacity, TextInput, Image, ScrollView, Platform, TouchableHighlight, KeyboardAvoidingView, Modal, ActivityIndicator, Alert, Switch } from 'react-native' -import React, { useContext, useEffect, useRef, useState } from 'react' +import { View, Text, TouchableOpacity, TextInput, Image, Platform, TouchableHighlight, Modal, Alert, ActivityIndicator } from 'react-native' +import React, { useContext, useState } from 'react' import { EventProps, UpdateEventScreenRouteProp } from '../../types/navigation' import { useRoute } from '@react-navigation/core'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { CommitteeMeeting, EventType, GeneralMeeting, IntramuralEvent, CustomEvent, SHPEEvent, SocialEvent, StudyHours, VolunteerEvent, Workshop, WorkshopType } from '../../types/events'; -import { destroyEvent, getCommittees, setEvent, uploadFile } from '../../api/firebaseUtils'; -import { Octicons } from '@expo/vector-icons'; +import { Octicons, FontAwesome } from '@expo/vector-icons'; +import { CommitteeMeeting, EventType, GeneralMeeting, IntramuralEvent, CustomEvent, SHPEEvent, SocialEvent, StudyHours, VolunteerEvent, Workshop } from '../../types/events'; +import { setEvent, uploadFile } from '../../api/firebaseUtils'; import DateTimePicker from '@react-native-community/datetimepicker'; +import * as ImagePicker from "expo-image-picker"; import { Images } from '../../../assets'; import { GeoPoint, Timestamp } from 'firebase/firestore'; import { UserContext } from '../../context/UserContext'; import { MillisecondTimes, formatDate, formatTime } from '../../helpers/timeUtils'; import { StatusBar } from 'expo-status-bar'; -import DismissibleModal from '../../components/DismissibleModal'; -import * as ImagePicker from "expo-image-picker"; import { Committee } from '../../types/committees'; -import CustomDropDownMenu, { CustomDropDownMethods } from '../../components/CustomDropDown'; import LocationPicker from '../../components/LocationPicker'; +import { KeyboardAwareScrollView } from '@pietile-native-kit/keyboard-aware-scrollview'; +import InteractButton from '../../components/InteractButton'; import { getBlobFromURI, selectImage } from '../../api/fileSelection'; import { CommonMimeTypes, validateFileBlob } from '../../helpers'; import { auth } from '../../config/firebaseConfig'; @@ -24,65 +24,63 @@ import { auth } from '../../config/firebaseConfig'; const UpdateEvent = ({ navigation }: EventProps) => { const route = useRoute(); const { event } = route.params; - const { userInfo } = useContext(UserContext)!; - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; - const [selectableCommittees, setSelectableCommittees] = useState([]); + const userContext = useContext(UserContext); + const { userInfo } = userContext!; + const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; // UI Hooks - const [updated, setUpdated] = useState(false); - const [changesMade, setChangesMade] = useState(false); const [showStartDatePicker, setShowStartDatePicker] = useState(false); const [showStartTimePicker, setShowStartTimePicker] = useState(false); const [showEndDatePicker, setShowEndDatePicker] = useState(false); const [showEndTimePicker, setShowEndTimePicker] = useState(false); - const [loading, setLoading] = useState(false); - const [showDeletionConfirmation, setShowDeletionConfirmation] = useState(false); - const dropDownRefCommittee = useRef(null); - const [openDropdown, setOpenDropdown] = useState(null); const [showLocationPicker, setShowLocationPicker] = useState(false); - const [localEventName, setLocalEventName] = useState(event.name); - const [localCoverImageURI, setLocalCoverImageURI] = useState(event.coverImageURI ?? ''); - const [isFocused, setIsFocused] = useState(true); + const [localImageURI, setLocalImageURI] = useState(); + const [isUploading, setIsUploading] = useState(); + const [loading, setLoading] = useState(false); // Form Data Hooks - const [name, setName] = useState(event.name); - const [description, setDescription] = useState(event.description); - const [tags, setTags] = useState(event.tags); - const [startTime, setStartTime] = useState(event.startTime); - const [endTime, setEndTime] = useState(event.endTime); - const [startTimeBuffer, setStartTimeBuffer] = useState(event.startTimeBuffer); - const [endTimeBuffer, setEndTimeBuffer] = useState(event.endTimeBuffer); - const [coverImageURI, setCoverImageURI] = useState(event.coverImageURI); - const [signInPoints, setSignInPoints] = useState(event.signInPoints); - const [signOutPoints, setSignOutPoints] = useState(event.signOutPoints); - const [pointsPerHour, setPointsPerHour] = useState(event.pointsPerHour); - const [locationName, setLocationName] = useState(event.locationName); - const [geolocation, setGeolocation] = useState(event.geolocation); - const [geofencingRadius, setGeofencingRadius] = useState(event.geofencingRadius); - const [workshopType, setWorkshopType] = useState(event.workshopType); - const [committee, setCommittee] = useState(event.committee); - const [nationalConventionEligible, setNationalConventionEligible] = useState(event.nationalConventionEligible); - const [general, setIsGeneral] = useState(event.general); - - useEffect(() => { - getCommittees() - .then((result) => setSelectableCommittees(result)) - .catch(err => console.error("Issue getting committees:", err)); - }, []); - - useEffect(() => { - if (openDropdown === null) { - setIsFocused(true); - } else { - setIsFocused(false); + const [name, setName] = useState(event.name ?? ""); + const [startTime, setStartTime] = useState(event.startTime ?? undefined); + const [endTime, setEndTime] = useState(event.endTime ?? undefined); + const [description, setDescription] = useState(event.description ?? ""); + const [coverImageURI, setCoverImageURI] = useState(event.coverImageURI ?? ""); + const [locationName, setLocationName] = useState(event.locationName ?? undefined); + const [geolocation, setGeolocation] = useState(event.geolocation ?? undefined); + const [geofencingRadius, setGeofencingRadius] = useState(event.geofencingRadius ?? undefined); + + + const selectCoverImage = async () => { + const result = await selectImage({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: true, + quality: 1, + }) + + if (result) { + const imageBlob = await getBlobFromURI(result.assets![0].uri); + if (imageBlob && validateFileBlob(imageBlob, CommonMimeTypes.IMAGE_FILES, true)) { + setLocalImageURI(result.assets![0].uri); + if (!imageBlob) return; + setIsUploading(true); + uploadFile( + imageBlob!, + CommonMimeTypes.IMAGE_FILES, + `events/cover-images/${auth.currentUser?.uid.toString()}${Date.now().toString()}`, + onImageUploadSuccess, + ); + } } - }, [openDropdown]) + } - const handleUpdateEvent = async () => { - setLoading(true); + const onImageUploadSuccess = async (URL: string) => { + setCoverImageURI(URL); + setIsUploading(false); + } + const handleUpdateEvent = async () => { + setLoading(true) let updatedEvent: SHPEEvent = {}; switch (event.eventType) { case EventType.GENERAL_MEETING: @@ -110,125 +108,56 @@ const UpdateEvent = ({ navigation }: EventProps) => { updatedEvent = new CustomEvent(); break; default: - console.warn(`Event type ${event.eventType} not handled. This may cause issues if given event object does not follow SHPEEvent schema.`) + console.warn(`Event type ${event.eventType} not handled. This may cause issues if given event object does not follow SHPEEvent schema.`); break; } - // Uses spread syntax as fallback in case class does not implement copyFromObject if (updatedEvent.copyFromObject) { updatedEvent.copyFromObject(event); - } - else { + } else { updatedEvent = { ...event }; } - if (updatedEvent.copyFromObject) { - updatedEvent.copyFromObject({ - name, - description, - tags, - startTime, - endTime, - startTimeBuffer, - endTimeBuffer, - coverImageURI, - signInPoints, - signOutPoints, - pointsPerHour, - locationName, - geolocation, - geofencingRadius, - general, - workshopType, - committee, - nationalConventionEligible, - }) - } - - const eventID = await setEvent(event.id!, updatedEvent) - .then((eventID) => { - setLoading(false); - return eventID; - }); - - if (eventID) { - setUpdated(true); - setChangesMade(false); + console.log(geofencingRadius, 'this is geofencingRadius variable'); + + // Create an object without the geofencingRadius field + const eventData: any = { + name, + description, + startTime, + endTime, + coverImageURI, + locationName, + geolocation, + geofencingRadius + }; + + if (geofencingRadius === undefined) { + eventData.geofencingRadius = null; } - else { - console.log('Event update failed'); - } - - setLocalEventName(name); - } - - const handleDestroyEvent = async () => { - const isDeleted = await destroyEvent(event.id!); - if (isDeleted) { - navigation.navigate("EventsScreen") - } else { - console.log("Failed to delete the event."); - } - } - - const selectCoverImage = async () => { - const result = await selectImage({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, - allowsEditing: true, - quality: 1, - }) - - if (result) { - const imageBlob = await getBlobFromURI(result.assets![0].uri); - if (imageBlob && validateFileBlob(imageBlob, CommonMimeTypes.IMAGE_FILES, true)) { - setLocalCoverImageURI(result.assets![0].uri); - if (!imageBlob) return; - uploadFile( - imageBlob!, - CommonMimeTypes.IMAGE_FILES, - `events/cover-images/${auth.currentUser?.uid.toString()}${Date.now().toString()}`, - onImageUploadSuccess, - ); - } + if (updatedEvent.copyFromObject) { + updatedEvent.copyFromObject(eventData); } - } - - - const onImageUploadSuccess = async (URL: string) => { - setCoverImageURI(URL); - setChangesMade(true); - } + console.log(updatedEvent.geofencingRadius, 'this is updatedEvent Variable geofencing'); + console.log(updatedEvent, 'updated event'); - const toggleDropdown = (dropdownKey: string) => { - if (openDropdown === dropdownKey) { - setOpenDropdown(null); - } else { - setOpenDropdown(dropdownKey); - } + await setEvent(event.id!, updatedEvent); + setLoading(false) }; - return ( - - - {changesMade && ( - - handleUpdateEvent()} - > - Update - - - )} - + + + handleUpdateEvent()} + /> + + + {/* Header */} { { }} /> - + + + + selectCoverImage()} + className="rounded-3xl w-24 h-24 justify-center items-center" + style={{ backgroundColor: 'rgba(0,0,0,0.6)' }} + > + + UPLOAD + + - - {localEventName}{changesMade && ' *'} - Edit Event - - - navigation.goBack()} + onPress={() => { navigation.goBack(); }} className="rounded-full w-10 h-10 justify-center items-center" - style={{ backgroundColor: 'rgba(0,0,0,0.3)' }} + style={{ backgroundColor: 'rgba(0,0,0,0.6)' }} > - - - - setShowDeletionConfirmation(true)} - > - Destroy - - - navigation.navigate("QRCode", { event: event })} - > - QRCode - - - - {/* Form */} - - {/* Event Name */} - General Details - - selectCoverImage()} - > - Choose Cover Image - - - - Event Name * - { - setName(text) - setChangesMade(true); - }} - keyboardType='ascii-capable' - enterKeyHint='enter' - /> - - - {/* Start Time Selection Buttons */} - - - Start Date * - {Platform.OS == 'android' && - setShowStartDatePicker(true)} - className={`flex flex-row justify-between p-2 mr-4 rounded ${darkMode ? "text-white bg-zinc-700" : "text-black bg-zinc-200"}`} - > - <> - {startTime ? formatDate(startTime.toDate()) : "No date picked"} - - - - } - {Platform.OS == 'ios' && - - - { - if (!date) { - console.warn("Date picked is undefined.") - } - else { - setStartTime(Timestamp.fromDate(date)); - if (endTime && date.valueOf() > endTime?.toDate().valueOf()) { - setEndTime(Timestamp.fromMillis(date.getTime() + MillisecondTimes.HOUR)); - } - } - setShowStartDatePicker(false); - setChangesMade(true); - }} - /> - - } + {loading && ()} + {!loading && ( + + + {/* General Details */} + + General Details - - Start Time * - {Platform.OS == 'android' && - setShowStartTimePicker(true)} - className={`flex flex-row justify-between p-2 rounded ${darkMode ? "text-white bg-zinc-700" : "text-black bg-zinc-200"}`} - > - <> - {startTime ? formatTime(startTime.toDate()) : "No date picked"} - - - - } - {Platform.OS == 'ios' && - - - { - if (!date) { - console.warn("Date picked is undefined.") - } - else if (endTime && date.valueOf() > endTime?.toDate().valueOf()) { - Alert.alert("Invalid Start Time", "Event cannot stard after end time.") - } - else { - setStartTime(Timestamp.fromDate(date)); - setChangesMade(true); - } - }} - /> - - } + {/* Event Name */} + + + Event Name* + + + setName(text)} + keyboardType='ascii-capable' + enterKeyHint='enter' + /> - - {/* End Time Selection Buttons */} - - - End Date * - {Platform.OS == 'android' && - setShowEndDatePicker(true)} - className={`flex flex-row justify-between p-2 mr-4 rounded ${darkMode ? "text-white bg-zinc-700" : "text-black bg-zinc-200"}`} - > - <> - {endTime ? formatDate(endTime.toDate()) : "No date picked"} - - - - } - {Platform.OS == 'ios' && - + {/* Start Time Selection Buttons */} + + + + Start Date* + + - { - if (!date) { - console.warn("Date picked is undefined.") - } - else if (startTime && date.valueOf() < startTime?.toDate().valueOf()) { - Alert.alert("Invalid End Date", "Event cannot end before start date.") - } - else { - setEndTime(Timestamp.fromDate(date)); - setChangesMade(true); - } - }} - /> + {Platform.OS == 'android' && + + setShowStartDatePicker(true)} + className={`flex flex-row justify-between p-2 mr-4 rounded ${darkMode ? "text-white bg-secondary-bg-dark" : "text-black bg-secondary-bg-light"}`} + > + {startTime ? formatDate(startTime.toDate()) : "No date picked"} + + + setShowStartTimePicker(true)} + className={`flex flex-row justify-between p-2 rounded ${darkMode ? "text-white bg-zinc-700" : "text-black bg-zinc-200"}`} + > + <> + {startTime ? formatTime(startTime.toDate()) : "No date picked"} + + + + + } + + {Platform.OS == 'ios' && + + { + if (!date) { + console.warn("Date picked is undefined.") + } + else { + setStartTime(Timestamp.fromDate(date)); + if (endTime && date.valueOf() > endTime?.toDate().valueOf()) { + setEndTime(Timestamp.fromMillis(date.getTime() + MillisecondTimes.HOUR)); + } + } + setShowStartDatePicker(false); + }} + /> + + { + if (isUploading) { + Alert.alert("Image upload in progress.", "Please wait for image to finish uploading.") + } + else if (!date) { + console.warn("Date picked is undefined.") + } + else if (endTime && date.valueOf() > endTime?.toDate().valueOf()) { + Alert.alert("Invalid Start Time", "Event cannot start after end time.") + } + else { + setStartTime(Timestamp.fromDate(date)); + } + }} + /> + + } + - } + - - End Time * - {Platform.OS == 'android' && - setShowEndTimePicker(true)} - className={`flex flex-row justify-between p-2 rounded ${darkMode ? "text-white bg-zinc-700" : "text-black bg-zinc-200"}`} - > - <> - {endTime ? formatTime(endTime.toDate()) : "No date picked"} - - - - } - {Platform.OS == 'ios' && - - - { - if (!date) { - console.warn("Date picked is undefined.") - } - else if (startTime && date.valueOf() < startTime?.toDate().valueOf()) { - Alert.alert("Invalid End Time", "Event cannot end before start time.") - } - else { - setEndTime(Timestamp.fromDate(date)); - } - setShowEndTimePicker(false); - setChangesMade(true); - }} - /> + + {/* End Time Selection Buttons */} + + + + End Date* + + + + + {Platform.OS == 'android' && + + setShowEndDatePicker(true)} + className={`flex flex-row justify-between p-2 mr-4 rounded ${darkMode ? "text-white bg-zinc-700" : "text-black bg-zinc-200"}`} + > + {endTime ? formatDate(endTime.toDate()) : "No date picked"} + + + setShowEndTimePicker(true)} + className={`flex flex-row justify-between p-2 rounded ${darkMode ? "text-white bg-zinc-700" : "text-black bg-zinc-200"}`} + > + <> + {endTime ? formatTime(endTime.toDate()) : "No date picked"} + + + + + } + + {Platform.OS == 'ios' && + + { + if (!date) { + console.warn("Date picked is undefined.") + } + else if (startTime && date.valueOf() < startTime?.toDate().valueOf()) { + Alert.alert("Invalid End Date", "Event cannot end before start date.") + } + else { + setEndTime(Timestamp.fromDate(date)); + } + }} + /> + + { + if (!date) { + console.warn("Date picked is undefined.") + } + else if (startTime && date.valueOf() < startTime?.toDate().valueOf()) { + Alert.alert("Invalid End Time", "Event cannot end before start time.") + } + else { + setEndTime(Timestamp.fromDate(date)); + } + setShowEndTimePicker(false); + }} + /> + + } - } - - - {/* toggle to make events appear on general tab*/} - - Event Scope - - Club-Wide Event - { - setIsGeneral(previousState => !previousState) - setChangesMade(true); - }} - value={general || false} - /> - - - - {/* Description */} - - Description - { - if (text.length <= 250) { - setDescription(text) - setChangesMade(true); - } - }} - numberOfLines={2} - keyboardType='ascii-capable' - autoCapitalize='sentences' - multiline - style={{ textAlignVertical: 'top' }} - enterKeyHint='enter' - /> - - - Specific Details - - Associated Committee - - { - setCommittee(item.iso); - setChangesMade(true); - }} - searchKey="committee" - label="Select Committee" - isOpen={openDropdown === 'committee'} - onToggle={() => toggleDropdown('committee')} - displayType='value' - ref={dropDownRefCommittee} - disableSearch - selectedItemProp={event.committee ? { value: event.committee, iso: event.committee } : null} - /> - - - {event.signInPoints !== undefined && - - Points for Signing In * - { - setSignInPoints(Number(item.iso)) - setChangesMade(true); - }} - searchKey="point" - label="Select Points" - isOpen={openDropdown === 'pointSignIn'} - onToggle={() => toggleDropdown('pointSignIn')} - displayType='iso' - disableSearch - selectedItemProp={event.signInPoints ? { value: String(event.signInPoints), iso: String(event.signInPoints) } : null} - /> - } - {event.signOutPoints !== undefined && - - Points for Signing Out * - { - setSignOutPoints(Number(item.iso)) - setChangesMade(true); - }} - searchKey="point" - label="Select Points" - isOpen={openDropdown === 'pointSignOut'} - onToggle={() => toggleDropdown('pointSignOut')} - displayType='iso' - disableSearch - selectedItemProp={event.signOutPoints ? { value: String(event.signOutPoints), iso: String(event.signOutPoints) } : null} - /> - - } - {event.pointsPerHour !== undefined && - - Hourly Points* - { - setPointsPerHour(Number(item.iso)) - setChangesMade(true); - }} - searchKey="point" - label="Select Points" - isOpen={openDropdown === 'pointPerHour'} - onToggle={() => toggleDropdown('pointPerHour')} - displayType='iso' - disableSearch - selectedItemProp={event.pointsPerHour ? { value: String(event.pointsPerHour), iso: String(event.pointsPerHour) } : null} - /> - - } - + - {event.workshopType !== undefined && - - Workshop Type * - { - setWorkshopType(item.iso as WorkshopType); - switch (item.iso) { - case "Academic": - setSignInPoints(2); - case "Professional": - setSignInPoints(3); - } - setChangesMade(true); + {/* Description */} + + Description + { + if (text.length <= 250) + setDescription(text) }} - searchKey="workshopType" - label="Select Workshop Type" - isOpen={openDropdown === 'workshopType'} - onToggle={() => toggleDropdown('workshopType')} - displayType='value' - disableSearch - selectedItemProp={event.workshopType ? { value: event.workshopType, iso: event.workshopType } : null} + numberOfLines={2} + keyboardType='ascii-capable' + autoCapitalize='sentences' + multiline + style={{ textAlignVertical: 'top' }} + enterKeyHint='enter' /> - } - { - setNationalConventionEligible(!nationalConventionEligible) - setChangesMade(true); - }} - > - - {nationalConventionEligible && ( - - )} + {/* Location Details */} + + Location Details - Eligible for National Convention - - - - Location Details - - {/* Location Name */} - - Location Name - { - setLocationName(text) - setChangesMade(true); - }} - keyboardType='ascii-capable' - enterKeyHint='enter' - /> - + {/* Location Name */} + + + Location Name* + + + setLocationName(text)} + keyboardType='ascii-capable' + enterKeyHint='enter' + /> + - setShowLocationPicker(true)} > - Open Geolocation Editor + Open Location Editor - - - - - - - - - - - - Are you sure that you want to destroy this event?{'\n\nNote: This is *not* reversable!'} - - { - setShowDeletionConfirmation(false); - setLoading(true); - handleDestroyEvent().then(() => setLoading(false)); - }} - > - Delete - - setShowDeletionConfirmation(false)} - > - Cancel - + - - - - - - { setShowLocationPicker(false) }} - > - Done - - Update Event Location - { - if (location?.geometry.location.lat && location?.geometry.location.lng) { - setGeolocation(new GeoPoint(location?.geometry.location.lat, location?.geometry.location.lng)); - } - setGeofencingRadius(radius); - setChangesMade(true); - }} - initialCoordinate={geolocation ? { latitude: geolocation.latitude, longitude: geolocation.longitude } : undefined} - /> - - + )} + {/* Start Date Pickers */} {Platform.OS == 'android' && showStartDatePicker && @@ -743,18 +446,16 @@ const UpdateEvent = ({ navigation }: EventProps) => { maximumDate={new Date(Date.now() + MillisecondTimes.YEAR)} mode='date' onChange={(_, date) => { - setShowStartDatePicker(false); if (!date) { console.warn("Date picked is undefined."); } - else if (endTime && date.valueOf() > endTime.toMillis()) { - setStartTime(Timestamp.fromDate(date)); - setEndTime(Timestamp.fromMillis(date.getTime() + MillisecondTimes.HOUR)); - } else { setStartTime(Timestamp.fromDate(date)); + if (endTime && date.valueOf() > endTime?.toDate().valueOf()) { + setEndTime(Timestamp.fromMillis(date.getTime() + MillisecondTimes.HOUR)); + } } - setChangesMade(true); + setShowStartDatePicker(false); }} /> } @@ -762,21 +463,21 @@ const UpdateEvent = ({ navigation }: EventProps) => { { - setShowStartTimePicker(false); - if (!date) { - console.warn("Date picked is undefined."); + if (isUploading) { + Alert.alert("Image upload in progress.", "Please wait for image to finish uploading.") } - else if (endTime && date.valueOf() > endTime.toMillis()) { - setStartTime(Timestamp.fromDate(date)); - setEndTime(Timestamp.fromMillis(date.getTime() + MillisecondTimes.HOUR)); + else if (!date) { + console.warn("Date picked is undefined.") + } + else if (endTime && date.valueOf() > endTime?.toDate().valueOf()) { + Alert.alert("Invalid Start Time", "Event cannot start after end time.") } else { setStartTime(Timestamp.fromDate(date)); } - setChangesMade(true); + setShowStartTimePicker(false); }} /> } @@ -784,46 +485,66 @@ const UpdateEvent = ({ navigation }: EventProps) => { {/* End Date Pickers */} {Platform.OS == 'android' && showEndDatePicker && { - setShowEndDatePicker(false); if (!date) { - console.warn("Date picked is undefined."); + console.warn("Date picked is undefined.") } - else if (startTime && startTime.toMillis() > date.valueOf()) { - Alert.alert("Invalid End Date", "Event cannot end before start time") + else if (startTime && date.valueOf() < startTime?.toDate().valueOf()) { + Alert.alert("Invalid End Date", "Event cannot end before start date.") } else { setEndTime(Timestamp.fromDate(date)); } - setChangesMade(true); + setShowEndDatePicker(false); }} /> } {Platform.OS == 'android' && showEndTimePicker && { - setShowEndTimePicker(false); if (!date) { - console.warn("Date picked is undefined."); + console.warn("Date picked is undefined.") } - else if (startTime && startTime.toMillis() > date.valueOf()) { - Alert.alert("Invalid End Date", "Event cannot end before start time") + else if (startTime && date.valueOf() < startTime?.toDate().valueOf()) { + Alert.alert("Invalid End Time", "Event cannot end before start time.") } else { setEndTime(Timestamp.fromDate(date)); } - setChangesMade(true); + setShowEndTimePicker(false); }} /> } + + + + + { setShowLocationPicker(false) }} + /> + + { + if (location?.geometry.location.lat && location?.geometry.location.lng) { + setGeolocation(new GeoPoint(location?.geometry.location.lat, location?.geometry.location.lng)); + } + setGeofencingRadius(radius); + }} + initialCoordinate={geolocation ? { latitude: geolocation.latitude, longitude: geolocation.longitude } : undefined} + initialRadius={geofencingRadius ?? undefined} + containerClassName="pt-16" + /> + + ) } @@ -835,14 +556,6 @@ const createCommitteeList = (committees: Committee[]) => { })); }; -const POINTS = [ - { point: "0", iso: "0" }, - { point: "1", iso: "1" }, - { point: "2", iso: "2" }, - { point: "3", iso: "3" }, - { point: "4", iso: "4" }, - { point: "5", iso: "5" }, -] export default UpdateEvent; diff --git a/src/screens/events/__tests__/eventUtils.test.ts b/src/screens/events/__tests__/eventUtils.test.ts index b45fb9f9..a2c51bbd 100644 --- a/src/screens/events/__tests__/eventUtils.test.ts +++ b/src/screens/events/__tests__/eventUtils.test.ts @@ -1,7 +1,7 @@ -import { Timestamp, GeoPoint, deleteDoc, doc, collection, getDocs } from "firebase/firestore"; +import { Timestamp, GeoPoint, deleteDoc, doc, collection, getDocs, getDoc } from "firebase/firestore"; import { signInAnonymously, signOut } from "firebase/auth"; import { auth, db } from "../../../config/firebaseConfig"; -import { createEvent, getUpcomingEvents, getPastEvents } from "../../../api/firebaseUtils"; +import { createEvent, getUpcomingEvents, getPastEvents, setEvent } from "../../../api/firebaseUtils"; import { EventType, SHPEEvent } from "../../../types/events"; const generateTestEvent = (overrides: Partial = {}): SHPEEvent => { @@ -58,7 +58,7 @@ afterAll(async () => { await signOut(auth); }); -describe("Event Utils", () => { +describe("Create and Get Event Functions", () => { test("Handle empty events collection", async () => { const upcomingEvents = await getUpcomingEvents(); expect(upcomingEvents.length).toBe(0); @@ -85,7 +85,7 @@ describe("Event Utils", () => { } }); - test("Create and fetch upcoming events", async () => { + test("Fetch upcoming events", async () => { const event = generateTestEvent(); const eventId = await createEvent(event as SHPEEvent); expect(eventId).not.toBeNull(); @@ -143,6 +143,7 @@ describe("Event Utils", () => { await deleteDoc(doc(db, "events", eventId!)); }); + test("Multiple events and sorting", async () => { const event1 = generateTestEvent({ startTime: Timestamp.fromDate(new Date(Date.now() + 3600 * 1000)), @@ -231,4 +232,69 @@ describe("Event Utils", () => { await Promise.all(eventIds.map(eventId => deleteDoc(doc(db, "events", eventId!)))); }); +}); + +describe("setEvent Function", () => { + test("Update an existing event with valid data", async () => { + const event = generateTestEvent(); + const eventId = await createEvent(event as SHPEEvent); + expect(eventId).not.toBeNull(); + + const updatedEvent = { ...event, name: "Updated Event Name" }; + const result = await setEvent(eventId!, updatedEvent); + + expect(result).toBe(eventId); + + const updatedDoc = await getDoc(doc(db, "events", eventId!)); + expect(updatedDoc.exists()).toBe(true); + expect(updatedDoc.data()?.name).toBe("Updated Event Name"); + + await deleteDoc(doc(db, "events", eventId!)); + }); + + test("Handle non-existent event ID", async () => { + const nonExistentId = "non-existent-id"; + const event = generateTestEvent(); + + const result = await setEvent(nonExistentId, event); + + expect(result).toBeNull(); + }); + + test("Update an event with missing fields", async () => { + const event = generateTestEvent(); + const eventId = await createEvent(event as SHPEEvent); + expect(eventId).not.toBeNull(); + + const updatedEvent = { ...event }; + delete updatedEvent.geofencingRadius; + const result = await setEvent(eventId!, updatedEvent); + + expect(result).toBe(eventId); + + const updatedDoc = await getDoc(doc(db, "events", eventId!)); + expect(updatedDoc.exists()).toBe(true); + expect(updatedDoc.data()?.name).toBe(event.name); + + await deleteDoc(doc(db, "events", eventId!)); + }); + + test("Update an multiple fields of an event", async () => { + const event = generateTestEvent({ locationName: "Initial Location", geolocation: new GeoPoint(30.621, -96.340) }); + const eventId = await createEvent(event as SHPEEvent); + expect(eventId).not.toBeNull(); + + const nestedUpdate = { locationName: "Updated Location", geolocation: new GeoPoint(30.622, -96.341) }; + const result = await setEvent(eventId!, nestedUpdate as SHPEEvent); + + expect(result).toBe(eventId); + + const updatedDoc = await getDoc(doc(db, "events", eventId!)); + expect(updatedDoc.exists()).toBe(true); + expect(updatedDoc.data()?.locationName).toBe("Updated Location"); + expect(updatedDoc.data()?.geolocation.latitude).toBe(30.622); + expect(updatedDoc.data()?.geolocation.longitude).toBe(-96.341); + + await deleteDoc(doc(db, "events", eventId!)); + }); }); \ No newline at end of file From d13310c1f6ec0a6dd1f497afb9551a9738c0bd46 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 18 Jun 2024 16:45:37 -0500 Subject: [PATCH 081/198] change eventinfo param to take in event object, add updated ui (non-admin) for event info screen. --- src/api/firebaseUtils.ts | 9 +- src/components/EventsList.tsx | 2 +- src/helpers/timeUtils.ts | 42 ++- src/screens/events/EventCard.tsx | 2 +- src/screens/events/EventInfo.tsx | 497 +++++++++++++------------------ src/screens/events/Events.tsx | 2 +- src/types/navigation.ts | 8 +- 7 files changed, 256 insertions(+), 306 deletions(-) diff --git a/src/api/firebaseUtils.ts b/src/api/firebaseUtils.ts index c7244b70..a51a3977 100644 --- a/src/api/firebaseUtils.ts +++ b/src/api/firebaseUtils.ts @@ -5,7 +5,7 @@ import { HttpsCallableResult, httpsCallable } from "firebase/functions"; import { validateFileBlob, validateTamuEmail } from "../helpers/validation"; import { OfficerStatus, PrivateUserInfo, PublicUserInfo, Roles, User, UserFilter } from "../types/user"; import { Committee } from "../types/committees"; -import { SHPEEvent, EventLogStatus, UserEventData } from "../types/events"; +import { SHPEEvent, EventLogStatus, UserEventData, SHPEEventLog } from "../types/events"; import * as Location from 'expo-location'; import { deleteUser } from "firebase/auth"; import { LinkData } from "../types/links"; @@ -708,15 +708,14 @@ export const signOutOfEvent = async (eventID: string, uid?: string): Promise { +export const getUserEventLog = async (eventId: string, uid: string): Promise => { const eventLogDocRef = doc(db, 'events', eventId, 'logs', uid); const docSnap = await getDoc(eventLogDocRef); if (docSnap.exists()) { - return true; + return docSnap.data(); } else { - return false; + return null; } } diff --git a/src/components/EventsList.tsx b/src/components/EventsList.tsx index 9cba2bc6..6efe469e 100644 --- a/src/components/EventsList.tsx +++ b/src/components/EventsList.tsx @@ -35,7 +35,7 @@ const EventsList = ({ events, navigation, isLoading, showImage = true, onEventCl key={index} className="flex-row space-x-2 pt-4" onPress={() => { - navigation.navigate("EventInfo", { eventId: event.id }); + navigation.navigate("EventInfo", { event: event }); if (onEventClick) { onEventClick(); } diff --git a/src/helpers/timeUtils.ts b/src/helpers/timeUtils.ts index 93393739..a45cb2fd 100644 --- a/src/helpers/timeUtils.ts +++ b/src/helpers/timeUtils.ts @@ -38,6 +38,15 @@ export const formatDate = (date: Date): string => { return `${dayOfWeek}, ${month} ${day}`; }; +export const formatDateWithYear = (date: Date): string => { + const dayOfWeek = dayNames[date.getDay()]; + const day = date.getDate(); + const month = monthNames[date.getMonth()]; + + return `${month} ${day}, ${date.getFullYear()}`; +}; + + /** * Constructs a readable string that represents the time of day of a given `Date` object * @param date @@ -47,9 +56,29 @@ export const formatTime = (date: Date): string => { const hour = date.getHours(); const minute = date.getMinutes(); - return `${hour % 12 == 0 ? 12 : hour % 12}:${minute.toString().padStart(2, '0')}${hour > 11 ? "pm" : "am"}` + return `${hour % 12 == 0 ? 12 : hour % 12}:${minute.toString().padStart(2, '0')}${hour > 11 ? "pm" : "am"}`; } +export const formatEventTime = (startDate: Date, endDate: Date): string => { + const startHour = startDate.getHours(); + const endHour = endDate.getHours(); + const startMinute = startDate.getMinutes(); + const endMinute = endDate.getMinutes(); + + const startAmPm = startHour >= 12 ? "pm" : "am"; + const endAmPm = endHour >= 12 ? "pm" : "am"; + + const formattedStartTime = `${startHour % 12 === 0 ? 12 : startHour % 12}:${startMinute.toString().padStart(2, '0')}`; + const formattedEndTime = `${endHour % 12 === 0 ? 12 : endHour % 12}:${endMinute.toString().padStart(2, '0')}${endAmPm}`; + + if (startAmPm === endAmPm) { + return `${formattedStartTime} - ${formattedEndTime}`; + } + + return `${formattedStartTime}${startAmPm} - ${formattedEndTime}`; +} + + /** * Constructs a readable string that represents the date and time of day of a given `Date` object and it's timezone offset. * @param date @@ -75,20 +104,15 @@ export const formatEventDate = (startTime: Date, endTime: Date) => { return `${month} ${day}`; } - const formatDayYearOnly = (date: Date): string => { - const day = date.getDate(); - const year = date.getFullYear(); - return `${day} ${year}`; - } if (isSameDay) { return `${formatDate(startTime)}`; } else if (isSameMonth) { - return `${formatMonthDayOnly(startTime)}-${formatDayYearOnly(endTime)}`; + return `${formatMonthDayOnly(startTime)} - ${endTime.getDate()}`; } else if (isSameYear) { - return `${formatMonthDayOnly(startTime)}-${formatDate(endTime)}`; + return `${formatMonthDayOnly(startTime)} - ${formatDate(endTime)}`; } else { - return `${formatDate(startTime)} - ${formatDate(endTime)}`; + return `${formatDateWithYear(startTime)} - ${formatDateWithYear(endTime)}`; } }; \ No newline at end of file diff --git a/src/screens/events/EventCard.tsx b/src/screens/events/EventCard.tsx index e50f00a6..4acd1245 100644 --- a/src/screens/events/EventCard.tsx +++ b/src/screens/events/EventCard.tsx @@ -27,7 +27,7 @@ const EventCard = ({ event, navigation }: { event: SHPEEvent, navigation: any }) elevation: 5, }} - onPress={() => { navigation.navigate("EventInfo", { eventId: event.id! }) }} + onPress={() => { navigation.navigate("EventInfo", { event: event }) }} > { + const route = useRoute(); + const { event } = route.params; + const { name, description, eventType, startTime, endTime, coverImageURI, signInPoints, signOutPoints, pointsPerHour, locationName, geolocation, geofencingRadius, workshopType, committee, creator, nationalConventionEligible, startTimeBuffer, endTimeBuffer } = event || {}; -const EventInfo: React.FC = ({ route, navigation }) => { - const { eventId } = route.params - const { userInfo } = useContext(UserContext)!; - const [event, setEvent] = useState(); - const [creatorData, setCreatorData] = useState(null) - const [userSignedIn, setUserSignedIn] = useState(false); - const [attendance, setAttendance] = useState(0); - const [allUserFetched, setAllUserFetched] = useState(false); - const [users, setUsers] = useState([]); - const [userModalVisible, setUserModalVisible] = useState(false); - const [userSignInOut, setUserSignInOut] = useState(); - const [loading, setLoading] = useState(false); - const [forceUpdate, setForceUpdate] = useState(0); + const insets = useSafeAreaInsets(); - const { name, description, eventType, startTime, endTime, coverImageURI, signInPoints, signOutPoints, pointsPerHour, locationName, geolocation, workshopType, committee, creator, nationalConventionEligible } = event || {}; + const userContext = useContext(UserContext); + const { userInfo } = userContext!; + const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; const hasPrivileges = (userInfo?.publicInfo?.roles?.admin?.valueOf() || userInfo?.publicInfo?.roles?.officer?.valueOf() || userInfo?.publicInfo?.roles?.developer?.valueOf()); - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; - const insets = useSafeAreaInsets(); + const [creatorData, setCreatorData] = useState(null); + const [loadingUserEventLog, setLoadingUserEventLog] = useState(true); + const [userEventLog, setUserEventLog] = useState(null); - const fetchAllUsers = async () => { - try { - const fetchedUsers = await getUsers(); - setUsers(fetchedUsers); - setAllUserFetched(true); - } catch (error) { - console.error("An error occurred while fetching users: ", error); + useEffect(() => { + const fetchUserEventLog = async () => { + setLoadingUserEventLog(true); + const res = await getUserEventLog(event.id!, auth.currentUser?.uid!); + setUserEventLog(res); + setLoadingUserEventLog(false); } - setForceUpdate(prev => prev + 1); - }; - - - useFocusEffect( - useCallback(() => { - const fetchUserInLog = async () => { - const isUserInLog = await isUserSignedIn(eventId, auth?.currentUser?.uid!); - setUserSignedIn(isUserInLog); - }; - - const fetchEventData = async () => { - try { - const eventData = await getEvent(eventId); - if (eventData) { - setEvent({ ...eventData, id: eventId }); - } - } catch (error) { - console.error("An error occurred while fetching the event: ", error); - } - }; - - const fetchAttendance = async () => { - try { - const attendanceCount = await getAttendanceNumber(eventId); - setAttendance(attendanceCount); - console.log(attendanceCount) - } catch (error) { - console.error("An error occurred while fetching the attendance: ", error); - } - }; - - - fetchUserInLog(); - fetchEventData(); - fetchAttendance(); - - return () => { }; - }, [eventId]) - ); + fetchUserEventLog(); + }, []) useEffect(() => { const fetchCreatorInfo = async () => { @@ -106,258 +66,223 @@ const EventInfo: React.FC = ({ route, navigation }) => { fetchCreatorInfo(); }, [creator]) + console.log(locationName); + + const getEventButtonState = (event: SHPEEvent, userEventLog: SHPEEventLog | null): EventButtonState => { + const now = new Date(); + + // Event has not started + if (now < new Date(startTime!.toDate().getTime() - (startTimeBuffer || 0))) { + return EventButtonState.NOT_STARTED; + } + + // Event Ended + if (now > new Date(endTime!.toDate().getTime() + (endTimeBuffer || 0))) { + if (userEventLog?.signInTime || userEventLog?.signOutTime) { + return EventButtonState.RECEIVED_POINTS; + } + return EventButtonState.EVENT_OVER; + } + + // Event is on-going + if (userEventLog?.signOutTime) { + return EventButtonState.RECEIVED_POINTS; + } + if (userEventLog?.signInTime) { + if (signOutPoints != null) { + return EventButtonState.SIGN_OUT; + } + return EventButtonState.RECEIVED_POINTS; + } + return EventButtonState.SIGN_IN; - if (!event) { - return ( - - - - ) } + const eventButtonState = getEventButtonState(event, userEventLog); + return ( - - - {/* Header */} - - + {!loadingUserEventLog && ( + + + { navigation.navigate("QRCodeScanningScreen") }} + disabled={!(eventButtonState == EventButtonState.SIGN_IN || eventButtonState == EventButtonState.SIGN_OUT)} + className={`bg-primary-blue h-14 items-center justify-center rounded-xl mx-4 ${(eventButtonState == EventButtonState.SIGN_IN || eventButtonState == EventButtonState.SIGN_OUT) ? "bg-primary-blue" : "bg-secondary-bg-light border border-grey-dark"}`} + style={{ + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + + elevation: 5, + }} + > + {eventButtonState === EventButtonState.SIGN_IN && ( + Sign In + )} + {eventButtonState === EventButtonState.SIGN_OUT && ( + Sign Out + )} + {eventButtonState === EventButtonState.NOT_STARTED && ( + This event has not started + )} + {eventButtonState === EventButtonState.EVENT_OVER && ( + This event is over + )} + {eventButtonState === EventButtonState.RECEIVED_POINTS && ( + You received {userEventLog?.points} points for this event + )} + + + {((eventButtonState === EventButtonState.SIGN_IN || eventButtonState === EventButtonState.SIGN_OUT) && (geolocation && geofencingRadius)) && ( + + You must be at the location to scan the QRCode. + + )} + + )} + + + + {/* Header */} + + > + - - - - {name ?? "Name"} - {eventType}{workshopType && (" • " + workshopType)}{committee && (" • " + reverseFormattedFirebaseName(committee))} • {(signInPoints || 0) + (signOutPoints || 0) + (pointsPerHour || 0)} points - - - - - { navigation.goBack(); }} - className="rounded-full w-10 h-10 justify-center items-center" - style={{ backgroundColor: 'rgba(0,0,0,0.3)' }} - > - - + - {userSignedIn && ( - + + { navigation.goBack(); }} + className="rounded-full w-10 h-10 justify-center items-center" + style={{ backgroundColor: 'rgba(0,0,0,0.6)' }} > - You are signed in - - - - - )} - - {/* TODO: bug here navagating back from home */} - - {hasPrivileges && - navigation.navigate("UpdateEvent", { event })} - className="rounded-lg px-3 py-3" - style={{ backgroundColor: 'rgba(0,0,0,0.5)' }} - > - - - } + + - - - - - {/* Body */} - - {hasPrivileges && ( - - Attendance: {attendance} - - )} + + + {/* General Details */} {nationalConventionEligible && ( - This event is eligible for national convention requirements* - )} - - {(description && description != "") && ( - - Description - {description} - + This event is eligible for national convention requirements* )} - Time and Location - - - {(startTime && endTime) ? formatEventDate(startTime.toDate(), endTime.toDate()) : ""} - - - - {startTime && formatTime(startTime.toDate())} - {endTime && formatTime(endTime.toDate())} + + {name} + {eventType}{committee && (" • " + reverseFormattedFirebaseName(committee))} • {calculateMaxPossiblePoints(event)} points + Hosted By {creatorData?.name} - {(locationName || geolocation) && ( - - - {locationName} - {geolocation && ( - { - if (Platform.OS === 'ios') { - handleLinkPress(`http://maps.apple.com/?daddr=${geolocation.latitude},${geolocation.longitude}`); - } else if (Platform.OS === 'android') { - handleLinkPress(`https://www.google.com/maps/dir/?api=1&destination=${geolocation.latitude},${geolocation.longitude}`); - } - }} - > - View Map - - )} - - )} + {/* Date, Time and Location */} + + + + Date + {(startTime && endTime) ? formatEventDate(startTime.toDate(), endTime.toDate()) : ""} + - {event.general && ( - - - Club-Wide Event - - )} - {creatorData && ( - - Event Host - + + Time + {startTime && endTime && formatEventTime(startTime.toDate(), endTime.toDate())} + - )} - + {locationName && ( + + Location + + {locationName} {geolocation && ( + { + if (Platform.OS === 'ios') { + handleLinkPress(`http://maps.apple.com/?daddr=${geolocation.latitude},${geolocation.longitude}`); + } else if (Platform.OS === 'android') { + handleLinkPress(`https://www.google.com/maps/dir/?api=1&destination=${geolocation.latitude},${geolocation.longitude}`); + } + }} + > + View in Maps + + )} + - {/* Admin Only - Sign in/out user */} - - {(typeof event.signInPoints === 'number' && hasPrivileges) && ( - { - setUserSignInOut("signIn") - setUserModalVisible(true) - if (!allUserFetched) { - fetchAllUsers(); - } - }}> - - Manually Sign In a user - - + + )} - {(typeof event.signOutPoints === 'number' && hasPrivileges) && ( - { - setUserSignInOut("signOut") - setUserModalVisible(true) - if (!allUserFetched) { - fetchAllUsers(); - } - }} - className='bg-pale-blue p-2 rounded-md mt-4' - > - - Manually Sign Out a user - - - )} - - - - - { - setUserModalVisible(false); - }} - > - - - - - Select a Member - - - setUserModalVisible(false)} - > - - + {/* Description */} + {(description && description.trim() != "") && ( + + About Event + {description} + )} - {!allUserFetched && ( - - )} - + - - { - setLoading(true) - console.log(uid) - if (userSignInOut == "signIn") { - signInToEvent(eventId, uid).then((status) => { - setLoading(false) - alert(getStatusMessage(status)) - }) - } else if (userSignInOut == "signOut") { - signOutOfEvent(eventId, uid).then((status) => { - setLoading(false) - alert(getStatusMessage(status)) - }) - } - setUserModalVisible(false) - }} - users={users} - /> - - - + + + ) +} - {loading && ( - - - +const calculateMaxPossiblePoints = (event: SHPEEvent): number => { + const { signInPoints, signOutPoints, pointsPerHour, startTime, endTime } = event; + let maxPossiblePoints = 0; - )} + const durationHours = (endTime!.toMillis() - startTime!.toMillis()) / MillisecondTimes.HOUR; + const accumulatedPoints = durationHours * (pointsPerHour ?? 0); - - ) + maxPossiblePoints = (signInPoints ?? 0) + (signOutPoints ?? 0) + accumulatedPoints; + return maxPossiblePoints; } -type EventScreenRouteProp = { - route: RouteProp; - navigation: NativeStackNavigationProp; -}; +enum EventButtonState { + NOT_STARTED = "Event has not started", + EVENT_OVER = "Event is over", + RECEIVED_POINTS = "You receive points for this event", + SIGN_OUT = "Sign out of this event", + SIGN_IN = "Sign in", +} +export type EventInfoScreenRouteProp = RouteProp; export default EventInfo \ No newline at end of file diff --git a/src/screens/events/Events.tsx b/src/screens/events/Events.tsx index 086cf6ef..01e51568 100644 --- a/src/screens/events/Events.tsx +++ b/src/screens/events/Events.tsx @@ -223,7 +223,7 @@ const Events = ({ navigation }: NativeStackScreenProps) => { elevation: 5, }} - onPress={() => { navigation.navigate("EventInfo", { eventId: event.id! }) }} + onPress={() => { navigation.navigate("EventInfo", { event: event }) }} > Date: Tue, 18 Jun 2024 17:18:27 -0500 Subject: [PATCH 082/198] add attendance count and ui for edit/qrcode button on event info screen --- src/api/firebaseUtils.ts | 23 +++++- src/screens/events/EventCard.tsx | 2 +- src/screens/events/EventInfo.tsx | 136 ++++++++++++++++++++----------- 3 files changed, 107 insertions(+), 54 deletions(-) diff --git a/src/api/firebaseUtils.ts b/src/api/firebaseUtils.ts index a51a3977..18dfd1c4 100644 --- a/src/api/firebaseUtils.ts +++ b/src/api/firebaseUtils.ts @@ -598,19 +598,34 @@ export const destroyEvent = async (eventID: string) => { } }; -export const getAttendanceNumber = async (eventId: string): Promise => { +export const getAttendanceNumber = async (eventId: string) => { try { const logsRef = collection(db, `events/${eventId}/logs`); const q = query(logsRef); const querySnapshot = await getDocs(q); - return querySnapshot.docs.length; + let signedInCount = 0; + let signedOutCount = 0; + + querySnapshot.forEach(doc => { + const data = doc.data(); + if (data.signInTime) { + signedInCount++; + } + if (data.signOutTime) { + signedOutCount++; + } + }); + + return { + signedInCount, + signedOutCount + }; } catch (error) { console.error("Error calculating attendance number:", error); throw new Error("Unable to calculate attendance."); } -} - +}; /** * Signs a user into an event given an event id * @param eventID ID of event to sign into. This is the name of the event document in firestore diff --git a/src/screens/events/EventCard.tsx b/src/screens/events/EventCard.tsx index 4acd1245..3f4c957a 100644 --- a/src/screens/events/EventCard.tsx +++ b/src/screens/events/EventCard.tsx @@ -45,7 +45,7 @@ const EventCard = ({ event, navigation }: { event: SHPEEvent, navigation: any }) {hasPrivileges && ( - + { navigation.navigate("QRCode", { event: event }) }} className='absolute right-0 p-2 rounded-full' diff --git a/src/screens/events/EventInfo.tsx b/src/screens/events/EventInfo.tsx index 5c768cc4..855b9813 100644 --- a/src/screens/events/EventInfo.tsx +++ b/src/screens/events/EventInfo.tsx @@ -2,7 +2,7 @@ import { View, Text, TouchableOpacity, ActivityIndicator, ScrollView, Image, Pla import React, { useCallback, useContext, useEffect, useState } from 'react' import { RouteProp, useFocusEffect, useRoute } from '@react-navigation/core'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { Octicons } from '@expo/vector-icons'; +import { Octicons, FontAwesome6, Entypo } from '@expo/vector-icons'; import { auth } from "../../config/firebaseConfig"; import { getEvent, getAttendanceNumber, getPublicUserData, getUsers, signInToEvent, signOutOfEvent, getUserEventLog } from '../../api/firebaseUtils'; import { UserContext } from '../../context/UserContext'; @@ -42,6 +42,7 @@ const EventInfo = ({ navigation }: EventProps) => { const [creatorData, setCreatorData] = useState(null); const [loadingUserEventLog, setLoadingUserEventLog] = useState(true); const [userEventLog, setUserEventLog] = useState(null); + const [attendanceCounts, setAttendanceCounts] = useState<{ signedInCount: number, signedOutCount: number }>({ signedInCount: 0, signedOutCount: 0 }); useEffect(() => { @@ -52,7 +53,13 @@ const EventInfo = ({ navigation }: EventProps) => { setLoadingUserEventLog(false); } + const fetchAttendanceCounts = async () => { + const counts = await getAttendanceNumber(event.id!); + setAttendanceCounts(counts); + } + fetchUserEventLog(); + fetchAttendanceCounts(); }, []) useEffect(() => { @@ -102,53 +109,6 @@ const EventInfo = ({ navigation }: EventProps) => { return ( - {!loadingUserEventLog && ( - - - { navigation.navigate("QRCodeScanningScreen") }} - disabled={!(eventButtonState == EventButtonState.SIGN_IN || eventButtonState == EventButtonState.SIGN_OUT)} - className={`bg-primary-blue h-14 items-center justify-center rounded-xl mx-4 ${(eventButtonState == EventButtonState.SIGN_IN || eventButtonState == EventButtonState.SIGN_OUT) ? "bg-primary-blue" : "bg-secondary-bg-light border border-grey-dark"}`} - style={{ - shadowColor: "#000", - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.25, - shadowRadius: 3.84, - - elevation: 5, - }} - > - {eventButtonState === EventButtonState.SIGN_IN && ( - Sign In - )} - {eventButtonState === EventButtonState.SIGN_OUT && ( - Sign Out - )} - {eventButtonState === EventButtonState.NOT_STARTED && ( - This event has not started - )} - {eventButtonState === EventButtonState.EVENT_OVER && ( - This event is over - )} - {eventButtonState === EventButtonState.RECEIVED_POINTS && ( - You received {userEventLog?.points} points for this event - )} - - - {((eventButtonState === EventButtonState.SIGN_IN || eventButtonState === EventButtonState.SIGN_OUT) && (geolocation && geofencingRadius)) && ( - - You must be at the location to scan the QRCode. - - )} - - )} - {/* Header */} @@ -185,8 +145,27 @@ const EventInfo = ({ navigation }: EventProps) => { > + {hasPrivileges && ( + { console.log("Edit Button") }} + className='absolute top-0 right-0 p-2 rounded-full items-center justify-center' + style={{ backgroundColor: 'rgba(0,0,0,0.7)' }} + > + + + )} + + {hasPrivileges && ( + { navigation.navigate("QRCode", { event: event }) }} + className='absolute right-0 bottom-0 p-3 rounded-full m-4 items-center justify-center' + style={{ backgroundColor: 'rgba(0,0,0,0.7)' }} + > + + + )} {/* General Details */} @@ -258,8 +237,67 @@ const EventInfo = ({ navigation }: EventProps) => { )} - + {!loadingUserEventLog && ( + + + {hasPrivileges && ( + + + {attendanceCounts.signedInCount || 0} Signed in + + + {attendanceCounts.signedOutCount || 0} Signed out + + + + )} + { navigation.navigate("QRCodeScanningScreen") }} + disabled={!(eventButtonState == EventButtonState.SIGN_IN || eventButtonState == EventButtonState.SIGN_OUT)} + className={`bg-primary-blue h-14 items-center justify-center rounded-xl mx-4 ${(eventButtonState == EventButtonState.SIGN_IN || eventButtonState == EventButtonState.SIGN_OUT) ? "bg-primary-blue" : "bg-secondary-bg-light border border-grey-dark"}`} + style={{ + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + + elevation: 5, + }} + > + {eventButtonState === EventButtonState.SIGN_IN && ( + Sign In + )} + {eventButtonState === EventButtonState.SIGN_OUT && ( + Sign Out + )} + {eventButtonState === EventButtonState.NOT_STARTED && ( + This event has not started + )} + {eventButtonState === EventButtonState.EVENT_OVER && ( + This event is over + )} + {eventButtonState === EventButtonState.RECEIVED_POINTS && ( + You received {userEventLog?.points} points for this event + )} + + + {((eventButtonState === EventButtonState.SIGN_IN || eventButtonState === EventButtonState.SIGN_OUT) && (geolocation && geofencingRadius)) && ( + + You must be at the location to scan the QRCode. + + )} + + )} ) } From b188249590a369ef2fbb52ae2b19e699af8371e1 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 18 Jun 2024 17:59:10 -0500 Subject: [PATCH 083/198] move attendance count --- src/screens/events/EventInfo.tsx | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/screens/events/EventInfo.tsx b/src/screens/events/EventInfo.tsx index 855b9813..c0cc59da 100644 --- a/src/screens/events/EventInfo.tsx +++ b/src/screens/events/EventInfo.tsx @@ -173,6 +173,21 @@ const EventInfo = ({ navigation }: EventProps) => { This event is eligible for national convention requirements* )} + {hasPrivileges && ( + + + + {attendanceCounts.signedInCount || 0} Member + + + {signInPoints && ( + + + {attendanceCounts.signedOutCount || 0} Member + + )} + + )} {name} {eventType}{committee && (" • " + reverseFormattedFirebaseName(committee))} • {calculateMaxPossiblePoints(event)} points @@ -241,20 +256,6 @@ const EventInfo = ({ navigation }: EventProps) => { {!loadingUserEventLog && ( - {hasPrivileges && ( - - - {attendanceCounts.signedInCount || 0} Signed in - - - {attendanceCounts.signedOutCount || 0} Signed out - - - - )} { navigation.navigate("QRCodeScanningScreen") }} disabled={!(eventButtonState == EventButtonState.SIGN_IN || eventButtonState == EventButtonState.SIGN_OUT)} From b7574fae1c3e651ebc95bc80cf140b4a78b3ca5e Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 18 Jun 2024 18:43:17 -0500 Subject: [PATCH 084/198] add updated edit option to event screen, re-add destroy event button in update event screen --- src/screens/events/EventInfo.tsx | 53 ++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/src/screens/events/EventInfo.tsx b/src/screens/events/EventInfo.tsx index c0cc59da..7d7c495a 100644 --- a/src/screens/events/EventInfo.tsx +++ b/src/screens/events/EventInfo.tsx @@ -4,27 +4,20 @@ import { RouteProp, useFocusEffect, useRoute } from '@react-navigation/core'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { Octicons, FontAwesome6, Entypo } from '@expo/vector-icons'; import { auth } from "../../config/firebaseConfig"; -import { getEvent, getAttendanceNumber, getPublicUserData, getUsers, signInToEvent, signOutOfEvent, getUserEventLog } from '../../api/firebaseUtils'; +import { getAttendanceNumber, getPublicUserData, getUsers, signInToEvent, signOutOfEvent, getUserEventLog } from '../../api/firebaseUtils'; import { UserContext } from '../../context/UserContext'; -import { MillisecondTimes, formatEventDate, formatEventTime, formatTime } from '../../helpers/timeUtils'; -import { SHPEEvent, SHPEEventLog, getStatusMessage } from '../../types/events'; +import { MillisecondTimes, formatEventDate, formatEventTime } from '../../helpers/timeUtils'; +import { SHPEEvent, SHPEEventLog } from '../../types/events'; import { Images } from '../../../assets'; import { StatusBar } from 'expo-status-bar'; -import CalendarIcon from '../../../assets/calandar_pale_blue.svg' -import ClockIcon from '../../../assets/clock-pale-blue.svg' -import MapIcon from '../../../assets/map-pale-blue.svg' -import TargetIcon from '../../../assets/target-pale-blue.svg' import { handleLinkPress } from '../../helpers/links'; import MemberCard from '../../components/MemberCard'; import { PublicUserInfo } from '../../types/user'; import { reverseFormattedFirebaseName } from '../../types/committees'; import MembersList from '../../components/MembersList'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { EventProps, EventsStackParams } from '../../types/navigation'; import { LinearGradient } from 'expo-linear-gradient'; import { KeyboardAwareScrollView } from '@pietile-native-kit/keyboard-aware-scrollview'; -import InteractButton from '../../components/InteractButton'; -import { sign } from 'crypto'; const EventInfo = ({ navigation }: EventProps) => { const route = useRoute(); @@ -43,6 +36,7 @@ const EventInfo = ({ navigation }: EventProps) => { const [loadingUserEventLog, setLoadingUserEventLog] = useState(true); const [userEventLog, setUserEventLog] = useState(null); const [attendanceCounts, setAttendanceCounts] = useState<{ signedInCount: number, signedOutCount: number }>({ signedInCount: 0, signedOutCount: 0 }); + const [showOptionMenu, setShowOptionMenu] = useState(false); useEffect(() => { @@ -146,13 +140,38 @@ const EventInfo = ({ navigation }: EventProps) => { {hasPrivileges && ( - { console.log("Edit Button") }} - className='absolute top-0 right-0 p-2 rounded-full items-center justify-center' - style={{ backgroundColor: 'rgba(0,0,0,0.7)' }} - > - - + + { setShowOptionMenu(!showOptionMenu) }} + className='absolute top-0 right-0 p-2 rounded-full items-center justify-center' + style={{ backgroundColor: 'rgba(0,0,0,0.7)' }} + > + + + {showOptionMenu && ( + + { + setShowOptionMenu(false); + navigation.navigate("UpdateEvent", { event: event }) + }} + > + Edit Event + + + + Manual Sign In + + + + Manual Sign Out + + + )} + )} From 5e949f22963d1c012186fba8ee3ddc7a485004f9 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 18 Jun 2024 18:43:42 -0500 Subject: [PATCH 085/198] add event destroy button --- src/screens/events/UpdateEvent.tsx | 68 ++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/src/screens/events/UpdateEvent.tsx b/src/screens/events/UpdateEvent.tsx index 10574855..356f419f 100644 --- a/src/screens/events/UpdateEvent.tsx +++ b/src/screens/events/UpdateEvent.tsx @@ -2,10 +2,10 @@ import { View, Text, TouchableOpacity, TextInput, Image, Platform, TouchableHigh import React, { useContext, useState } from 'react' import { EventProps, UpdateEventScreenRouteProp } from '../../types/navigation' import { useRoute } from '@react-navigation/core'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { Octicons, FontAwesome } from '@expo/vector-icons'; import { CommitteeMeeting, EventType, GeneralMeeting, IntramuralEvent, CustomEvent, SHPEEvent, SocialEvent, StudyHours, VolunteerEvent, Workshop } from '../../types/events'; -import { setEvent, uploadFile } from '../../api/firebaseUtils'; +import { destroyEvent, setEvent, uploadFile } from '../../api/firebaseUtils'; import DateTimePicker from '@react-native-community/datetimepicker'; import * as ImagePicker from "expo-image-picker"; import { Images } from '../../../assets'; @@ -20,6 +20,8 @@ import InteractButton from '../../components/InteractButton'; import { getBlobFromURI, selectImage } from '../../api/fileSelection'; import { CommonMimeTypes, validateFileBlob } from '../../helpers'; import { auth } from '../../config/firebaseConfig'; +import { LinearGradient } from 'expo-linear-gradient'; +import DismissibleModal from '../../components/DismissibleModal'; const UpdateEvent = ({ navigation }: EventProps) => { const route = useRoute(); @@ -29,6 +31,8 @@ const UpdateEvent = ({ navigation }: EventProps) => { const { userInfo } = userContext!; const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const insets = useSafeAreaInsets(); + // UI Hooks const [showStartDatePicker, setShowStartDatePicker] = useState(false); const [showStartTimePicker, setShowStartTimePicker] = useState(false); @@ -38,6 +42,7 @@ const UpdateEvent = ({ navigation }: EventProps) => { const [localImageURI, setLocalImageURI] = useState(); const [isUploading, setIsUploading] = useState(); const [loading, setLoading] = useState(false); + const [showDeletionConfirmation, setShowDeletionConfirmation] = useState(false); // Form Data Hooks @@ -144,8 +149,19 @@ const UpdateEvent = ({ navigation }: EventProps) => { await setEvent(event.id!, updatedEvent); setLoading(false) + navigation.navigate("EventInfo", { event: updatedEvent }); }; + const handleDestroyEvent = async () => { + const isDeleted = await destroyEvent(event.id!); + if (isDeleted) { + navigation.navigate("EventsScreen") + } else { + console.log("Failed to delete the event."); + } + } + + return ( @@ -177,6 +193,12 @@ const UpdateEvent = ({ navigation }: EventProps) => { }} /> + + @@ -190,14 +212,21 @@ const UpdateEvent = ({ navigation }: EventProps) => { - { navigation.goBack(); }} + onPress={() => { navigation.goBack() }} className="rounded-full w-10 h-10 justify-center items-center" style={{ backgroundColor: 'rgba(0,0,0,0.6)' }} > + + { setShowDeletionConfirmation(true) }} + className="absolute top-0 right-0 rounded-full w-10 h-10 justify-center items-center" + style={{ backgroundColor: 'rgba(0,0,0,.8)' }} + > + + @@ -545,6 +574,37 @@ const UpdateEvent = ({ navigation }: EventProps) => { /> + + + + + + This is *not* reversable! + Are you sure you want to destroy this event? + + { + setShowDeletionConfirmation(false); + setLoading(true); + handleDestroyEvent().then(() => setLoading(false)); + }} + className="w-[45%] bg-red-1 rounded-xl items-center justify-center h-12 mr-2" + > + Delete + + setShowDeletionConfirmation(false)} + className="w-[45%] rounded-xl justify-center items-center ml-2 border border-black" + > + Cancel + + + + + ) } From b4dc33bc0711ee5aea853babd11d5bf780a18c20 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 18 Jun 2024 18:59:00 -0500 Subject: [PATCH 086/198] add new manual sign in/out ui --- src/screens/events/EventInfo.tsx | 128 ++++++++++++++++++++++++++++--- 1 file changed, 117 insertions(+), 11 deletions(-) diff --git a/src/screens/events/EventInfo.tsx b/src/screens/events/EventInfo.tsx index 7d7c495a..2d4ff8e4 100644 --- a/src/screens/events/EventInfo.tsx +++ b/src/screens/events/EventInfo.tsx @@ -1,4 +1,4 @@ -import { View, Text, TouchableOpacity, ActivityIndicator, ScrollView, Image, Platform, Modal } from 'react-native' +import { View, Text, TouchableOpacity, ActivityIndicator, ScrollView, Image, Platform, Modal, Alert } from 'react-native' import React, { useCallback, useContext, useEffect, useState } from 'react' import { RouteProp, useFocusEffect, useRoute } from '@react-navigation/core'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -7,7 +7,7 @@ import { auth } from "../../config/firebaseConfig"; import { getAttendanceNumber, getPublicUserData, getUsers, signInToEvent, signOutOfEvent, getUserEventLog } from '../../api/firebaseUtils'; import { UserContext } from '../../context/UserContext'; import { MillisecondTimes, formatEventDate, formatEventTime } from '../../helpers/timeUtils'; -import { SHPEEvent, SHPEEventLog } from '../../types/events'; +import { SHPEEvent, SHPEEventLog, getStatusMessage } from '../../types/events'; import { Images } from '../../../assets'; import { StatusBar } from 'expo-status-bar'; import { handleLinkPress } from '../../helpers/links'; @@ -38,6 +38,19 @@ const EventInfo = ({ navigation }: EventProps) => { const [attendanceCounts, setAttendanceCounts] = useState<{ signedInCount: number, signedOutCount: number }>({ signedInCount: 0, signedOutCount: 0 }); const [showOptionMenu, setShowOptionMenu] = useState(false); + const [users, setUsers] = useState([]); + const [allUserFetched, setAllUserFetched] = useState(false); + const [forceUpdate, setForceUpdate] = useState(0); + const [userSignInOut, setUserSignInOut] = useState("signIn"); + const [userModalVisible, setUserModalVisible] = useState(false); + const [loading, setLoading] = useState(false); + + const fetchAttendanceCounts = async () => { + setLoading(true); + const counts = await getAttendanceNumber(event.id!); + setAttendanceCounts(counts); + setLoading(false) + } useEffect(() => { const fetchUserEventLog = async () => { @@ -47,11 +60,6 @@ const EventInfo = ({ navigation }: EventProps) => { setLoadingUserEventLog(false); } - const fetchAttendanceCounts = async () => { - const counts = await getAttendanceNumber(event.id!); - setAttendanceCounts(counts); - } - fetchUserEventLog(); fetchAttendanceCounts(); }, []) @@ -67,7 +75,17 @@ const EventInfo = ({ navigation }: EventProps) => { fetchCreatorInfo(); }, [creator]) - console.log(locationName); + const fetchAllUsers = async () => { + try { + const fetchedUsers = await getUsers(); + setUsers(fetchedUsers); + setAllUserFetched(true); + } catch (error) { + console.error("An error occurred while fetching users: ", error); + } + setForceUpdate(prev => prev + 1); + }; + const getEventButtonState = (event: SHPEEvent, userEventLog: SHPEEventLog | null): EventButtonState => { const now = new Date(); @@ -162,11 +180,31 @@ const EventInfo = ({ navigation }: EventProps) => { Edit Event - + { + setShowOptionMenu(false); + setUserSignInOut("signIn") + setUserModalVisible(true) + if (!allUserFetched) { + fetchAllUsers(); + } + }} + > Manual Sign In - + { + setShowOptionMenu(false); + setUserSignInOut("signOut") + setUserModalVisible(true) + if (!allUserFetched) { + fetchAllUsers(); + } + }} + > Manual Sign Out @@ -192,7 +230,13 @@ const EventInfo = ({ navigation }: EventProps) => { This event is eligible for national convention requirements* )} - {hasPrivileges && ( + {loading && ( + + + + )} + + {(hasPrivileges && !loading) && ( @@ -318,6 +362,68 @@ const EventInfo = ({ navigation }: EventProps) => { )} )} + + { + setUserModalVisible(false); + }} + > + + + + + Select a Member + + + setUserModalVisible(false)} + > + + + + + {!allUserFetched && ( + + )} + + + + { + if (userSignInOut === "signIn") { + signInToEvent(event.id!, uid).then((status) => { + Alert.alert( + 'Status', + getStatusMessage(status), + [{ text: 'OK', onPress: () => { fetchAttendanceCounts(); } }], + { cancelable: false } + ); + }); + } else if (userSignInOut === "signOut") { + signOutOfEvent(event.id!, uid).then((status) => { + Alert.alert( + 'Status', + getStatusMessage(status), + [{ text: 'OK', onPress: () => { fetchAttendanceCounts(); } }], + { cancelable: false } + ); + }); + } + setUserModalVisible(false); + }} + users={users} + /> + + + ) } From ecdf6dcfc63ab856f60659e371bdec6427a4c86b Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 18 Jun 2024 19:19:28 -0500 Subject: [PATCH 087/198] add method to close option menu by touching outside of it --- src/screens/events/EventInfo.tsx | 252 ++++++++++++++++--------------- 1 file changed, 127 insertions(+), 125 deletions(-) diff --git a/src/screens/events/EventInfo.tsx b/src/screens/events/EventInfo.tsx index 2d4ff8e4..eaeac3f0 100644 --- a/src/screens/events/EventInfo.tsx +++ b/src/screens/events/EventInfo.tsx @@ -1,6 +1,6 @@ -import { View, Text, TouchableOpacity, ActivityIndicator, ScrollView, Image, Platform, Modal, Alert } from 'react-native' -import React, { useCallback, useContext, useEffect, useState } from 'react' -import { RouteProp, useFocusEffect, useRoute } from '@react-navigation/core'; +import { View, Text, TouchableOpacity, ActivityIndicator, Image, Platform, Modal, Alert, ScrollView } from 'react-native' +import React, { useContext, useEffect, useState } from 'react' +import { RouteProp, useRoute } from '@react-navigation/core'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { Octicons, FontAwesome6, Entypo } from '@expo/vector-icons'; import { auth } from "../../config/firebaseConfig"; @@ -11,13 +11,11 @@ import { SHPEEvent, SHPEEventLog, getStatusMessage } from '../../types/events'; import { Images } from '../../../assets'; import { StatusBar } from 'expo-status-bar'; import { handleLinkPress } from '../../helpers/links'; -import MemberCard from '../../components/MemberCard'; import { PublicUserInfo } from '../../types/user'; import { reverseFormattedFirebaseName } from '../../types/committees'; import MembersList from '../../components/MembersList'; import { EventProps, EventsStackParams } from '../../types/navigation'; import { LinearGradient } from 'expo-linear-gradient'; -import { KeyboardAwareScrollView } from '@pietile-native-kit/keyboard-aware-scrollview'; const EventInfo = ({ navigation }: EventProps) => { const route = useRoute(); @@ -121,7 +119,7 @@ const EventInfo = ({ navigation }: EventProps) => { return ( - + {/* Header */} { {showOptionMenu && ( - - { - setShowOptionMenu(false); - navigation.navigate("UpdateEvent", { event: event }) - }} - > - Edit Event - - - { - setShowOptionMenu(false); - setUserSignInOut("signIn") - setUserModalVisible(true) - if (!allUserFetched) { - fetchAllUsers(); - } - }} - > - Manual Sign In - - - { - setShowOptionMenu(false); - setUserSignInOut("signOut") - setUserModalVisible(true) - if (!allUserFetched) { - fetchAllUsers(); - } - }} + + { setShowOptionMenu(false) }} className='absolute -right-4 w-screen h-screen z-10' /> + + - Manual Sign Out - + { + setShowOptionMenu(false); + navigation.navigate("UpdateEvent", { event: event }) + }} + > + Edit Event + + + { + setShowOptionMenu(false); + setUserSignInOut("signIn") + setUserModalVisible(true) + if (!allUserFetched) { + fetchAllUsers(); + } + }} + > + Manual Sign In + + + { + setShowOptionMenu(false); + setUserSignInOut("signOut") + setUserModalVisible(true) + if (!allUserFetched) { + fetchAllUsers(); + } + }} + > + Manual Sign Out + + )} @@ -217,7 +219,7 @@ const EventInfo = ({ navigation }: EventProps) => { {hasPrivileges && ( { navigation.navigate("QRCode", { event: event }) }} - className='absolute right-0 bottom-0 p-3 rounded-full m-4 items-center justify-center' + className='absolute right-0 bottom-0 p-3 rounded-full m-4 items-center justify-center -z-10' style={{ backgroundColor: 'rgba(0,0,0,0.7)' }} > @@ -225,97 +227,94 @@ const EventInfo = ({ navigation }: EventProps) => { )} - {/* General Details */} - {nationalConventionEligible && ( - This event is eligible for national convention requirements* - )} + + {/* General Details */} + {nationalConventionEligible && (This event is eligible for national convention requirements*)} - {loading && ( - - - - )} + {loading && ()} - {(hasPrivileges && !loading) && ( - - - - {attendanceCounts.signedInCount || 0} Member - - - {signInPoints && ( - - - {attendanceCounts.signedOutCount || 0} Member + {(hasPrivileges && !loading) && ( + + + + {attendanceCounts.signedInCount || 0} Member - )} - - )} - - {name} - {eventType}{committee && (" • " + reverseFormattedFirebaseName(committee))} • {calculateMaxPossiblePoints(event)} points - Hosted By {creatorData?.name} - - {/* Date, Time and Location */} - - - - Date - {(startTime && endTime) ? formatEventDate(startTime.toDate(), endTime.toDate()) : ""} + {signInPoints && ( + + + {attendanceCounts.signedOutCount || 0} Member + + )} + )} - - Time - {startTime && endTime && formatEventTime(startTime.toDate(), endTime.toDate())} - + + {name} + {eventType}{committee && (" • " + reverseFormattedFirebaseName(committee))} • {calculateMaxPossiblePoints(event)} points + Hosted By {creatorData?.name} - {locationName && ( - - Location - - {locationName} {geolocation && ( - { - if (Platform.OS === 'ios') { - handleLinkPress(`http://maps.apple.com/?daddr=${geolocation.latitude},${geolocation.longitude}`); - } else if (Platform.OS === 'android') { - handleLinkPress(`https://www.google.com/maps/dir/?api=1&destination=${geolocation.latitude},${geolocation.longitude}`); - } - }} - > - View in Maps - - )} - + {/* Date, Time and Location */} + + + + Date + {(startTime && endTime) ? formatEventDate(startTime.toDate(), endTime.toDate()) : ""} + + + + Time + {startTime && endTime && formatEventTime(startTime.toDate(), endTime.toDate())} - )} - + {locationName && ( + + Location + + {locationName} {geolocation && ( + { + if (Platform.OS === 'ios') { + handleLinkPress(`http://maps.apple.com/?daddr=${geolocation.latitude},${geolocation.longitude}`); + } else if (Platform.OS === 'android') { + handleLinkPress(`https://www.google.com/maps/dir/?api=1&destination=${geolocation.latitude},${geolocation.longitude}`); + } + }} + > + View in Maps + + )} + - {/* Description */} - {(description && description.trim() != "") && ( - - About Event - {description} + + + )} - )} - - + {/* Description */} + {(description && description.trim() != "") && ( + + About Event + {description} + + )} + + + + {!loadingUserEventLog && ( @@ -348,7 +347,10 @@ const EventInfo = ({ navigation }: EventProps) => { This event is over )} {eventButtonState === EventButtonState.RECEIVED_POINTS && ( - You received {userEventLog?.points} points for this event + + You received {userEventLog?.points} points for this event + Points will be updated later after verification. No action needed. + )} From 6f8704a621d8cb6ed325f65fe8ef183020bfbd81 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 19 Jun 2024 13:47:31 -0500 Subject: [PATCH 088/198] add option to use system default for light/dark mode --- app.json | 8 ++++-- src/api/firebaseUtils.ts | 1 + src/components/SettingsComponents.tsx | 19 +++++++------ src/screens/events/EventInfo.tsx | 12 ++++++-- src/screens/userProfile/Settings.tsx | 40 +++++++++++++++++++++++++-- src/types/user.ts | 3 +- 6 files changed, 67 insertions(+), 16 deletions(-) diff --git a/app.json b/app.json index d8531cef..34c1d091 100644 --- a/app.json +++ b/app.json @@ -6,7 +6,7 @@ "owner": "tamu-shpe", "orientation": "portrait", "icon": "./assets/icon.png", - "userInterfaceStyle": "light", + "userInterfaceStyle": "automatic", "splash": { "image": "./assets/splash.png", "resizeMode": "contain", @@ -52,11 +52,13 @@ "permissions": [ "android.permission.RECORD_AUDIO" ], - "versionCode": 31 + "versionCode": 31, + "userInterfaceStyle": "automatic" }, "ios": { "bundleIdentifier": "com.tamu.shpe", - "buildNumber": "1" + "buildNumber": "1", + "userInterfaceStyle": "automatic" }, "extra": { "eas": { diff --git a/src/api/firebaseUtils.ts b/src/api/firebaseUtils.ts index 18dfd1c4..c94a983a 100644 --- a/src/api/firebaseUtils.ts +++ b/src/api/firebaseUtils.ts @@ -260,6 +260,7 @@ export const initializeCurrentUserData = async (): Promise => { completedAccountSetup: false, settings: { darkMode: false, + useSystemDefault: true, }, expirationDate: Timestamp.fromDate(oneWeekFromNow), email: auth.currentUser?.email ?? "", diff --git a/src/components/SettingsComponents.tsx b/src/components/SettingsComponents.tsx index 071cabe7..6c93a0ff 100644 --- a/src/components/SettingsComponents.tsx +++ b/src/components/SettingsComponents.tsx @@ -22,13 +22,13 @@ const SettingsSectionTitle = ({ text, darkMode }: { text: string, darkMode?: boo */ const SettingsButton = ({ iconName, mainText, mainTextColor, subText, darkMode, onPress }: { iconName?: keyof typeof MaterialCommunityIcons.glyphMap, mainText?: string | null, mainTextColor?: string, subText?: string | null, darkMode?: boolean, onPress?: Function }) => { const mainTextStyle: StyleProp = {} - if(mainTextColor){ + if (mainTextColor) { mainTextStyle.color = mainTextColor; } - else{ + else { mainTextStyle.color = darkMode ? "#FFF" : "#000"; } - + return ( onPress ? onPress() : console.log(`${mainText} Button Pressed`)} @@ -54,13 +54,14 @@ const SettingsButton = ({ iconName, mainText, mainTextColor, subText, darkMode, * @param darkMode - Whether or not the button should display in dark mode. Will default to false * @param onPress - Function that is called when button is pressed. Defaults to logging "Button Pressed" * @param isToggled - Sets whether or not the button is toggled on/off. If this doesn't have a value, the button will stay off. + * @param disabled - Sets whether or not the button is disabled. If this doesn't have a value, the button will stay enabled. */ -const SettingsToggleButton = ({ iconName, mainText, subText, darkMode, onPress, isToggled }: { iconName?: keyof typeof MaterialCommunityIcons.glyphMap, mainText?: string | null, subText?: string | null, darkMode?: boolean, onPress?: () => any | Promise, isToggled?: boolean }) => { +const SettingsToggleButton = ({ iconName, mainText, subText, darkMode, onPress, isToggled, disabled }: { iconName?: keyof typeof MaterialCommunityIcons.glyphMap, mainText?: string | null, subText?: string | null, darkMode?: boolean, onPress?: () => any | Promise, isToggled?: boolean, disabled?: boolean }) => { // Used to guard button from being spammed const [buttonLocked, setButtonLocked] = useState(false); const handleToggle = async () => { - if (buttonLocked) return; + if (buttonLocked || disabled) return; setButtonLocked(true); await new Promise(async (resolve) => { @@ -75,19 +76,21 @@ const SettingsToggleButton = ({ iconName, mainText, subText, darkMode, onPress, handleToggle()} underlayColor={darkMode ? "#444" : "#DDD"} - className='w-full h-24 justify-center px-3' + className={`w-full h-24 justify-center px-3 ${disabled ? 'opacity-50' : ''}`} + disabled={disabled} > {iconName && } - {mainText ?? "Default Text"} - {subText && {subText}} + {mainText ?? "Default Text"} + {subText && {subText}} handleToggle()} value={isToggled} + disabled={disabled} /> diff --git a/src/screens/events/EventInfo.tsx b/src/screens/events/EventInfo.tsx index eaeac3f0..09f63e0a 100644 --- a/src/screens/events/EventInfo.tsx +++ b/src/screens/events/EventInfo.tsx @@ -1,4 +1,4 @@ -import { View, Text, TouchableOpacity, ActivityIndicator, Image, Platform, Modal, Alert, ScrollView } from 'react-native' +import { View, Text, TouchableOpacity, ActivityIndicator, Image, Platform, Modal, Alert, ScrollView, useColorScheme } from 'react-native' import React, { useContext, useEffect, useState } from 'react' import { RouteProp, useRoute } from '@react-navigation/core'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -26,7 +26,15 @@ const EventInfo = ({ navigation }: EventProps) => { const userContext = useContext(UserContext); const { userInfo } = userContext!; - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + + const fixDarkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const useSystemDefault = userInfo?.private?.privateInfo?.settings?.useSystemDefault; + const colorScheme = useColorScheme(); + const darkMode = useSystemDefault ? colorScheme === 'dark' : fixDarkMode; + + + console.log(darkMode, "darkmode") + console.log(colorScheme, "colorScheme") const hasPrivileges = (userInfo?.publicInfo?.roles?.admin?.valueOf() || userInfo?.publicInfo?.roles?.officer?.valueOf() || userInfo?.publicInfo?.roles?.developer?.valueOf()); diff --git a/src/screens/userProfile/Settings.tsx b/src/screens/userProfile/Settings.tsx index 67f4ac3a..9e597eb4 100644 --- a/src/screens/userProfile/Settings.tsx +++ b/src/screens/userProfile/Settings.tsx @@ -620,6 +620,7 @@ const DisplaySettingsScreen = ({ navigation }: NativeStackScreenProps(false); const [darkModeToggled, setDarkModeToggled] = useState(userInfo?.private?.privateInfo?.settings?.darkMode ?? false); + const [systemDefaultToggled, setSystemDefaultToggled] = useState(userInfo?.private?.privateInfo?.settings?.useSystemDefault ?? false); const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode @@ -642,7 +643,8 @@ const DisplaySettingsScreen = ({ navigation }: NativeStackScreenProps { @@ -652,8 +654,42 @@ const DisplaySettingsScreen = ({ navigation }: NativeStackScreenProps console.error(err)); + } + }) + .catch((err) => console.error(err)) + .finally(() => { + setLoading(false); + }); + }} + disabled={systemDefaultToggled} + /> + + { + setSystemDefaultToggled(!systemDefaultToggled); + setLoading(true); + await setPrivateUserData({ + settings: { + useSystemDefault: !systemDefaultToggled + } + }) + .then(async () => { + if (auth.currentUser?.uid) { + await getUser(auth.currentUser?.uid) + .then(async (firebaseUser) => { + if (firebaseUser) { + setUserInfo(firebaseUser); + await AsyncStorage.setItem("@user", JSON.stringify(firebaseUser)); + } else { console.warn("firebaseUser returned as undefined when attempting to sync. Sync will be skipped."); } }) diff --git a/src/types/user.ts b/src/types/user.ts index 3c6b03a0..3ae6ec8d 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -54,7 +54,8 @@ export interface PublicUserInfo { * Different sliders and values will be added to this over time. */ export interface AppSettings { - darkMode: boolean; + darkMode?: boolean; + useSystemDefault?: boolean; } /** From 27881ac6ebcbf5fc01e6b01bb9be6e792bae43e4 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 19 Jun 2024 14:49:11 -0500 Subject: [PATCH 089/198] add darkMode to event Info screen add and system default mode to other event screens --- assets/SHPE_WHITE.png | Bin 0 -> 30858 bytes assets/index.ts | 1 + src/components/MemberCard.tsx | 20 ++++-- src/components/MembersList.tsx | 43 ++++++++---- src/screens/events/EventCard.tsx | 11 ++-- src/screens/events/EventInfo.tsx | 106 +++++++++++++++++------------- src/screens/events/Events.tsx | 10 ++- src/screens/events/PastEvents.tsx | 7 +- 8 files changed, 125 insertions(+), 73 deletions(-) create mode 100644 assets/SHPE_WHITE.png diff --git a/assets/SHPE_WHITE.png b/assets/SHPE_WHITE.png new file mode 100644 index 0000000000000000000000000000000000000000..ed91e5b8597f73c2b8a4f91b40a809730b297824 GIT binary patch literal 30858 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 KuK29&?f(Nq#HvgH literal 0 HcmV?d00001 diff --git a/assets/index.ts b/assets/index.ts index 6dd3f11b..27c02ccc 100644 --- a/assets/index.ts +++ b/assets/index.ts @@ -25,4 +25,5 @@ export const Images = { SHPE_NAVY: require("./SHPE_NAVY.png"), SHPE_NAVY_HORIZ: require("./SHPE_NAVY_Horizontal.png"), SHPE_NAVY_HEADER: require("./SHPE_NAVY_Header.png"), + SHPE_WHITE: require("./SHPE_WHITE.png"), }; diff --git a/src/components/MemberCard.tsx b/src/components/MemberCard.tsx index 899ada49..5fe2b7fb 100644 --- a/src/components/MemberCard.tsx +++ b/src/components/MemberCard.tsx @@ -1,12 +1,20 @@ -import { Image, Text, TouchableOpacity, View } from 'react-native' -import React, { useEffect, useState } from 'react' +import { Image, Text, TouchableOpacity, View, useColorScheme } from 'react-native' +import React, { useContext, useEffect, useState } from 'react' import { MemberCardProp } from '../types/navigation' import { Images } from '../../assets' import TwitterSvg from './TwitterSvg' import { getBadgeColor, isMemberVerified } from '../helpers/membership' +import { UserContext } from '../context/UserContext' const MemberCard: React.FC = ({ userData, handleCardPress, navigation, displayPoints }) => { if (!userData) { return } + const userContext = useContext(UserContext); + const { userInfo } = userContext!; + + const fixDarkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const useSystemDefault = userInfo?.private?.privateInfo?.settings?.useSystemDefault; + const colorScheme = useColorScheme(); + const darkMode = useSystemDefault ? colorScheme === 'dark' : fixDarkMode; const { name, roles, uid, displayName, photoURL, chapterExpiration, nationalExpiration, pointsThisMonth } = userData const isOfficer = roles ? roles.officer : false; @@ -24,7 +32,7 @@ const MemberCard: React.FC = ({ userData, handleCardPress, navig handleCardPress && handleCardPress(uid!)} - activeOpacity={!!handleCardPress && 1 || 0.6} + activeOpacity={!!handleCardPress ? 1 : 0.6} > = ({ userData, handleCardPress, navig - {name} + {name} {(isOfficer || isVerified) && } - {displayName} + {displayName} {displayPoints && pointsThisMonth?.valueOf && ( - {parseFloat(pointsThisMonth.toFixed(3))} pts + {parseFloat(pointsThisMonth.toFixed(3))} pts )} diff --git a/src/components/MembersList.tsx b/src/components/MembersList.tsx index a0c863c9..c988a805 100644 --- a/src/components/MembersList.tsx +++ b/src/components/MembersList.tsx @@ -1,13 +1,22 @@ -import { View, Text, ScrollView, TouchableOpacity, TextInput } from 'react-native' -import React, { useEffect, useRef, useState } from 'react' +import { View, Text, ScrollView, TouchableOpacity, TextInput, useColorScheme } from 'react-native' +import React, { useContext, useEffect, useRef, useState } from 'react' import { Octicons } from '@expo/vector-icons'; import { HomeStackParams } from '../types/navigation' import MemberCard from './MemberCard' import { MAJORS, PublicUserInfo, UserFilter, classYears } from '../types/user'; import CustomDropDownMenu, { CustomDropDownMethods } from './CustomDropDown'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { UserContext } from '../context/UserContext'; const MembersList: React.FC = ({ handleCardPress, users, navigation }) => { + const userContext = useContext(UserContext); + const { userInfo } = userContext!; + + const fixDarkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const useSystemDefault = userInfo?.private?.privateInfo?.settings?.useSystemDefault; + const colorScheme = useColorScheme(); + const darkMode = useSystemDefault ? colorScheme === 'dark' : fixDarkMode; + const [search, setSearch] = useState("") const [showFilterMenu, setShowFilterMenu] = useState(false); const [filter, setFilter] = useState({ major: "", classYear: "", role: "" }); @@ -68,14 +77,25 @@ const MembersList: React.FC = ({ handleCardPress, users, naviga { inputRef.current?.focus() }} + style={{ + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + + elevation: 5, + }} > - + { setSearch(text); }} @@ -83,19 +103,20 @@ const MembersList: React.FC = ({ handleCardPress, users, naviga value={search} underlineColorAndroid="transparent" placeholder="Search" + placeholderTextColor={"grey"} className='flex-1 text-lg justify-center' /> - setShowFilterMenu(!showFilterMenu)} className='pl-4 items-center justify-center' style={{ minWidth: 45 }} > - + */} - {showFilterMenu && ( + {/* {showFilterMenu && ( @@ -124,7 +145,7 @@ const MembersList: React.FC = ({ handleCardPress, users, naviga /> handleApplyFilter()} - className='items-center justify-center bg-pale-blue py-2 w-20 rounded-lg ml-3'> + className='items-center justify-center bg-primary-blue py-2 w-20 rounded-lg ml-3'> Apply @@ -146,12 +167,12 @@ const MembersList: React.FC = ({ handleCardPress, users, naviga handleClearFilter()} className='items-center justify-center py-2 w-20 rounded-lg ml-3'> - Rest + Reset - )} + )} */} diff --git a/src/screens/events/EventCard.tsx b/src/screens/events/EventCard.tsx index 3f4c957a..87c182fc 100644 --- a/src/screens/events/EventCard.tsx +++ b/src/screens/events/EventCard.tsx @@ -1,4 +1,4 @@ -import { View, Text, TouchableOpacity, Image } from 'react-native' +import { View, Text, TouchableOpacity, Image, useColorScheme } from 'react-native' import React, { useContext } from 'react' import { FontAwesome6 } from '@expo/vector-icons'; import { UserContext } from '../../context/UserContext' @@ -9,7 +9,10 @@ import { SHPEEvent } from '../../types/events' const EventCard = ({ event, navigation }: { event: SHPEEvent, navigation: any }) => { const userContext = useContext(UserContext); const { userInfo } = userContext!; - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const fixDarkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const useSystemDefault = userInfo?.private?.privateInfo?.settings?.useSystemDefault; + const colorScheme = useColorScheme(); + const darkMode = useSystemDefault ? colorScheme === 'dark' : fixDarkMode; const hasPrivileges = (userInfo?.publicInfo?.roles?.admin?.valueOf() || userInfo?.publicInfo?.roles?.officer?.valueOf() || userInfo?.publicInfo?.roles?.developer?.valueOf()); @@ -32,8 +35,8 @@ const EventCard = ({ event, navigation }: { event: SHPEEvent, navigation: any }) diff --git a/src/screens/events/EventInfo.tsx b/src/screens/events/EventInfo.tsx index 09f63e0a..937ee08c 100644 --- a/src/screens/events/EventInfo.tsx +++ b/src/screens/events/EventInfo.tsx @@ -32,10 +32,6 @@ const EventInfo = ({ navigation }: EventProps) => { const colorScheme = useColorScheme(); const darkMode = useSystemDefault ? colorScheme === 'dark' : fixDarkMode; - - console.log(darkMode, "darkmode") - console.log(colorScheme, "colorScheme") - const hasPrivileges = (userInfo?.publicInfo?.roles?.admin?.valueOf() || userInfo?.publicInfo?.roles?.officer?.valueOf() || userInfo?.publicInfo?.roles?.developer?.valueOf()); const [creatorData, setCreatorData] = useState(null); @@ -57,7 +53,8 @@ const EventInfo = ({ navigation }: EventProps) => { setAttendanceCounts(counts); setLoading(false) } - + console.log("locationName:", locationName); + console.log("geolocation:", geolocation); useEffect(() => { const fetchUserEventLog = async () => { setLoadingUserEventLog(true); @@ -139,8 +136,8 @@ const EventInfo = ({ navigation }: EventProps) => { > { /> { navigation.goBack(); }} - className="rounded-full w-10 h-10 justify-center items-center" + className="rounded-full w-10 h-10 justify-center items-center z-20" style={{ backgroundColor: 'rgba(0,0,0,0.6)' }} > @@ -226,8 +227,11 @@ const EventInfo = ({ navigation }: EventProps) => { {hasPrivileges && ( { navigation.navigate("QRCode", { event: event }) }} - className='absolute right-0 bottom-0 p-3 rounded-full m-4 items-center justify-center -z-10' + onPress={() => { + navigation.navigate("QRCode", { event: event }) + setShowOptionMenu(false) + }} + className='absolute right-0 bottom-0 p-3 rounded-full m-4 items-center justify-center' style={{ backgroundColor: 'rgba(0,0,0,0.7)' }} > @@ -237,21 +241,25 @@ const EventInfo = ({ navigation }: EventProps) => { {/* General Details */} - {nationalConventionEligible && (This event is eligible for national convention requirements*)} + {nationalConventionEligible && ( + + This event is eligible for national convention requirements* + + )} {loading && ()} {(hasPrivileges && !loading) && ( - - {attendanceCounts.signedInCount || 0} Member + + {attendanceCounts.signedInCount || 0} Member {signInPoints && ( - - {attendanceCounts.signedOutCount || 0} Member + + {attendanceCounts.signedOutCount || 0} Member )} @@ -259,12 +267,17 @@ const EventInfo = ({ navigation }: EventProps) => { {name} - {eventType}{committee && (" • " + reverseFormattedFirebaseName(committee))} • {calculateMaxPossiblePoints(event)} points - Hosted By {creatorData?.name} + + {eventType} + {committee && (" • " + reverseFormattedFirebaseName(committee))} • {calculateMaxPossiblePoints(event)} points + + + Hosted By {creatorData?.name} + {/* Date, Time and Location */} - { }, shadowOpacity: 0.25, shadowRadius: 3.84, - elevation: 5, }} > - Date - {(startTime && endTime) ? formatEventDate(startTime.toDate(), endTime.toDate()) : ""} + Date + {(startTime && endTime) ? formatEventDate(startTime.toDate(), endTime.toDate()) : ""} - Time - {startTime && endTime && formatEventTime(startTime.toDate(), endTime.toDate())} + Time + {startTime && endTime && formatEventTime(startTime.toDate(), endTime.toDate())} + {locationName && ( - Location - - {locationName} {geolocation && ( + Location + + + {locationName} + + {geolocation && ( { if (Platform.OS === 'ios') { @@ -302,11 +318,9 @@ const EventInfo = ({ navigation }: EventProps) => { } }} > - View in Maps + View in Maps )} - - )} @@ -316,20 +330,21 @@ const EventInfo = ({ navigation }: EventProps) => { {(description && description.trim() != "") && ( About Event - {description} + {description} )} - + + {!loadingUserEventLog && ( { navigation.navigate("QRCodeScanningScreen") }} disabled={!(eventButtonState == EventButtonState.SIGN_IN || eventButtonState == EventButtonState.SIGN_OUT)} - className={`bg-primary-blue h-14 items-center justify-center rounded-xl mx-4 ${(eventButtonState == EventButtonState.SIGN_IN || eventButtonState == EventButtonState.SIGN_OUT) ? "bg-primary-blue" : "bg-secondary-bg-light border border-grey-dark"}`} + className={`h-14 items-center justify-center rounded-xl mx-4 ${(eventButtonState == EventButtonState.SIGN_IN || eventButtonState == EventButtonState.SIGN_OUT) ? "bg-primary-blue" : `${darkMode ? "bg-secondary-bg-dark border border-grey-light" : "bg-secondary-bg-light border border-grey-dark"}`}`} style={{ shadowColor: "#000", shadowOffset: { @@ -338,7 +353,6 @@ const EventInfo = ({ navigation }: EventProps) => { }, shadowOpacity: 0.25, shadowRadius: 3.84, - elevation: 5, }} > @@ -349,15 +363,15 @@ const EventInfo = ({ navigation }: EventProps) => { Sign Out )} {eventButtonState === EventButtonState.NOT_STARTED && ( - This event has not started + This event has not started )} {eventButtonState === EventButtonState.EVENT_OVER && ( - This event is over + This event is over )} {eventButtonState === EventButtonState.RECEIVED_POINTS && ( - You received {userEventLog?.points} points for this event - Points will be updated later after verification. No action needed. + You received {userEventLog?.points} points for this event + Points will be updated later after verification. No action needed. )} @@ -365,9 +379,9 @@ const EventInfo = ({ navigation }: EventProps) => { {((eventButtonState === EventButtonState.SIGN_IN || eventButtonState === EventButtonState.SIGN_OUT) && (geolocation && geofencingRadius)) && ( - You must be at the location to scan the QRCode. + You must be at the location to scan the QRCode. )} @@ -383,19 +397,18 @@ const EventInfo = ({ navigation }: EventProps) => { > - - Select a Member + Select a Member setUserModalVisible(false)} > - + @@ -403,8 +416,7 @@ const EventInfo = ({ navigation }: EventProps) => { )} - - + { diff --git a/src/screens/events/Events.tsx b/src/screens/events/Events.tsx index 01e51568..5bf1c423 100644 --- a/src/screens/events/Events.tsx +++ b/src/screens/events/Events.tsx @@ -12,11 +12,15 @@ import { Images } from '../../../assets'; import { formatTime } from '../../helpers/timeUtils'; import EventCard from './EventCard'; import { StatusBar } from 'expo-status-bar'; +import { useColorScheme } from 'react-native'; const Events = ({ navigation }: NativeStackScreenProps) => { const userContext = useContext(UserContext); const { userInfo } = userContext!; - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const fixDarkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const useSystemDefault = userInfo?.private?.privateInfo?.settings?.useSystemDefault; + const colorScheme = useColorScheme(); + const darkMode = useSystemDefault ? colorScheme === 'dark' : fixDarkMode; const [todayEvents, setTodayEvents] = useState([]); const [upcomingEvents, setUpcomingEvents] = useState([]); @@ -228,8 +232,8 @@ const Events = ({ navigation }: NativeStackScreenProps) => { ) => { const userContext = useContext(UserContext); const { userInfo } = userContext!; - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const fixDarkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const useSystemDefault = userInfo?.private?.privateInfo?.settings?.useSystemDefault; + const colorScheme = useColorScheme(); + const darkMode = useSystemDefault ? colorScheme === 'dark' : fixDarkMode; const [pastEvents, setPastEvents] = useState([]); const [loading, setLoading] = useState(false); From 0f825210a612c60b09e915e1c6434b1cbf495ca3 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 19 Jun 2024 14:52:51 -0500 Subject: [PATCH 090/198] add system default to event update screen --- src/screens/events/UpdateEvent.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/screens/events/UpdateEvent.tsx b/src/screens/events/UpdateEvent.tsx index 356f419f..2dfc21f4 100644 --- a/src/screens/events/UpdateEvent.tsx +++ b/src/screens/events/UpdateEvent.tsx @@ -1,4 +1,4 @@ -import { View, Text, TouchableOpacity, TextInput, Image, Platform, TouchableHighlight, Modal, Alert, ActivityIndicator } from 'react-native' +import { View, Text, TouchableOpacity, TextInput, Image, Platform, TouchableHighlight, Modal, Alert, ActivityIndicator, useColorScheme } from 'react-native' import React, { useContext, useState } from 'react' import { EventProps, UpdateEventScreenRouteProp } from '../../types/navigation' import { useRoute } from '@react-navigation/core'; @@ -29,7 +29,11 @@ const UpdateEvent = ({ navigation }: EventProps) => { const userContext = useContext(UserContext); const { userInfo } = userContext!; - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + + const fixDarkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const useSystemDefault = userInfo?.private?.privateInfo?.settings?.useSystemDefault; + const colorScheme = useColorScheme(); + const darkMode = useSystemDefault ? colorScheme === 'dark' : fixDarkMode; const insets = useSafeAreaInsets(); @@ -184,8 +188,8 @@ const UpdateEvent = ({ navigation }: EventProps) => { > Date: Wed, 19 Jun 2024 15:35:56 -0500 Subject: [PATCH 091/198] add dark mode to event creation screens --- src/components/LocationPicker.tsx | 120 ++++++++++++------ src/screens/events/CreateEvent.tsx | 21 +-- src/screens/events/EventInfo.tsx | 3 +- src/screens/events/SetGeneralEventDetails.tsx | 8 +- .../events/SetLocationEventDetails.tsx | 15 ++- .../events/SetSpecificEventDetails.tsx | 112 ++++++++-------- 6 files changed, 163 insertions(+), 116 deletions(-) diff --git a/src/components/LocationPicker.tsx b/src/components/LocationPicker.tsx index d8ac6efc..d6fbc2ff 100644 --- a/src/components/LocationPicker.tsx +++ b/src/components/LocationPicker.tsx @@ -1,5 +1,5 @@ -import { View, Text, Switch } from 'react-native'; -import React, { useEffect, useState } from 'react'; +import { View, Text, Switch, useColorScheme } from 'react-native'; +import React, { useContext, useEffect, useState } from 'react'; import { GooglePlacesAutocomplete, GooglePlaceDetail } from 'react-native-google-places-autocomplete'; import MapView, { Marker, Circle, LatLng, Region } from 'react-native-maps'; import * as Location from 'expo-location' @@ -7,6 +7,7 @@ import { GooglePlacesApiKey, presetLocationList, reverseGeocode } from '../helpe import Slider from '@react-native-community/slider'; import { TouchableOpacity } from 'react-native'; import { Octicons } from '@expo/vector-icons'; +import { UserContext } from '../context/UserContext'; const zacharyCoords = { latitude: 30.621160236499136, longitude: -96.3403560168198 } const initialMapDelta = { latitudeDelta: 0.0922, longitudeDelta: 0.0421 } // Size of map view @@ -17,6 +18,14 @@ const LocationPicker = ({ onLocationChange, initialCoordinate = zacharyCoords, i initialRadius?: number, containerClassName?: string }) => { + const userContext = useContext(UserContext); + const { userInfo } = userContext!; + + const fixDarkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const useSystemDefault = userInfo?.private?.privateInfo?.settings?.useSystemDefault; + const colorScheme = useColorScheme(); + const darkMode = useSystemDefault ? colorScheme === 'dark' : fixDarkMode; + const [userLocation, setUserLocation] = useState(); const [locationDetails, setLocationDetails] = useState(); const [draggableMarkerCoord, setDraggableMarkerCoord] = useState(initialCoordinate); @@ -73,37 +82,72 @@ const LocationPicker = ({ onLocationChange, initialCoordinate = zacharyCoords, i )} - + - {/* Search Box for to search using Google Places */} - { - if (details === null) { - alert("There was a problem searching for that location. Please try again.") - return; - } + {/* Search Box for Google Places */} + + { + if (details === null) { + alert("There was a problem searching for that location. Please try again."); + return; + } - setLocationDetails(details); - setDraggableMarkerCoord({ - latitude: details.geometry.location.lat, - longitude: details.geometry.location.lng, - }); - setMapRegion({ - latitude: details.geometry.location.lat, - longitude: details.geometry.location.lng, - ...initialMapDelta - }); - setLocationDetails(details); - }} - fetchDetails={true} - predefinedPlaces={presetLocationList} - onFail={(error) => console.error(error)} - /> + setLocationDetails(details); + setDraggableMarkerCoord({ + latitude: details.geometry.location.lat, + longitude: details.geometry.location.lng, + }); + setMapRegion({ + latitude: details.geometry.location.lat, + longitude: details.geometry.location.lng, + ...initialMapDelta, + }); + setLocationDetails(details); + }} + fetchDetails={true} + predefinedPlaces={presetLocationList} + onFail={(error) => console.error(error)} + styles={{ + textInputContainer: { + backgroundColor: darkMode ? 'black' : 'white', + borderRadius: 10, + paddingHorizontal: 10, + }, + textInput: { + backgroundColor: darkMode ? 'black' : 'white', + color: darkMode ? 'white' : 'black', + borderRadius: 10, + height: 40, + }, + listView: { + position: 'absolute', + top: 50, + left: 0, + right: 0, + backgroundColor: darkMode ? 'black' : 'white', + zIndex: 9999, + borderRadius: 10, + }, + row: { + backgroundColor: darkMode ? 'black' : 'white', + borderBottomWidth: 0.5, + borderBottomColor: darkMode ? 'gray' : 'lightgray', + }, + description: { + color: darkMode ? 'white' : 'black', + }, + predefinedPlacesDescription: { + color: darkMode ? 'white' : 'black', + }, + }} + /> + - + {!geofencingEnabled && ( - - Area Restriction + Area Restriction { setGeofencingEnabled(true); setRadius(defaultRadius); }}> - Enable + Enable )} {geofencingEnabled && ( - + - - {radius?.toFixed(0)}m + {radius?.toFixed(0)}m )} diff --git a/src/screens/events/CreateEvent.tsx b/src/screens/events/CreateEvent.tsx index 8db75b26..9271ab4b 100644 --- a/src/screens/events/CreateEvent.tsx +++ b/src/screens/events/CreateEvent.tsx @@ -1,4 +1,4 @@ -import { View, Text, TouchableOpacity, Alert } from 'react-native' +import { View, Text, TouchableOpacity, Alert, useColorScheme } from 'react-native' import React, { useContext, useState } from 'react' import { CommitteeMeeting, CustomEvent, EventType, GeneralMeeting, IntramuralEvent, SHPEEvent, SocialEvent, StudyHours, VolunteerEvent, Workshop } from '../../types/events' import { SafeAreaView } from 'react-native-safe-area-context' @@ -20,7 +20,11 @@ import { StatusBar } from 'expo-status-bar'; const CreateEvent = ({ navigation }: NativeStackScreenProps) => { const userContext = useContext(UserContext); const { userInfo } = userContext!; - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + + const fixDarkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const useSystemDefault = userInfo?.private?.privateInfo?.settings?.useSystemDefault; + const colorScheme = useColorScheme(); + const darkMode = useSystemDefault ? colorScheme === 'dark' : fixDarkMode; const [selectedEventType, setSelectedEventType] = useState(); @@ -32,7 +36,7 @@ const CreateEvent = ({ navigation }: NativeStackScreenProps) return ( ) }, shadowOpacity: 0.25, shadowRadius: 3.84, - elevation: 5, }} onPress={() => { @@ -49,22 +52,20 @@ const CreateEvent = ({ navigation }: NativeStackScreenProps) setSelectedEventType(undefined); return; } - setSelectedEventType(eventType) + setSelectedEventType(eventType); }} > - + - + {Image && } - - {label} + {label} ) } - return ( diff --git a/src/screens/events/EventInfo.tsx b/src/screens/events/EventInfo.tsx index 937ee08c..7a02a0df 100644 --- a/src/screens/events/EventInfo.tsx +++ b/src/screens/events/EventInfo.tsx @@ -53,8 +53,7 @@ const EventInfo = ({ navigation }: EventProps) => { setAttendanceCounts(counts); setLoading(false) } - console.log("locationName:", locationName); - console.log("geolocation:", geolocation); + useEffect(() => { const fetchUserEventLog = async () => { setLoadingUserEventLog(true); diff --git a/src/screens/events/SetGeneralEventDetails.tsx b/src/screens/events/SetGeneralEventDetails.tsx index 51612850..2aa25624 100644 --- a/src/screens/events/SetGeneralEventDetails.tsx +++ b/src/screens/events/SetGeneralEventDetails.tsx @@ -1,4 +1,4 @@ -import { View, Text, TouchableOpacity, TextInput, Alert, TouchableHighlight, Platform, Image } from 'react-native'; +import { View, Text, TouchableOpacity, TextInput, Alert, TouchableHighlight, Platform, Image, useColorScheme } from 'react-native'; import React, { useContext, useState } from 'react'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Octicons, FontAwesome } from '@expo/vector-icons'; @@ -24,7 +24,11 @@ const SetGeneralEventDetails = ({ navigation }: EventProps) => { const userContext = useContext(UserContext); const { userInfo } = userContext!; - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + + const fixDarkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const useSystemDefault = userInfo?.private?.privateInfo?.settings?.useSystemDefault; + const colorScheme = useColorScheme(); + const darkMode = useSystemDefault ? colorScheme === 'dark' : fixDarkMode; // UI Hooks const [showStartDatePicker, setShowStartDatePicker] = useState(false); diff --git a/src/screens/events/SetLocationEventDetails.tsx b/src/screens/events/SetLocationEventDetails.tsx index 9acaa83a..e12ee94e 100644 --- a/src/screens/events/SetLocationEventDetails.tsx +++ b/src/screens/events/SetLocationEventDetails.tsx @@ -1,4 +1,4 @@ -import { View, Text, TouchableOpacity, TextInput } from 'react-native' +import { View, Text, TouchableOpacity, TextInput, useColorScheme } from 'react-native' import React, { useContext, useState } from 'react' import { EventProps, UpdateEventScreenRouteProp } from '../../types/navigation' import { useRoute } from '@react-navigation/core'; @@ -17,7 +17,11 @@ const SetLocationEventDetails = ({ navigation }: EventProps) => { const userContext = useContext(UserContext); const { userInfo } = userContext!; - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + + const fixDarkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const useSystemDefault = userInfo?.private?.privateInfo?.settings?.useSystemDefault; + const colorScheme = useColorScheme(); + const darkMode = useSystemDefault ? colorScheme === 'dark' : fixDarkMode; const [locationName, setLocationName] = useState(event.locationName ?? undefined); const [geolocation, setGeolocation] = useState(event.geolocation ?? undefined); @@ -85,12 +89,11 @@ const SetLocationEventDetails = ({ navigation }: EventProps) => { }} /> - Location details can be changed later + Location details can be changed later - ); diff --git a/src/screens/events/SetSpecificEventDetails.tsx b/src/screens/events/SetSpecificEventDetails.tsx index ad3feb2c..99254632 100644 --- a/src/screens/events/SetSpecificEventDetails.tsx +++ b/src/screens/events/SetSpecificEventDetails.tsx @@ -1,4 +1,4 @@ -import { View, Text, TouchableOpacity, Alert, KeyboardAvoidingView, Switch, ScrollView } from 'react-native'; +import { View, Text, TouchableOpacity, Alert, KeyboardAvoidingView, Switch, ScrollView, useColorScheme, Modal } from 'react-native'; import React, { useContext, useEffect, useRef, useState } from 'react'; import { Octicons } from '@expo/vector-icons'; import { EventProps, UpdateEventScreenRouteProp } from '../../types/navigation'; @@ -20,7 +20,11 @@ const SetSpecificEventDetails = ({ navigation }: EventProps) => { const userContext = useContext(UserContext); const { userInfo } = userContext!; - const darkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + + const fixDarkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const useSystemDefault = userInfo?.private?.privateInfo?.settings?.useSystemDefault; + const colorScheme = useColorScheme(); + const darkMode = useSystemDefault ? colorScheme === 'dark' : fixDarkMode; const insets = useSafeAreaInsets(); @@ -63,14 +67,12 @@ const SetSpecificEventDetails = ({ navigation }: EventProps) => { }; return ( - + - - {/* Header */} Specific Details @@ -88,7 +90,7 @@ const SetSpecificEventDetails = ({ navigation }: EventProps) => { {event.signInPoints !== undefined && ( - { }, shadowOpacity: 0.25, shadowRadius: 3.84, - elevation: 5, }} > - Sign In + Sign In {points.map((point) => ( { if (signInPoints === point) { setSignInPoints(undefined); @@ -116,7 +117,7 @@ const SetSpecificEventDetails = ({ navigation }: EventProps) => { } }} > - +{point} + +{point} ))} @@ -124,7 +125,7 @@ const SetSpecificEventDetails = ({ navigation }: EventProps) => { )} {event.signOutPoints !== undefined && ( - { }, shadowOpacity: 0.25, shadowRadius: 3.84, - elevation: 5, }} > - Sign Out + Sign Out {points.map((point) => ( { if (signOutPoints === point) { setSignOutPoints(undefined); @@ -152,7 +152,7 @@ const SetSpecificEventDetails = ({ navigation }: EventProps) => { } }} > - +{point} + +{point} ))} @@ -160,7 +160,7 @@ const SetSpecificEventDetails = ({ navigation }: EventProps) => { )} {event.pointsPerHour !== undefined && ( - { }, shadowOpacity: 0.25, shadowRadius: 3.84, - elevation: 5, }} > - Hourly + Hourly {points.map((point) => ( { if (pointsPerHour === point) { setPointsPerHour(undefined); @@ -188,13 +187,12 @@ const SetSpecificEventDetails = ({ navigation }: EventProps) => { } }} > - +{point} + +{point} ))} )} - {/* Event Scope (Club-Wide, Associated Committees, Notifications)*/} @@ -202,7 +200,7 @@ const SetSpecificEventDetails = ({ navigation }: EventProps) => { Event Scope - { }, shadowOpacity: 0.25, shadowRadius: 3.84, - elevation: 5, }} > - Club-Wide + Club-Wide { /> - { }, shadowOpacity: 0.25, shadowRadius: 3.84, - elevation: 5, }} > - Associated Committee + Associated Committee { - { }, shadowOpacity: 0.25, shadowRadius: 3.84, - elevation: 5, }} > - Notifications + Notifications { {!notificationSent && ( {isGeneral ? ( - All Members will be notified + All Members will be notified ) : ( <> {committee && committee !== "" && eventTypeNotification.includes(event.eventType!) ? ( - This will notify {reverseFormattedFirebaseName(committee)} members and those interested in {event.eventType} + + This will notify {reverseFormattedFirebaseName(committee)} members and those interested in {event.eventType} + ) : ( {committee && committee !== "" && ( - Member in {reverseFormattedFirebaseName(committee)} will be notified + + Member in {reverseFormattedFirebaseName(committee)} will be notified + )} {eventTypeNotification.includes(event.eventType!) && ( - Members interested in {event.eventType} will be notified + + Members interested in {event.eventType} will be notified + )} )} @@ -307,10 +308,8 @@ const SetSpecificEventDetails = ({ navigation }: EventProps) => { - setAdvanceOptionsModal(true)} - > - Advanced Options + setAdvanceOptionsModal(true)}> + Advanced Options { )} - setAdvanceOptionsModal(false)} > - + {/* Header */} @@ -369,7 +370,7 @@ const SetSpecificEventDetails = ({ navigation }: EventProps) => { {/* Advance Options */} - { }, shadowOpacity: 0.25, shadowRadius: 3.84, - elevation: 5, }} > - Eligible for National Convention + Eligible for National Convention { /> - { }, shadowOpacity: 0.25, shadowRadius: 3.84, - elevation: 5, }} > - Hidden Event + Hidden Event { - { }, shadowOpacity: 0.25, shadowRadius: 3.84, - elevation: 5, }} > - Start Buffer (mins) + Start Buffer (mins) { - Allow to scan QRCode {startTimeBuffer && ((startTimeBuffer / 60000).toFixed(0)).toString()} mins before event starts + Allow to scan QRCode {startTimeBuffer && ((startTimeBuffer / 60000).toFixed(0)).toString()} mins before event starts - { }, shadowOpacity: 0.25, shadowRadius: 3.84, - elevation: 5, }} > - End Buffer (mins) + End Buffer (mins) { - Allow to scan QRCode {endTimeBuffer && ((endTimeBuffer / 60000).toFixed(0)).toString()} mins after event ends + Allow to scan QRCode {endTimeBuffer && ((endTimeBuffer / 60000).toFixed(0)).toString()} mins after event ends - + + ); }; From 5929e718960d1e485bcc7b27fe70c0857d32f5e3 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 19 Jun 2024 15:43:57 -0500 Subject: [PATCH 092/198] add auto refetch event for officers --- src/screens/events/Events.tsx | 34 +++++++++++----------------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/src/screens/events/Events.tsx b/src/screens/events/Events.tsx index 5bf1c423..85be29e0 100644 --- a/src/screens/events/Events.tsx +++ b/src/screens/events/Events.tsx @@ -1,5 +1,5 @@ -import { View, Text, TouchableOpacity, ActivityIndicator, ScrollView, Image } from 'react-native' -import React, { useContext, useEffect, useState } from 'react' +import { View, Text, TouchableOpacity, ActivityIndicator, ScrollView, Image, useColorScheme } from 'react-native' +import React, { useCallback, useContext, useEffect, useState } from 'react' import { SafeAreaView } from 'react-native-safe-area-context'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { Ionicons, Octicons, FontAwesome6 } from '@expo/vector-icons'; @@ -12,7 +12,7 @@ import { Images } from '../../../assets'; import { formatTime } from '../../helpers/timeUtils'; import EventCard from './EventCard'; import { StatusBar } from 'expo-status-bar'; -import { useColorScheme } from 'react-native'; +import { useFocusEffect } from '@react-navigation/core'; const Events = ({ navigation }: NativeStackScreenProps) => { const userContext = useContext(UserContext); @@ -64,6 +64,14 @@ const Events = ({ navigation }: NativeStackScreenProps) => { fetchEvents(); }, []) + useFocusEffect( + useCallback(() => { + if (hasPrivileges) { + fetchEvents(); + } + }, [hasPrivileges]) + ); + const handleFilterSelect = (filter: ExtendedEventType) => { if (selectedFilter === filter) { setSelectedFilter(null); @@ -108,26 +116,6 @@ const Events = ({ navigation }: NativeStackScreenProps) => { {/* Header */} Events - - {(!isLoading && hasPrivileges) && ( - fetchEvents()} - > - - - )} {/* Filters */} From 9c03580dcb41dceddb6b7243fb25fa9798fabc39 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 19 Jun 2024 16:03:30 -0500 Subject: [PATCH 093/198] add dark mode to qrcode screen and fix volunteer hour point during event creation --- src/screens/events/QRCodeManager.tsx | 158 +++++++++++++++++---------- src/types/events.ts | 4 +- 2 files changed, 104 insertions(+), 58 deletions(-) diff --git a/src/screens/events/QRCodeManager.tsx b/src/screens/events/QRCodeManager.tsx index f205f540..b7359031 100644 --- a/src/screens/events/QRCodeManager.tsx +++ b/src/screens/events/QRCodeManager.tsx @@ -1,5 +1,5 @@ -import { View, Text, Alert, ActivityIndicator, Button } from 'react-native'; -import React, { useState, useRef } from 'react'; +import { View, Text, Alert, ActivityIndicator, Button, useColorScheme } from 'react-native'; +import React, { useState, useRef, useContext } from 'react'; import { RouteProp, useRoute } from '@react-navigation/core'; import QRCode from 'react-native-qrcode-svg'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -10,14 +10,23 @@ import * as Sharing from 'expo-sharing'; import DismissibleModal from '../../components/DismissibleModal'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { EventsStackParams } from '../../types/navigation'; +import { UserContext } from '../../context/UserContext'; const QRCodeManager: React.FC = ({ route, navigation }) => { const { event } = route.params; - const [loading, setLoading] = useState(false); + + const userContext = useContext(UserContext); + const { userInfo } = userContext!; + + const fixDarkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const useSystemDefault = userInfo?.private?.privateInfo?.settings?.useSystemDefault; + const colorScheme = useColorScheme(); + const darkMode = useSystemDefault ? colorScheme === 'dark' : fixDarkMode; const signInQRCodeRef = useRef(null); const signOutQRCodeRef = useRef(null); + const [loading, setLoading] = useState(false); const [showSignInModal, setSignInModal] = useState(false); const [showSignOutModal, setSignOutModal] = useState(false); @@ -60,85 +69,120 @@ const QRCodeManager: React.FC = ({ route, navigation }) = return ( - - - - - navigation.goBack()}> - - - + + + + navigation.goBack()}> + + + - - - {event.name} - + + + {event.name} + - - - {typeof event.signInPoints === 'number' && ( - - setSignInModal(true)}> - - Sign In QR Code - - - { signInQRCodeRef.current = c; }} - size={60} - value={`tamu-shpe://event?id=${event.id}&mode=sign-in`} - /> - - - - )} - {typeof event.signOutPoints === 'number' && ( - - setSignOutModal(true)}> - - Sign Out QR Code - - - { signOutQRCodeRef.current = c; }} - size={60} - value={`tamu-shpe://event?id=${event.id}&mode=sign-out`} - /> - - - - )} - {loading && } - + + + {!event.signInPoints && !event.signOutPoints && ( + + No QR Codes Available + + )} + {event.signInPoints && ( + + setSignInModal(true)}> + + Sign In QR Code + + + { signInQRCodeRef.current = c; }} + size={60} + value={`tamu-shpe://event?id=${event.id}&mode=sign-in`} + backgroundColor={darkMode ? '#121212' : '#fff'} + color={darkMode ? '#fff' : '#000'} + /> + + + + )} + {event.signOutPoints && ( + + setSignOutModal(true)}> + + Sign Out QR Code + + + { signOutQRCodeRef.current = c; }} + size={60} + value={`tamu-shpe://event?id=${event.id}&mode=sign-out`} + backgroundColor={darkMode ? '#121212' : '#fff'} + color={darkMode ? '#fff' : '#000'} + /> + + + + )} + {loading && } - - Sign In QR Code + + Sign In QR Code { signInQRCodeRef.current = c; }} size={350} value={`tamu-shpe://event?id=${event.id}&mode=sign-in`} + backgroundColor={darkMode ? '#121212' : '#fff'} + color={darkMode ? '#fff' : '#000'} /> -