diff --git a/App.tsx b/App.tsx index 715051f..25bc1c6 100644 --- a/App.tsx +++ b/App.tsx @@ -1,4 +1,5 @@ import "react-native-gesture-handler"; +import styles from "./src/styles"; import { NavigationContainer } from "@react-navigation/native"; import { createDrawerNavigator } from "@react-navigation/drawer"; import { Provider } from "react-redux"; @@ -22,6 +23,11 @@ import { StatusBar } from "expo-status-bar"; import UserInfoPage from "./src/routes/UserInfoPage/UserInfoPage"; import SubjectsPage from "./src/routes/SubjectsPage/SubjectsPage"; import ConversationPage from "./src/routes/ConversationPage/ConversationPage"; +import Loading from "./src/routes/Loading/Loading"; +import StartStudying from "./src/routes/StartStudying/StartStudying"; +import { ToastProvider } from "react-native-toast-notifications"; +import InfoIcon from "./src/icons/InfoIcon/InfoIcon"; +import CreateGroup from "./src/routes/CreateGroup/CreateGroup"; const Drawer = createDrawerNavigator(); @@ -29,7 +35,7 @@ const linking = { prefixes: [Linking.makeUrl("/")], config: { screens: { - Home: "home", + Home: "", Login: "login", Register: "register", Onboarding: "onboarding", @@ -56,28 +62,36 @@ export default function App() { } }, [initialRoute]); return ( - - - + } + textStyle={{ ...styles.text_white_tiny_bold }} + > + + + - - - - - - - - - - - - - - - + + }> + + + + + + + + + + + + + + + + + ); } diff --git a/app.json b/app.json index 893a6ba..2456397 100644 --- a/app.json +++ b/app.json @@ -42,9 +42,15 @@ [ "expo-location", { - "locationAlwaysAndWhenInUsePermission": "Allow Stud-E to use your location." + "locationAlwaysAndWhenInUsePermission": "Allow StudE to use your location." } - ] + ], + [ + "expo-image-picker", + { + "photosPermission": "Allow StudE to take and send photos for sharing in-app" + } + ] ], "extra": { "eas": { diff --git a/package-lock.json b/package-lock.json index 2c02127..e918be3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,8 @@ "@tanstack/react-query": "^4.29.25", "axios": "^1.4.0", "expo": "~48.0.18", + "expo-file-system": "~15.2.2", + "expo-image-picker": "~14.1.1", "expo-intent-launcher": "~10.5.2", "expo-linking": "~4.0.1", "expo-location": "~15.1.1", @@ -34,6 +36,7 @@ "react-native-screens": "~3.20.0", "react-native-select-dropdown": "^3.3.4", "react-native-svg": "13.4.0", + "react-native-toast-notifications": "^3.3.1", "react-query": "^3.39.3", "react-redux": "^8.1.1", "redux": "^4.2.1" @@ -41,6 +44,7 @@ "devDependencies": { "@babel/core": "^7.20.0", "@types/react": "~18.0.14", + "@types/react-native-fetch-blob": "^0.10.7", "typescript": "^4.9.4" } }, @@ -5311,6 +5315,12 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-native-fetch-blob": { + "version": "0.10.7", + "resolved": "https://registry.npmjs.org/@types/react-native-fetch-blob/-/react-native-fetch-blob-0.10.7.tgz", + "integrity": "sha512-9UTvmUvArimShiENeR3xnRO71NcZjpTi7AcFAIbhdTIfqQOO2OK/I/DpUPXcZF/erffLxOoRkoXrZOxyBBWKRQ==", + "dev": true + }, "node_modules/@types/scheduler": { "version": "0.16.3", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", @@ -7458,6 +7468,25 @@ "expo": "*" } }, + "node_modules/expo-image-loader": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-4.1.1.tgz", + "integrity": "sha512-ciEHVokU0f6w0eTxdRxLCio6tskMsjxWIoV92+/ZD37qePUJYMfEphPhu1sruyvMBNR8/j5iyOvPFVGTfO8oxA==", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-14.1.1.tgz", + "integrity": "sha512-SvWtnkLW7jp5Ntvk3lVcRQmhFYja8psmiR7O6P/+7S6f4llt3vaFwb4I3+pUXqJxxpi7BHc2+95qOLf0SFOIag==", + "dependencies": { + "expo-image-loader": "~4.1.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-intent-launcher": { "version": "10.5.2", "resolved": "https://registry.npmjs.org/expo-intent-launcher/-/expo-intent-launcher-10.5.2.tgz", @@ -12425,6 +12454,15 @@ "react-native": "*" } }, + "node_modules/react-native-toast-notifications": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/react-native-toast-notifications/-/react-native-toast-notifications-3.3.1.tgz", + "integrity": "sha512-yc1Q2nOdIYvAf0GAIlmg8q42hiwpEHnLxkxJ6P+tN6jpcKZ1qzMXlgnmNdyF9cm9VOyHQexEP8952IKNAv1Olw==", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native/node_modules/promise": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", diff --git a/package.json b/package.json index 5d4609b..df97e93 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "@tanstack/react-query": "^4.29.25", "axios": "^1.4.0", "expo": "~48.0.18", + "expo-file-system": "~15.2.2", + "expo-image-picker": "~14.1.1", "expo-intent-launcher": "~10.5.2", "expo-linking": "~4.0.1", "expo-location": "~15.1.1", @@ -35,6 +37,7 @@ "react-native-screens": "~3.20.0", "react-native-select-dropdown": "^3.3.4", "react-native-svg": "13.4.0", + "react-native-toast-notifications": "^3.3.1", "react-query": "^3.39.3", "react-redux": "^8.1.1", "redux": "^4.2.1" @@ -42,6 +45,7 @@ "devDependencies": { "@babel/core": "^7.20.0", "@types/react": "~18.0.14", + "@types/react-native-fetch-blob": "^0.10.7", "typescript": "^4.9.4" }, "private": true diff --git a/src/components/Api/Api.tsx b/src/components/Api/Api.tsx index 20a3151..0eaad7d 100644 --- a/src/components/Api/Api.tsx +++ b/src/components/Api/Api.tsx @@ -1,21 +1,28 @@ import axios from "axios"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { - ActivationParams, - LoginParams, - OnboardingParams, - PatchStudentData, - RegistrationParams, - StudentData, + ActivationType, + LocationType, + LoginType, + OnboardingType, + PatchUserInfoType, + RegistrationType, + StudentStatusPatchType, + StudentStatusType, + StudyGroupCreateType, + StudyGroupType, } from "../../interfaces/Interfaces"; -export let backendURL = ""; -export let backendURLWebsocket = ""; -let use_production = true; -if (__DEV__ || !use_production) { - backendURL = "http://10.0.10.8:8000"; - backendURLWebsocket = "ws://10.0.10.8:8000"; -} else { +export let backendURL = "https://stude.keannu1.duckdns.org"; +export let backendURLWebsocket = "ws://stude.keannu1.duckdns.org"; +if (__DEV__) { + backendURL = "http://10.0.10.8:8083"; + backendURLWebsocket = "ws://10.0.10.8:8083"; +} + +// Switch this on if you wanna run production URLs while in development +let use_production = false; +if (__DEV__ && use_production) { backendURL = "https://stude.keannu1.duckdns.org"; backendURLWebsocket = "ws://stude.keannu1.duckdns.org"; } @@ -25,8 +32,29 @@ const instance = axios.create({ timeout: 1000, }); +console.log("Using backend API:", backendURL); + +// 3rd Party APIs +export const urlProvider = + "https://openstreetmap.keannu1.duckdns.org/tile/{z}/{x}/{y}.png?"; // App APIs +// Error Handling +export function ParseError(error: any) { + if (error.response && error.response.data) { + return JSON.stringify(error.response.data) + .replaceAll(/[{}()"]/g, " ") + .replaceAll(/,/g, "\n") + .replaceAll("[", "") + .replaceAll("]", "") + .replaceAll(".", "") + .replaceAll(/"/g, "") + .replaceAll("non_field_errors", "") + .trim(); + } + return "Unable to reach server"; +} + // Token Handling export async function getAccessToken() { const accessToken = await AsyncStorage.getItem("access_token"); @@ -59,40 +87,28 @@ export async function GetConfig() { } // User APIs -export function UserRegister(register: RegistrationParams) { - console.log(JSON.stringify(register)); +export function UserRegister(register: RegistrationType) { return instance .post("/api/v1/accounts/users/", register) .then(async (response) => { return [true, response.status]; }) .catch((error) => { - let error_message = ""; - if (error.response) error_message = error.response.data; - else error_message = "Unable to reach servers"; + let error_message = ParseError(error); return [false, error_message]; }); } -export function UserLogin(user: LoginParams) { +export function UserLogin(user: LoginType) { return instance .post("/api/v1/accounts/jwt/create/", user) .then(async (response) => { - /*console.log( - "Access Token:", - response.data.access, - "\nRefresh Token:", - response.data.refresh - );*/ setAccessToken(response.data.access); setRefreshToken(response.data.refresh); return [true]; }) .catch((error) => { - let error_message = ""; - if (error.response) error_message = error.response.data; - else error_message = "Unable to reach servers"; - // console.log(error_message); + let error_message = ParseError(error); return [false, error_message]; }); } @@ -106,21 +122,14 @@ export async function TokenRefresh() { }) .then(async (response) => { setAccessToken(response.data.access); - /*console.log( - "Token refresh success! New Access Token", - response.data.access - );*/ return true; }) .catch((error) => { - let error_message = ""; - if (error.response) error_message = error.response.data; - else error_message = "Unable to reach servers"; - console.log("Token Refresh error:", error_message); + let error_message = ParseError(error); return false; }); } -export async function UserInfo() { +export async function GetUserInfo() { const config = await GetConfig(); return instance .get("/api/v1/accounts/users/me/", config) @@ -129,37 +138,31 @@ export async function UserInfo() { return [true, response.data]; }) .catch((error) => { - let error_message = ""; - if (error.response) error_message = error.response.data; - else error_message = "Unable to reach servers"; + let error_message = ParseError(error); return [false, error_message]; }); } -export async function PatchUserInfo(info: PatchStudentData) { +export async function PatchUserInfo(info: PatchUserInfoType) { const config = await GetConfig(); return instance .patch("/api/v1/accounts/users/me/", info, config) .then((response) => { - console.log(JSON.stringify(response.data)); return [true, response.data]; }) .catch((error) => { - let error_message = ""; - if (error.response) error_message = error.response.data; - else error_message = "Unable to reach servers"; - // console.log(error_message); + let error_message = ParseError(error); return [false, error_message]; }); } -export function UserActivate(activation: ActivationParams) { +export function UserActivate(activation: ActivationType) { return instance .post("/api/v1/accounts/users/activation/", activation) - .then(async (response) => { + .then(() => { return true; }) - .catch((error) => { + .catch(() => { return false; }); } @@ -179,9 +182,7 @@ export async function GetCourses() { return [true, response.data]; }) .catch((error) => { - let error_message = ""; - if (error.response) error_message = error.response.data; - else error_message = "Unable to reach servers"; + let error_message = ParseError(error); return [false, error_message]; }); } @@ -199,9 +200,7 @@ export async function GetSemesters() { return [true, response.data]; }) .catch((error) => { - let error_message = ""; - if (error.response) error_message = error.response.data; - else error_message = "Unable to reach servers"; + let error_message = ParseError(error); return [false, error_message]; }); } @@ -215,69 +214,135 @@ export async function GetYearLevels() { return [true, response.data]; }) .catch((error) => { - let error_message = ""; - if (error.response) error_message = error.response.data; - else error_message = "Unable to reach servers"; + let error_message = ParseError(error); return [false, error_message]; }); } -export async function GetSubjects( - byCourseOnly: boolean, - course: string, - year_level?: string, - semester?: string -) { - const config = await GetConfig(); - console.log("by course only?", byCourseOnly); - // If year level and semester specified, - if (!byCourseOnly && year_level && semester) { - return instance - .get( - "/api/v1/subjects/" + course + "/" + year_level + "/" + semester, - config - ) - .then((response) => { - // console.log(JSON.stringify(response.data)); - return [true, response.data]; - }) - .catch((error) => { - let error_message = ""; - if (error.response) error_message = error.response.data; - else error_message = "Unable to reach servers"; - return [false, error_message]; - }); - } - // If only course is specified - else { - return instance - .get("/api/v1/subjects/" + course, config) - .then((response) => { - // console.log(JSON.stringify(response.data)); - return [true, response.data]; - }) - .catch((error) => { - let error_message = ""; - if (error.response) error_message = error.response.data; - else error_message = "Unable to reach servers"; - return [false, error_message]; - }); - } -} - -export async function OnboardingUpdateStudentInfo(info: OnboardingParams) { +export async function GetSubjects() { const config = await GetConfig(); return instance - .patch("/api/v1/accounts/users/me/", info, config) + .get("/api/v1/subjects/", config) .then((response) => { - console.log(JSON.stringify(response.data)); return [true, response.data]; }) .catch((error) => { - let error_message = ""; - if (error.response) error_message = error.response.data; - else error_message = "Unable to reach servers"; - console.log("Error updating onboarding info", error_message); + let error_message = ParseError(error); + return [false, error_message]; + }); +} + +export async function GetStudentStatus() { + const config = await GetConfig(); + return instance + .get("/api/v1/student_status/self/", config) + .then((response) => { + return [true, response.data]; + }) + .catch((error) => { + let error_message = ParseError(error); + return [false, error_message]; + }); +} + +export async function PatchStudentStatus(info: StudentStatusPatchType) { + const config = await GetConfig(); + return instance + .patch("/api/v1/student_status/self/", info, config) + .then((response) => { + return [true, response.data]; + }) + .catch((error) => { + let error_message = ParseError(error); + return [false, error_message]; + }); +} + +export async function GetStudentStatusList() { + const config = await GetConfig(); + return instance + .get("/api/v1/student_status/list/", config) + .then((response) => { + return [true, response.data]; + }) + .catch((error) => { + let error_message = ParseError(error); + return [false, error_message]; + }); +} + +export async function GetStudentStatusListNear() { + const config = await GetConfig(); + return instance + .get("/api/v1/student_status/near/", config) + .then((response) => { + return [true, response.data]; + }) + .catch((error) => { + let error_message = ParseError(error); + return [false, error_message]; + }); +} + +// To-do +export async function GetStudentStatusListFilteredCurrentLocation( + location: LocationType +) { + const config = await GetConfig(); + return instance + .post( + "/api/v1/student_status/near_current_location/", + { + location: location, + }, + config + ) + .then((response) => { + return [true, response.data]; + }) + .catch((error) => { + let error_message = ParseError(error); + return [false, error_message]; + }); +} + +export async function GetStudyGroupListFiltered() { + const config = await GetConfig(); + return instance + .get("/api/v1/study_groups/near/", config) + .then((response) => { + console.log("DEBUGGG", response.data); + return [true, response.data]; + }) + .catch((error) => { + let error_message = ParseError(error); + return [false, error_message]; + }); +} + +export async function GetStudyGroupList() { + const config = await GetConfig(); + return instance + .get("/api/v1/study_groups/", config) + .then((response) => { + return [true, response.data]; + }) + .catch((error) => { + let error_message = ParseError(error); + return [false, error_message]; + }); +} + +export async function CreateStudyGroup(info: StudyGroupCreateType) { + const config = await GetConfig(); + // console.log("Creating study group:", info); + return instance + .post("/api/v1/study_groups/create/", info, config) + .then((response) => { + return [true, response.data]; + }) + .catch((error) => { + let error_message = ParseError(error); return [false, error_message]; }); } diff --git a/src/components/DrawerSettings/CustomDrawerContent.tsx b/src/components/DrawerSettings/CustomDrawerContent.tsx index 0c46297..cfd622a 100644 --- a/src/components/DrawerSettings/CustomDrawerContent.tsx +++ b/src/components/DrawerSettings/CustomDrawerContent.tsx @@ -5,7 +5,10 @@ import { Text, View } from "react-native"; import { colors } from "../../styles"; import styles from "../../styles"; -import { RootDrawerParamList } from "../../interfaces/Interfaces"; +import { + RootDrawerParamList, + StudentStatusPatchType, +} from "../../interfaces/Interfaces"; import AppIcon from "../../icons/AppIcon/AppIcon"; import HomeIcon from "../../icons/HomeIcon/HomeIcon"; import LoginIcon from "../../icons/LoginIcon/LoginIcon"; @@ -18,11 +21,48 @@ import { logout } from "../../features/redux/slices/StatusSlice/StatusSlice"; import AsyncStorage from "@react-native-async-storage/async-storage"; import UserIcon from "../../icons/UserIcon/UserIcon"; import SubjectIcon from "../../icons/SubjectIcon/SubjectIcon"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import toast from "react-native-toast-notifications/lib/typescript/toast"; +import { PatchStudentStatus } from "../Api/Api"; +import { useToast } from "react-native-toast-notifications"; export default function CustomDrawerContent(props: {}) { const navigation = useNavigation(); const status = useSelector((state: RootState) => state.status); const dispatch = useDispatch(); + const queryClient = useQueryClient(); + const toast = useToast(); + const debug_disable_clear_on_logout = true; + const stop_studying_logout = useMutation({ + mutationFn: async (info: StudentStatusPatchType) => { + const data = await PatchStudentStatus(info); + if (data[0] != true) { + return Promise.reject(new Error()); + } + console.log("DEBUG", data); + return data; + }, + onSuccess: async () => { + toast.show("Logged out. Stopped studying", { + type: "warning", + placement: "top", + duration: 2000, + animationType: "slide-in", + }); + queryClient.clear(); + dispatch(logout()); + await AsyncStorage.clear(); + navigation.navigate("Login"); + }, + onError: (error: Error) => { + toast.show(String(error), { + type: "warning", + placement: "top", + duration: 2000, + animationType: "slide-in", + }); + }, + }); if (status.logged_in && status.onboarding) { return ( @@ -38,9 +78,16 @@ export default function CustomDrawerContent(props: {}) { { - dispatch(logout()); - await AsyncStorage.clear(); - navigation.navigate("Login"); + if (debug_disable_clear_on_logout) { + queryClient.clear(); + dispatch(logout()); + await AsyncStorage.clear(); + navigation.navigate("Login"); + } else { + stop_studying_logout.mutate({ + active: false, + }); + } }} > @@ -86,9 +133,16 @@ export default function CustomDrawerContent(props: {}) { { - dispatch(logout()); - await AsyncStorage.clear(); - navigation.navigate("Login"); + if (debug_disable_clear_on_logout) { + queryClient.clear(); + dispatch(logout()); + await AsyncStorage.clear(); + navigation.navigate("Login"); + } else { + stop_studying_logout.mutate({ + active: false, + }); + } }} > diff --git a/src/components/GetDistance/GetDistanceFromUSTP.tsx b/src/components/GetDistance/GetDistanceFromUSTP.tsx new file mode 100644 index 0000000..391df27 --- /dev/null +++ b/src/components/GetDistance/GetDistanceFromUSTP.tsx @@ -0,0 +1,20 @@ +import { LocationType } from "../../interfaces/Interfaces"; +import GetDistance from "./GetDistance"; + +export default function GetDistanceFromUSTP(location: LocationType) { + const ustpCoords = { + latitude: 8.4857, + longitude: 124.6565, + latitudeDelta: 0.000235, + longitudeDelta: 0.000067, + }; + + let dist = GetDistance( + location.latitude, + location.longitude, + ustpCoords.latitude, + ustpCoords.longitude + ); + dist = Math.round(dist); + return dist; +} diff --git a/src/components/MapRenderer/MapRendererFar.tsx b/src/components/MapRenderer/MapRendererFar.tsx new file mode 100644 index 0000000..e65a145 --- /dev/null +++ b/src/components/MapRenderer/MapRendererFar.tsx @@ -0,0 +1,79 @@ +import * as React from "react"; +import { View, Text } from "react-native"; +import MapView, { UrlTile, Callout, Marker } from "react-native-maps"; +import styles, { Viewport, colors } from "../../styles"; +import { urlProvider } from "../Api/Api"; +import { LocationType, RawLocationType } from "../../interfaces/Interfaces"; +import GetDistance from "../../components/GetDistance/GetDistance"; + +type props = { + location: LocationType; + dist: any; +}; + +export default function MapRendererFar(props: props) { + return ( + <> + + You are too far from USTP {"\n"} + Get closer to use Stud-E + + + + + + + You are here {"\n"} + X: {Math.round(props.location.longitude) + "\n"} + Z: {Math.round(props.location.latitude)} + + + + + + {props.dist}km away from USTP {"\n"} + + + ); +} diff --git a/src/components/ParseError/ParseError.tsx b/src/components/ParseError/ParseError.tsx deleted file mode 100644 index ab55ea6..0000000 --- a/src/components/ParseError/ParseError.tsx +++ /dev/null @@ -1,24 +0,0 @@ -export default function ParseError(text: string) { - if (text) { - return text - .replaceAll(/[{}()"]/g, " ") - .replaceAll(/,/g, "\n") - .replaceAll("[", "") - .replaceAll("]", "") - .replaceAll(".", ""); - } - return ""; -} - -export function ParseLoginError(text: string) { - if (text) { - return text - .replaceAll(/[{}()"]/g, " ") - .replaceAll(/,/g, "\n") - .replaceAll("[", "") - .replaceAll("]", "") - .replaceAll(".", "") - .replaceAll("non_field_errors", ""); - } - return ""; -} diff --git a/src/icons/DropdownIcon/DropdownIcon.tsx b/src/icons/CaretDownIcon/CaretDownIcon.tsx similarity index 91% rename from src/icons/DropdownIcon/DropdownIcon.tsx rename to src/icons/CaretDownIcon/CaretDownIcon.tsx index 27851ab..6a34501 100644 --- a/src/icons/DropdownIcon/DropdownIcon.tsx +++ b/src/icons/CaretDownIcon/CaretDownIcon.tsx @@ -3,7 +3,7 @@ import { IconProps } from "../../interfaces/Interfaces"; import { Svg, Path } from "react-native-svg"; import { colors } from "../../styles"; -export default function DropdownIcon(props: IconProps) { +export default function CaretDownIcon(props: IconProps) { return ( <> + + + + + + ); +} diff --git a/src/icons/InfoIcon/InfoIcon.tsx b/src/icons/InfoIcon/InfoIcon.tsx new file mode 100644 index 0000000..6e60500 --- /dev/null +++ b/src/icons/InfoIcon/InfoIcon.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; +import { IconProps } from "../../interfaces/Interfaces"; + +import { Svg, Path } from "react-native-svg"; +import { colors } from "../../styles"; + +export default function InfoIcon(props: IconProps) { + return ( + <> + + + + + + + + ); +} diff --git a/src/interfaces/Interfaces.tsx b/src/interfaces/Interfaces.tsx index 2b6e526..ae61d63 100644 --- a/src/interfaces/Interfaces.tsx +++ b/src/interfaces/Interfaces.tsx @@ -1,3 +1,7 @@ +import * as Location from "expo-location"; +import { GetStudentStatus } from "../components/Api/Api"; +import { Float } from "react-native/Libraries/Types/CodegenTypes"; + export interface IconProps { size: number; } @@ -9,6 +13,7 @@ export interface ResponsiveIconProps { export interface RootDrawerParamList { navigate: any; + replace: any; } // Redux Interfaces @@ -28,7 +33,7 @@ export interface LoggedInUserState { // API Interfaces -export interface RegistrationParams { +export interface RegistrationType { email: string; username: string; password: string; @@ -37,12 +42,12 @@ export interface RegistrationParams { student_id_number: string; } -export interface LoginParams { +export interface LoginType { username: string; password: string; } -export interface ActivationParams { +export interface ActivationType { uid: string; token: string; } @@ -53,80 +58,165 @@ export interface OptionType { } // Semester -export interface Semester { +export interface SemesterType { id: string; name: string; shortname: string; } -export type Semesters = Array; +export type SemestersType = Array; -export type SemesterParams = [boolean, Semesters]; +export type SemesterReturnType = [boolean, SemestersType]; // Year Level -export interface YearLevel { +export interface YearLevelType { id: string; name: string; shortname: string; } -export type YearLevels = Array; +export type YearLevelsType = Array; -export type YearLevelParams = [boolean, YearLevels]; +export type YearLevelReturnType = [boolean, YearLevelsType]; // Course -export interface Course { +export interface CourseType { id: string; name: string; shortname: string; } -export type Courses = Array; -export type CourseParams = [boolean, Courses]; +export type CoursesType = Array; +export type CourseReturnType = [boolean, CoursesType]; // Subject -export interface Subject { +export interface SubjectType { + id: number; name: string; code: string; - // courses: any[]; // To-do - // year_levels: any[]; // To-do - // semesters: any[]; // To-do + course: string; + year_level: string; + semester: string; } -export type Subjects = Array; -export type SubjectParams = [boolean, Subjects]; +export type SubjectsType = Array; +export type SubjectsReturnType = [boolean, SubjectsType]; +export type AvatarType = { + uri: string; + type: string; + name: string; +}; // For dropdown menu -export interface OnboardingParams { +export interface OnboardingType { year_level: string; course: string; semester: string; } -export interface PatchStudentData { - course?: string | null; - first_name?: string | null; - last_name?: string | null; - semester?: string | null; - subjects?: any[] | null; // To-do, replace 'any' with your actual type - year_level?: string | null; +export interface PatchUserInfoType { + course?: string; + first_name?: string; + last_name?: string; + semester?: string; + subjects?: string[]; + year_level?: string; + irregular?: boolean; + avatar?: string; } -export interface StudentData { +export interface LocationType { + latitude: Float; + longitude: Float; +} + +export interface StudentStatusType { + subject: string; + location: LocationType; + landmark: string | null; + active: boolean; + study_group: string; +} + +export interface StudentStatusPatchType { + subject?: string; + location?: LocationType; + landmark?: string | null; + active?: boolean; + study_group?: string; +} + +export interface StudentStatusFilterType { + active: boolean; + distance: number; + landmark: string | null; + location: LocationType; + study_group?: string; + subject: string; + user: string; + weight?: number; +} + +export interface StudentStatusFilterTypeFlattened { + active: boolean; + distance: number; + landmark: string | null; + latitude: Float; + longitude: Float; + study_group?: string; + subject: string; + user: string; + weight?: number; +} + +export interface StudyGroupType { + name: string; + students: string[]; + distance: number; + landmark: string | null; + location: LocationType; + subject: string; + radius: number; +} + +export interface StudyGroupCreateType { + name: string; + location: LocationType; + subject: string; +} + +export type StudyGroupReturnType = [boolean, StudyGroupType[]]; + +export type StudentStatusReturnType = [boolean, StudentStatusType]; + +export type StudentStatusListType = Array; +export type StudentStatusListReturnType = [boolean, StudentStatusListType]; + +export type RawLocationType = Location.LocationObject; + +export interface UserInfoType { first_name: string; last_name: string; email: string; avatar: string; student_id_number: string; - is_banned: boolean; + irregular: boolean; semester: string; semester_shortname: string; course: string; course_shortname: string; year_level: string; yearlevel_shortname: string; - subjects: any[]; // To-do + subjects: string[]; username: string; } -export type UserInfoParams = [boolean, StudentData]; +export type UserInfoReturnType = [boolean, UserInfoType]; + +export type subjectUserMapType = { + subject: string; + users: string[]; + latitude: Float; + longitude: Float; + radius: Float; +}; diff --git a/src/routes/Activation/Activation.tsx b/src/routes/Activation/Activation.tsx index 10aab6a..c2846c2 100644 --- a/src/routes/Activation/Activation.tsx +++ b/src/routes/Activation/Activation.tsx @@ -6,6 +6,7 @@ import { useNavigation, useRoute } from "@react-navigation/native"; import { useEffect, useState } from "react"; import { UserActivate } from "../../components/Api/Api"; import { RootDrawerParamList } from "../../interfaces/Interfaces"; +import { useToast } from "react-native-toast-notifications"; interface ActivationRouteParams { uid?: string; @@ -16,9 +17,7 @@ export default function Activation() { const route = useRoute(); const { uid, token } = (route.params as ActivationRouteParams) || ""; const navigation = useNavigation(); - const [state, setState] = useState( - "Activating with UID " + uid + " and Token " + token - ); + const toast = useToast(); const [loading, setLoading] = useState(true); useEffect(() => { @@ -28,16 +27,22 @@ export default function Activation() { token: String(token), }); if (result) { - setTimeout(() => { - setState("Activation successful!"); - }, 1000); + toast.show("Activation successful", { + type: "success", + placement: "top", + duration: 4000, + animationType: "slide-in", + }); setTimeout(() => { navigation.navigate("Login"); }, 2000); } else { - setTimeout(() => { - setState("Activation unsuccessful\nPlease contact support"); - }, 1000); + toast.show("Activation unsuccessful. Please contact support", { + type: "warning", + placement: "top", + duration: 4000, + animationType: "slide-in", + }); } setLoading(false); } @@ -63,8 +68,9 @@ export default function Activation() { size={96} color={colors.secondary_1} /> - {state} - {uid + "\n" + token} + + {"Activating with UID: " + uid + "\nToken: " + token} + ); diff --git a/src/routes/CreateGroup/CreateGroup.tsx b/src/routes/CreateGroup/CreateGroup.tsx new file mode 100644 index 0000000..0f4ec15 --- /dev/null +++ b/src/routes/CreateGroup/CreateGroup.tsx @@ -0,0 +1,177 @@ +import * as React from "react"; +import styles, { Viewport } from "../../styles"; +import { + View, + Text, + TextInput, + NativeSyntheticEvent, + TextInputChangeEventData, +} from "react-native"; +import { useState } from "react"; +import { + RootDrawerParamList, + StudentStatusPatchType, + StudyGroupCreateType, +} from "../../interfaces/Interfaces"; +import Button from "../../components/Button/Button"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { PatchStudentStatus, CreateStudyGroup } from "../../components/Api/Api"; +import { colors } from "../../styles"; +import AnimatedContainerNoScroll from "../../components/AnimatedContainer/AnimatedContainerNoScroll"; +import { urlProvider } from "../../components/Api/Api"; +import MapView, { UrlTile, Marker } from "react-native-maps"; +import { useNavigation } from "@react-navigation/native"; +import { useToast } from "react-native-toast-notifications"; + +export default function CreateGroup({ route }: any) { + const { location, subject } = route.params; + const queryClient = useQueryClient(); + const navigation = useNavigation(); + const toast = useToast(); + + const [name, setName] = useState(""); + const study_group_create = useMutation({ + mutationFn: async (info: StudyGroupCreateType) => { + const data = await CreateStudyGroup(info); + if (data[0] != true) { + return Promise.reject(new Error()); + } + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["user"] }); + queryClient.invalidateQueries({ queryKey: ["user_status"] }); + queryClient.invalidateQueries({ queryKey: ["study_group_list"] }); + student_status_patch.mutate({ + study_group: name, + }); + toast.show("Created successfully", { + type: "success", + placement: "top", + duration: 2000, + animationType: "slide-in", + }); + }, + onError: (error: Error) => { + toast.show(String(error), { + type: "warning", + placement: "top", + duration: 2000, + animationType: "slide-in", + }); + }, + }); + + const student_status_patch = useMutation({ + mutationFn: async (info: StudentStatusPatchType) => { + const data = await PatchStudentStatus(info); + if (data[0] != true) { + return Promise.reject(new Error()); + } + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["user"] }); + queryClient.invalidateQueries({ queryKey: ["user_status"] }); + toast.show(`Joined group ${name} successfully`, { + type: "success", + placement: "top", + duration: 2000, + animationType: "slide-in", + }); + navigation.navigate("Home"); + }, + onError: (error: Error) => { + toast.show(String(error), { + type: "warning", + placement: "top", + duration: 2000, + animationType: "slide-in", + }); + }, + }); + + if (location) { + return ( + + + + + + + + + + + + ): void => { + setName(e.nativeEvent.text); + }} + /> + + + + + + ); + } + return ; +} diff --git a/src/routes/Home/Home.tsx b/src/routes/Home/Home.tsx index f80af2e..c1b7ff0 100644 --- a/src/routes/Home/Home.tsx +++ b/src/routes/Home/Home.tsx @@ -1,127 +1,359 @@ -import styles, { Viewport, colors } from "../../styles"; -import { View, Text } from "react-native"; +import styles, { colors } from "../../styles"; +import { View, Text, Pressable, ScrollView, Switch } from "react-native"; import AnimatedContainer from "../../components/AnimatedContainer/AnimatedContainer"; import { useState, useEffect } from "react"; -import MapView, { Callout, Marker, UrlTile } from "react-native-maps"; +import MapView, { Circle, Marker } from "react-native-maps"; import * as Location from "expo-location"; import GetDistance from "../../components/GetDistance/GetDistance"; import Button from "../../components/Button/Button"; -import { AnimatedMapView } from "react-native-maps/lib/MapView"; -type LocationType = Location.LocationObject; +import { + RootDrawerParamList, + StudentStatusReturnType, + RawLocationType, + StudentStatusType, + StudentStatusListReturnType, + StudentStatusListType, + StudentStatusPatchType, + StudyGroupType, + StudyGroupReturnType, + StudentStatusFilterType, +} from "../../interfaces/Interfaces"; +import { useNavigation } from "@react-navigation/native"; +import { + GetStudentStatus, + GetStudentStatusList, + GetStudentStatusListNear, + GetStudyGroupList, + GetStudyGroupListFiltered, + PatchStudentStatus, +} from "../../components/Api/Api"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useToast } from "react-native-toast-notifications"; +import React from "react"; +import MapRendererFar from "../../components/MapRenderer/MapRendererFar"; +import GetDistanceFromUSTP from "../../components/GetDistance/GetDistanceFromUSTP"; +import Modal from "react-native-modal"; +import DropdownIcon from "../../icons/CaretDownIcon/CaretDownIcon"; +import CaretUpIcon from "../../icons/CaretUpIcon/CaretUpIcon"; + export default function Home() { - const [location, setLocation] = useState(null); + // Switch this condition to see the main map when debugging + const map_debug = true; + const navigation = useNavigation(); + const [location, setLocation] = useState(null); const [dist, setDist] = useState(null); const [feedback, setFeedback] = useState( "To continue, please allow Stud-E permission to location services" ); - const urlProvider = - "https://tile.thunderforest.com/atlas/{z}/{x}/{y}.png?apikey=0f5cb5930d7642a8a921daea650754d9"; - const ustpCoords = { - latitude: 8.4857, - longitude: 124.6565, - latitudeDelta: 0.000235, - longitudeDelta: 0.000067, - }; + const queryClient = useQueryClient(); + const toast = useToast(); + + const [modalOpen, setModalOpen] = useState(false); + const [modalByGroup, setModalByGroup] = useState(false); + async function requestLocation() { const { status } = await Location.requestForegroundPermissionsAsync(); if (status !== "granted") { - setFeedback( - "Permission to access location was denied. Please allow permission" + setFeedback("Allow location permissions to continue"); + toast.show( + "Location permission was denied. Please allow in order to use StudE", + { + type: "warning", + placement: "top", + duration: 2000, + animationType: "slide-in", + } ); return; } if (status == "granted") { - let location = await Location.getCurrentPositionAsync({}); - if (location) { - setLocation(location); - getDistance(location); + let newLocation = await Location.getCurrentPositionAsync({}); + if (newLocation) { + // Only update location state if user's location has changed + if ( + !location || + newLocation.coords.latitude !== location.coords.latitude || + newLocation.coords.longitude !== location.coords.longitude + ) { + setLocation(newLocation); + DistanceHandler(newLocation); + } } } } - // Refresh every 10 seconds + // Refresh every 15 seconds useEffect(() => { const interval = setInterval(() => { requestLocation(); - }, 10000); + }, 15000); return () => clearInterval(interval); }); - // Refresh when user moves location - useEffect(() => { - requestLocation(); - }, [location]); - - // Run when screen loads + // Refresh when screen loads useEffect(() => { requestLocation(); }, []); - async function getDistance(location: LocationType) { - let dist = GetDistance( - location.coords.latitude, - location.coords.longitude, - 8.4857, // LatitudeDelta - 124.6565 // LongitudeDelta - ); - setDist(Math.round(dist)); + async function DistanceHandler(location: RawLocationType) { + let dist = GetDistanceFromUSTP(location.coords); + setDist(dist); + // Deactivate student status if too far away + if (dist >= 2 && !map_debug) + stop_studying.mutate({ + active: false, + }); } + // Student Status + const [studying, setStudying] = useState(false); + const [subject, setSubject] = useState(""); + const [buttonLabel, setButtonLabel] = useState("Start studying"); + const [student_status, setStudentStatus] = useState(); + const StudentStatusQuery = useQuery({ + queryKey: ["user_status"], + queryFn: async () => { + const data = await GetStudentStatus(); + if (data[0] == false) { + return Promise.reject(new Error(JSON.stringify(data[1]))); + } + return data; + }, + onSuccess: (data: StudentStatusReturnType) => { + if (data[1].active !== undefined) { + setStudying(data[1].active); + } + if (data[1].subject !== undefined) { + setSubject(data[1].subject); + } + if (data[1].active == true) { + setButtonLabel("Stop Studying"); + } else if (data[1].active == false) { + setButtonLabel("Start Studying"); + } + setStudentStatus(data[1]); + }, + onError: (error: Error) => { + toast.show(String(error), { + type: "warning", + placement: "top", + duration: 2000, + animationType: "slide-in", + }); + }, + }); + + const stop_studying = useMutation({ + mutationFn: async (info: StudentStatusPatchType) => { + const data = await PatchStudentStatus(info); + if (data[0] != true) { + return Promise.reject(new Error()); + } + return data; + }, + onSuccess: () => { + if (student_status?.study_group) { + // Display separate toast if you stop studying while in a study group + toast.show("You left study group \n" + student_status?.study_group, { + type: "success", + placement: "top", + duration: 2000, + animationType: "slide-in", + }); + } + toast.show("You are no longer studying \n" + subject, { + type: "success", + placement: "top", + duration: 2000, + animationType: "slide-in", + }); + queryClient.invalidateQueries({ queryKey: ["user_status"] }); + // Delay refetching for study groups since backend still needs to delete groups without students after leaving a study group + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ["study_group_list"] }); + queryClient.invalidateQueries({ + queryKey: ["study_group_list_global"], + }); + }, 500); + setStudyGroups([]); + setStudying(false); + }, + onError: (error: Error) => { + toast.show(String(error), { + type: "warning", + placement: "top", + duration: 2000, + animationType: "slide-in", + }); + }, + }); + + const change_study_group = useMutation({ + mutationFn: async (info: StudentStatusPatchType) => { + const data = await PatchStudentStatus(info); + if (data[0] != true) { + return Promise.reject(new Error()); + } + return data; + }, + onSuccess: () => { + if (student_status?.study_group) { + // Display separate toast if you stop studying while in a study group + toast.show("You left study group \n" + student_status?.study_group, { + type: "success", + placement: "top", + duration: 2000, + animationType: "slide-in", + }); + } + queryClient.invalidateQueries({ queryKey: ["user_status"] }); + + // Delay refetching for study groups since backend still needs to delete groups without students after leaving a study group + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ["study_group_list"] }); + queryClient.invalidateQueries({ + queryKey: ["study_group_list_global"], + }); + }, 500); + setStudyGroups([]); + }, + onError: (error: Error) => { + toast.show(String(error), { + type: "warning", + placement: "top", + duration: 2000, + animationType: "slide-in", + }); + }, + }); + + const [student_statuses, setStudentStatuses] = + useState([]); + // Student Status List + const StudentStatusListQuery = useQuery({ + enabled: studying, + queryKey: ["user_status_list"], + queryFn: async () => { + const data = await GetStudentStatusListNear(); + if (data[0] == false) { + return Promise.reject(new Error(JSON.stringify(data[1]))); + } + return data; + }, + onSuccess: (data: StudentStatusListReturnType) => { + if (data[1] && location) { + // Filter to only include students studying solo + let data_filtered = data[1].filter( + (item: StudentStatusFilterType) => item.study_group == null + ); + setStudentStatuses(data_filtered); + } + }, + onError: (error: Error) => { + toast.show(String(error), { + type: "warning", + placement: "top", + duration: 2000, + animationType: "slide-in", + }); + }, + }); + + const [student_statuses_global, setStudentStatusesGlobal] = + useState([]); + // Student Status List Global + const StudentStatusListGlobalQuery = useQuery({ + enabled: !studying, + queryKey: ["user_status_list_global"], + queryFn: async () => { + const data = await GetStudentStatusList(); + if (data[0] == false) { + return Promise.reject(new Error(JSON.stringify(data[1]))); + } + return data; + }, + onSuccess: (data: StudentStatusListReturnType) => { + if (data[1] && location) { + // Filter to only include students studying solo + let data_filtered = data[1].filter( + (item: StudentStatusFilterType) => item.study_group == null + ); + setStudentStatusesGlobal(data_filtered); + } + }, + onError: (error: Error) => { + toast.show(String(error), { + type: "warning", + placement: "top", + duration: 2000, + animationType: "slide-in", + }); + }, + }); + + const [study_groups, setStudyGroups] = useState([]); + // Study Group List + const StudyGroupQuery = useQuery({ + enabled: studying, + queryKey: ["study_group_list"], + queryFn: async () => { + const data = await GetStudyGroupListFiltered(); + if (data[0] == false) { + return Promise.reject(new Error(JSON.stringify(data[1]))); + } + return data; + }, + onSuccess: (data: StudyGroupReturnType) => { + if (data[1] && location) { + setStudyGroups(data[1]); + } + }, + onError: (error: Error) => { + toast.show(String(error), { + type: "warning", + placement: "top", + duration: 2000, + animationType: "slide-in", + }); + }, + }); + const [study_groups_global, setStudyGroupsGlobal] = useState< + StudyGroupType[] + >([]); + // Study Group Global List + const StudyGroupGlobalQuery = useQuery({ + enabled: !studying, + queryKey: ["study_group_list_global"], + queryFn: async () => { + const data = await GetStudyGroupList(); + if (data[0] == false) { + return Promise.reject(new Error(JSON.stringify(data[1]))); + } + return data; + }, + onSuccess: (data: StudyGroupReturnType) => { + if (data[1] && location) { + setStudyGroupsGlobal(data[1]); + } + }, + onError: (error: Error) => { + toast.show(String(error), { + type: "warning", + placement: "top", + duration: 2000, + animationType: "slide-in", + }); + }, + }); + function CustomMap() { if (dist && location) { - if (dist <= 1.5) { - // Just switch this condition for map debugging + if (dist <= 2 || map_debug) { return ( - - - - - ); - } else { - return ( - - - You are too far from USTP {"\n"} - Get closer to use Stud-E - - + + {!studying ? ( + student_statuses_global.map( + (student_status: StudentStatusFilterType, index: number) => { + const randomColorWithOpacity = `rgba(${Math.floor( + Math.random() * 256 + )}, ${Math.floor(Math.random() * 256)}, ${Math.floor( + Math.random() * 256 + )}, 0.7)`; + + return ( + { + toast.hideAll(); + toast.show( + + + Student: {student_status.user} + + + {`Studying ${student_status.subject}`} + + , + { + type: "normal", + placement: "top", + duration: 2000, + animationType: "slide-in", + style: { + backgroundColor: colors.secondary_2, + borderWidth: 1, + borderColor: colors.primary_1, + }, + } + ); + }} + /> + ); + } + ) + ) : ( + <> + )} + {studying ? ( + student_statuses.map( + (student_status: StudentStatusFilterType, index: number) => { + const randomColorWithOpacity = `rgba(${Math.floor( + Math.random() * 256 + )}, ${Math.floor(Math.random() * 256)}, ${Math.floor( + Math.random() * 256 + )}, 0.7)`; + + return ( + { + toast.hideAll(); + toast.show( + + + Student: {student_status.user} + + + {`Studying ${student_status.subject}`} + + + {`${Math.round( + student_status.distance * 1000 + )}m away`} + + , + { + type: "normal", + placement: "top", + duration: 2000, + animationType: "slide-in", + style: { + backgroundColor: colors.secondary_2, + borderWidth: 1, + borderColor: colors.primary_1, + }, + } + ); + }} + /> + ); + } + ) + ) : ( + <> + )} + {studying ? ( + study_groups.map( + (studygroup: StudyGroupType, index: number) => { + const randomColorWithOpacity = `rgba(${Math.floor( + Math.random() * 256 + )}, ${Math.floor(Math.random() * 256)}, ${Math.floor( + Math.random() * 256 + )}, 0.7)`; + + return ( + + { + toast.hideAll(); + toast.show( + + + Study Group: {studygroup.name} + + + {`Studying ${studygroup.subject}`} + + + + {`${studygroup.students.length} ${ + studygroup.students.length > 1 + ? "students" + : "student" + } studying`} + + + {`${Math.round( + studygroup.distance * 1000 + )}m away`} + + {student_status?.study_group != + studygroup.name ? ( + + ) : ( + <> + )} + {student_status?.study_group == + studygroup.name ? ( + + ) : ( + <> + )} + , + { + type: "normal", + placement: "top", + duration: 2000, + animationType: "slide-in", + style: { + backgroundColor: colors.secondary_2, + borderWidth: 1, + borderColor: colors.primary_1, + }, + } + ); + }} + /> + + + ); + } + ) + ) : ( + <> + )} + {!studying ? ( + study_groups_global.map( + (studygroup: StudyGroupType, index: number) => { + const randomColorWithOpacity = `rgba(${Math.floor( + Math.random() * 256 + )}, ${Math.floor(Math.random() * 256)}, ${Math.floor( + Math.random() * 256 + )}, 0.7)`; + + return ( + + { + toast.hideAll(); + toast.show( + + + Study Group: {studygroup.name} + + + {`Studying ${studygroup.subject}`} + + + {`${studygroup.students.length} ${ + studygroup.students.length > 1 + ? "students" + : "student" + } studying`} + + {student_status?.study_group != + studygroup.name ? ( + + Study nearby to join + + ) : ( + <> + )} + , + { + type: "normal", + placement: "top", + duration: 2000, + animationType: "slide-in", + style: { + backgroundColor: colors.secondary_2, + borderWidth: 1, + borderColor: colors.primary_1, + }, + } + ); + }} + /> + + + ); + } + ) + ) : ( + <> + )} + {!studying || !student_status?.study_group ? ( + { + const newLocation = e.nativeEvent.coordinate; + const distance = GetDistance( + newLocation.latitude, + newLocation.longitude, + location.coords.latitude, + location.coords.longitude + ); + if (distance <= 0.1) { + // If the new location is within 100 meters of the actual location, update the location state + setLocation({ + ...location, + coords: { + ...location.coords, + latitude: newLocation.latitude, + longitude: newLocation.longitude, + }, + }); + } else { + // If the new location is more than 100 meters away from the actual location, reset the marker to the actual location + setLocation({ + ...location, + }); + } + }} + pinColor={colors.primary_1} + onPress={() => { + toast.hideAll(); + toast.show( + + + You are here + + {student_status?.active && + !student_status?.study_group ? ( + <> + + {student_status?.active + ? "Studying " + student_status?.subject + : ""} + + + + ) : ( + <> + )} + {student_status?.study_group ? ( + <> + + {`Studying: ${student_status?.subject}`} + + + {`In group: ${student_status?.study_group}`} + + + ) : ( + <> + )} + , + { + type: "normal", + placement: "top", + duration: 2000, + animationType: "slide-in", + style: { + backgroundColor: colors.secondary_2, + borderWidth: 1, + borderColor: colors.primary_1, + }, + } + ); + }} + > + ) : ( + <> + )} + + - - { + if (!student_status?.active) { + navigation.navigate("Start Studying", { + location: location, + }); + } else { + stop_studying.mutate({ + active: false, + }); + } }} - onPress={() => console.log(location)} - pinColor={colors.primary_1} > - - - X: {Math.round(location.coords.longitude) + "\n"} - Z: {Math.round(location.coords.latitude)} - - - - - - {dist}km away from USTP {"\n"} - - + {buttonLabel} + + { + setModalOpen(true); + }} + > + {studying ? : <>} + + + + + ); + } else { + return ; } } else { return ( - + <> {feedback} - + ); } } return ( + + + Groups List + setModalOpen(false)} + > + + + { + setModalByGroup(!modalByGroup); + }} + /> + + {!modalByGroup ? ( + student_statuses.map( + (student_status: StudentStatusFilterType, index: number) => { + return ( + + + Student: {student_status.user} + + + {`Studying ${student_status.subject}`} + + + {`${Math.round(student_status.distance * 1000)}m away`} + + + ); + } + ) + ) : ( + <> + )} + {modalByGroup ? ( + study_groups.map((studygroup: StudyGroupType, index: number) => { + return ( + + + Group Name: {studygroup.name} + + + {`Studying ${studygroup.subject}`} + + + Students Studying: {studygroup.students.length} + + {student_status?.study_group != studygroup.name ? ( + + {`${Math.round(studygroup.distance * 1000)}m away`} + + ) : ( + <> + )} + + ); + }) + ) : ( + <> + )} + + + diff --git a/src/routes/Loading/Loading.tsx b/src/routes/Loading/Loading.tsx new file mode 100644 index 0000000..9fb7c7c --- /dev/null +++ b/src/routes/Loading/Loading.tsx @@ -0,0 +1,17 @@ +import * as React from "react"; +import styles from "../../styles"; +import { View, Text, ActivityIndicator } from "react-native"; +import { colors } from "../../styles"; +import AnimatedContainer from "../../components/AnimatedContainer/AnimatedContainer"; + +export default function Loading() { + return ( + + + + + Loading StudE... + + + ); +} diff --git a/src/routes/Login/Login.tsx b/src/routes/Login/Login.tsx index 1a5cf31..5a72b1a 100644 --- a/src/routes/Login/Login.tsx +++ b/src/routes/Login/Login.tsx @@ -8,14 +8,12 @@ import { TextInputChangeEventData, } from "react-native"; import { useDispatch } from "react-redux"; -import { colors } from "../../styles"; import { useState } from "react"; import LoginIcon from "../../icons/LoginIcon/LoginIcon"; import Button from "../../components/Button/Button"; import { useNavigation } from "@react-navigation/native"; import { RootDrawerParamList } from "../../interfaces/Interfaces"; -import { UserInfo, UserLogin } from "../../components/Api/Api"; -import { ParseLoginError } from "../../components/ParseError/ParseError"; +import { GetUserInfo, UserLogin } from "../../components/Api/Api"; import AnimatedContainer from "../../components/AnimatedContainer/AnimatedContainer"; import { setUser } from "../../features/redux/slices/UserSlice/UserSlice"; import { @@ -23,6 +21,7 @@ import { setOnboarding, unsetOnboarding, } from "../../features/redux/slices/StatusSlice/StatusSlice"; +import { useToast } from "react-native-toast-notifications"; export default function Login() { const navigation = useNavigation(); @@ -31,7 +30,7 @@ export default function Login() { username: "", password: "", }); - const [error, setError] = useState(""); + const toast = useToast(); return ( @@ -60,14 +59,13 @@ export default function Login() { placeholderTextColor="white" secureTextEntry={true} value={creds.password} + autoCapitalize={"none"} onChange={( e: NativeSyntheticEvent ): void => { setCreds({ ...creds, password: e.nativeEvent.text }); }} /> - - {error} diff --git a/src/routes/UserInfoPage/UserInfoPage.tsx b/src/routes/UserInfoPage/UserInfoPage.tsx index 143a0a5..5d8a49b 100644 --- a/src/routes/UserInfoPage/UserInfoPage.tsx +++ b/src/routes/UserInfoPage/UserInfoPage.tsx @@ -6,18 +6,19 @@ import { TextInput, NativeSyntheticEvent, TextInputChangeEventData, + Pressable, } from "react-native"; import { useState } from "react"; import { - SemesterParams, - UserInfoParams, - Semester, - SubjectParams, - Subject, - YearLevel, - Course, + SemesterReturnType, + UserInfoReturnType, + SemesterType, + YearLevelType, + CourseType, OptionType, - Subjects, + StudentStatusType, + PatchUserInfoType, + StudentStatusPatchType, } from "../../interfaces/Interfaces"; import Button from "../../components/Button/Button"; import { Image } from "react-native"; @@ -25,18 +26,52 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { GetCourses, GetSemesters, - GetSubjects, GetYearLevels, + PatchStudentStatus, PatchUserInfo, - UserInfo, + GetUserInfo, } from "../../components/Api/Api"; import { colors } from "../../styles"; import DropDownPicker from "react-native-dropdown-picker"; -import { ValueType } from "react-native-dropdown-picker"; import AnimatedContainerNoScroll from "../../components/AnimatedContainer/AnimatedContainerNoScroll"; +import BouncyCheckbox from "react-native-bouncy-checkbox"; +import { useSelector } from "react-redux"; +import { RootState } from "../../features/redux/Store/Store"; +import { useDispatch } from "react-redux"; +import { setUser as setUserinState } from "../../features/redux/slices/UserSlice/UserSlice"; +import * as ImagePicker from "expo-image-picker"; +import * as FileSystem from "expo-file-system"; +import { useToast } from "react-native-toast-notifications"; export default function UserInfoPage() { + const logged_in_user = useSelector((state: RootState) => state.user.user); + const dispatch = useDispatch(); const queryClient = useQueryClient(); + const toast = useToast(); + + // Student Status + const studentstatus_mutation = useMutation({ + mutationFn: async (info: StudentStatusPatchType) => { + const data = await PatchStudentStatus(info); + if (data[0] != true) { + return Promise.reject(new Error()); + } + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["user"] }); + queryClient.invalidateQueries({ queryKey: ["user_status"] }); + }, + onError: () => { + toast.show("An error has occured\nChanges have not been saved", { + type: "warning", + placement: "top", + duration: 2000, + animationType: "slide-in", + }); + }, + }); + // User Info const [user, setUser] = useState({ first_name: "", @@ -49,15 +84,18 @@ export default function UserInfoPage() { course_shortname: "", avatar: "", student_id_number: "", - }); - const [displayName, setDisplayName] = useState({ - first_name: "", - last_name: "", + irregular: false, }); const StudentInfo = useQuery({ queryKey: ["user"], - queryFn: UserInfo, - onSuccess: (data: UserInfoParams) => { + queryFn: async () => { + const data = await GetUserInfo(); + if (data[0] == false) { + return Promise.reject(new Error(data[1])); + } + return data; + }, + onSuccess: (data: UserInfoReturnType) => { // console.log(data[1]); setUser({ ...user, @@ -66,23 +104,55 @@ export default function UserInfoPage() { year_level: data[1].year_level, semester: data[1].semester, course: data[1].course, - avatar: data[1].avatar, student_id_number: data[1].student_id_number, - }); - setDisplayName({ - first_name: data[1].first_name, - last_name: data[1].last_name, + irregular: data[1].irregular, + avatar: data[1].avatar, }); setSelectedCourse(data[1].course); setSelectedSemester(data[1].semester); setSelectedYearLevel(data[1].year_level); + dispatch(setUserinState(data[1])); + }, + onError: (error: Error) => { + toast.show(String(error), { + type: "warning", + placement: "top", + duration: 2000, + animationType: "slide-in", + }); }, }); + const mutation = useMutation({ - mutationFn: PatchUserInfo, + mutationFn: async (info: PatchUserInfoType) => { + const data = await PatchUserInfo(info); + if (data[0] == false) { + return Promise.reject(new Error()); + } + return data; + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["user"] }); queryClient.invalidateQueries({ queryKey: ["subjects"] }); + // Reset student status when changing user info to prevent bugs + studentstatus_mutation.mutate({ + active: false, + }); + toast.show("Changes applied successfully.\nStudent status reset", { + type: "success", + placement: "top", + duration: 2000, + animationType: "slide-in", + }); + dispatch(setUserinState(user)); + }, + onError: () => { + toast.show("An error has occured\nChanges have not been saved", { + type: "warning", + placement: "top", + duration: 2000, + animationType: "slide-in", + }); }, }); @@ -92,9 +162,15 @@ export default function UserInfoPage() { const [semesters, setSemesters] = useState([]); const Semesters = useQuery({ queryKey: ["semesters"], - queryFn: GetSemesters, - onSuccess: (data: SemesterParams) => { - let semestersData = data[1].map((semester: Semester) => ({ + queryFn: async () => { + const data = await GetSemesters(); + if (data[0] == false) { + return Promise.reject(new Error(data[1])); + } + return data; + }, + onSuccess: (data: SemesterReturnType) => { + let semestersData = data[1].map((semester: SemesterType) => ({ label: semester.name, value: semester.name, shortname: semester.shortname, @@ -102,6 +178,14 @@ export default function UserInfoPage() { // Update the 'semesters' state setSemesters(semestersData); }, + onError: (error: Error) => { + toast.show(String(error), { + type: "warning", + placement: "top", + duration: 2000, + animationType: "slide-in", + }); + }, }); // Year Level @@ -110,14 +194,28 @@ export default function UserInfoPage() { const [year_levels, setYearLevels] = useState([]); const yearlevel_query = useQuery({ queryKey: ["year_levels"], - queryFn: GetYearLevels, + queryFn: async () => { + const data = await GetYearLevels(); + if (data[0] == false) { + return Promise.reject(new Error(data[1])); + } + return data; + }, onSuccess: (data) => { - let year_levels = data[1].map((yearlevel: YearLevel) => ({ + let year_levels = data[1].map((yearlevel: YearLevelType) => ({ label: yearlevel.name, value: yearlevel.name, })); setYearLevels(year_levels); }, + onError: (error: Error) => { + toast.show(String(error), { + type: "warning", + placement: "top", + duration: 2000, + animationType: "slide-in", + }); + }, }); // Course @@ -126,27 +224,64 @@ export default function UserInfoPage() { const [courses, setCourses] = useState([]); const course_query = useQuery({ queryKey: ["courses"], - queryFn: GetCourses, + queryFn: async () => { + const data = await GetCourses(); + if (data[0] == false) { + return Promise.reject(new Error(data[1])); + } + return data; + }, onSuccess: (data) => { - let courses = data[1].map((course: Course) => ({ + let courses = data[1].map((course: CourseType) => ({ label: course.name, value: course.name, })); setCourses(courses); }, + onError: (error: Error) => { + toast.show(String(error), { + type: "warning", + placement: "top", + duration: 2000, + animationType: "slide-in", + }); + }, }); - // Toggle editing of profile - const [isEditable, setIsEditable] = useState(false); + // Profile photo + const pickImage = async () => { + // No permissions request is necessary for launching the image library + let result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.All, + allowsEditing: true, + aspect: [4, 3], + quality: 1, + }); + if (!result.canceled) { + const encodedImage = await FileSystem.readAsStringAsync( + result.assets[0].uri, + { encoding: "base64" } + ); + mutation.mutate({ + avatar: encodedImage, + }); + } + }; function Avatar() { if (user.avatar) { - return ; + return ( + + + + ); } else { return ( - + + + ); } } @@ -157,9 +292,9 @@ export default function UserInfoPage() { - {(displayName.first_name || "Undefined") + + {(logged_in_user.first_name || "Undefined") + " " + - (displayName.last_name || "User") + + (logged_in_user.last_name || "User") + "\n" + user.student_id_number} @@ -173,7 +308,6 @@ export default function UserInfoPage() { ): void => { @@ -190,7 +324,6 @@ export default function UserInfoPage() { ): void => { @@ -206,7 +339,6 @@ export default function UserInfoPage() { + + { + mutation.mutate({ + irregular: !user.irregular, + }); + setUser({ ...user, irregular: !user.irregular }); + }} + isChecked={user.irregular} + disableBuiltInState + fillColor={colors.secondary_3} + /> + Irregular + + diff --git a/src/styles.tsx b/src/styles.tsx index c1a1613..0f9ab15 100644 --- a/src/styles.tsx +++ b/src/styles.tsx @@ -171,7 +171,8 @@ const styles = StyleSheet.create({ width: "70%", }, map: { - height: Viewport.height * 0.8, + marginVertical: 4, + height: Viewport.height * 0.7, width: Viewport.width * 0.8, alignSelf: "center", }, @@ -182,6 +183,8 @@ const styles = StyleSheet.create({ borderRadius: 150 / 2, overflow: "hidden", padding: 0, + borderColor: colors.primary_2, + borderWidth: 3, }, input: { paddingHorizontal: 8,