diff --git a/api/_client.ts b/api/_client.ts index 052e60e..69e176c 100644 --- a/api/_client.ts +++ b/api/_client.ts @@ -74,10 +74,10 @@ export const get_test = async (id: number) => { return response.data; } -export const get_user_tests = async () => { +export const get_my_user_tests = async () => { const response = await axiosInstance.get<{ data: UserTestResponse[]; - }>("/api/user-tests/"); + }>("/api/user-tests/me/"); return response.data; } diff --git a/api/types.ts b/api/types.ts index 4635304..3e33c8d 100644 --- a/api/types.ts +++ b/api/types.ts @@ -29,7 +29,7 @@ export interface QuestionResponse { type: QuestionTypes; difficulty: number; - variants?: QuestionVariant[]; + variants: QuestionVariant[]; correct_answers: number[]|string[]; category_id: number; @@ -63,7 +63,8 @@ export interface TestResponse { author_id: number; author: UserResponse; - questions: QuestionResponse[]; + questions?: QuestionResponse[]; + questions_count: number; closed_at: string; @@ -95,10 +96,9 @@ export interface UserTestResponse { is_completed: boolean; score: number; is_available: boolean; - + answers: AnswerResponse[]; - created_at: string; updated_at: string; } diff --git a/api/userTests.ts b/api/userTests.ts index 0c7ea70..7f0e51a 100644 --- a/api/userTests.ts +++ b/api/userTests.ts @@ -1,17 +1,17 @@ import { useQuery } from "@tanstack/react-query"; -import { get_user_test, get_user_tests } from "./_client"; +import { get_my_user_tests, get_user_test } from "./_client"; -export const useTests = () => { +export const useMyTests = () => { return useQuery({ queryKey: ['user-tests'], queryFn: async () => { - const response = await get_user_tests(); + const response = await get_my_user_tests(); return response.data; } }) } -export const useTest = (id: number) => { +export const useUserTest = (id: number) => { return useQuery({ queryKey: ['test', id], queryFn: async () => { diff --git a/app/(tabs)/me.tsx b/app/(tabs)/me.tsx index 3720926..8739078 100644 --- a/app/(tabs)/me.tsx +++ b/app/(tabs)/me.tsx @@ -1,9 +1,10 @@ - -import useAuthContext from '@/components/providers/auth-provider/hook'; -import { Button, ButtonText } from '@/components/ui/button'; -import Content from '@/components/ui/content'; -import { Divider } from '@/components/ui/divider'; -import UserHeader from '@/components/user-header'; +import useAuthContext from "@/components/providers/auth-provider/hook"; +import { ButtonText } from "@/components/ui/button"; +import ButtonSection from "@/components/ui/button-section"; +import Content from "@/components/ui/content"; +import { Divider } from "@/components/ui/divider"; +import UserHeader from "@/components/user-header"; +import { router } from "expo-router"; export default function MeScreen() { const { user, logout, isAuthorized } = useAuthContext(); @@ -12,9 +13,25 @@ export default function MeScreen() { - + - {isAuthorized && } + {isAuthorized && ( + <> + router.push('/me/user-tests')}> + My Tests + + + + + logout()} + variant="solid" + > + Logout + + + )} ); -} \ No newline at end of file +} diff --git a/app/(tabs)/tests.tsx b/app/(tabs)/tests.tsx index 9ffce8e..1da2c00 100644 --- a/app/(tabs)/tests.tsx +++ b/app/(tabs)/tests.tsx @@ -3,11 +3,13 @@ import { useTests } from '@/api/tests'; import { TestResponse } from '@/api/types'; import useTaxonomyContext from '@/components/providers/taxonomy-provider/hook'; import Test from '@/components/test'; +import { Button, ButtonText } from '@/components/ui/button'; import Content from '@/components/ui/content'; import CustomSelect from '@/components/ui/custom-select'; import PaginationList from '@/components/ui/pagination-list'; import { ThemedText } from '@/components/ui/themed-text'; import getCategoryOptions from '@/utils/get-category-options'; +import { FontAwesome5 } from '@expo/vector-icons'; import { router } from 'expo-router'; import { useRef, useState } from 'react'; import { ScrollView, View } from 'react-native'; @@ -39,13 +41,17 @@ export default function TestsScreen() { Tests - + + diff --git a/app/me/user-tests.tsx b/app/me/user-tests.tsx new file mode 100644 index 0000000..abe1987 --- /dev/null +++ b/app/me/user-tests.tsx @@ -0,0 +1,61 @@ +import { useMyTests } from "@/api/userTests"; +import Content from "@/components/ui/content"; +import CustomSelect from "@/components/ui/custom-select"; +import UserTest from "@/components/user-test"; +import { getUserTestStatus, TestStatus } from "@/utils/get-user-test-status"; +import { router, Stack } from "expo-router"; +import { useMemo, useState } from "react"; +import { View } from "react-native"; + +const filterSelectOptions = [ + { label: TestStatus.Completed, value: TestStatus.Completed }, + { label: TestStatus.InProgress, value: TestStatus.InProgress }, + { label: TestStatus.Expired, value: TestStatus.Expired }, +]; + +const MeUserTestsScreen = () => { + const { data: userTests } = useMyTests(); + const [selectedStatus, setStatus] = useState("all"); + const filteredUserTests = useMemo(() => { + if(!userTests) return []; + if(selectedStatus !== "all") { + return userTests.filter((userTest) => getUserTestStatus(userTest) === selectedStatus); + } + return userTests; + }, [userTests, selectedStatus]); + + if(!userTests) + return ( + <> + + + + + + ); + + return ( + <> + + + + setStatus(value)} + /> + + + {filteredUserTests.map((userTest) => ( + + router.push(`/user-tests/${userTest.id}`)} /> + + ))} + + + ) +} + +export default MeUserTestsScreen; \ No newline at end of file diff --git a/app/tests/[id].tsx b/app/tests/[id].tsx index d45abbc..9b35559 100644 --- a/app/tests/[id].tsx +++ b/app/tests/[id].tsx @@ -5,11 +5,13 @@ import { Button, ButtonText } from "@/components/ui/button"; import Content from "@/components/ui/content"; import { Stack, useLocalSearchParams } from "expo-router"; import { View } from "react-native"; +import { useToast } from "react-native-toast-notifications"; const TestScreen = () => { const { id: idParam } = useLocalSearchParams<{ id: string }>(); const id = +idParam; const { data, isLoading } = useTest(id); + const toast = useToast(); if (!data) return ( @@ -19,7 +21,9 @@ const TestScreen = () => { ); - const handleStartTest = () => {}; + const handleStartTest = () => { + toast.show("Test started!", { type: "success" }); + }; return ( <> @@ -34,7 +38,7 @@ const TestScreen = () => { } - {data.questions.map((question) => ( + {data.questions?.map((question) => ( ))} diff --git a/app/user-tests/[id].tsx b/app/user-tests/[id].tsx new file mode 100644 index 0000000..2291a1c --- /dev/null +++ b/app/user-tests/[id].tsx @@ -0,0 +1,95 @@ +import { AnswerResponse, QuestionVariant } from "@/api/types"; +import { useUserTest } from "@/api/userTests"; +import Question from "@/components/question"; +import Answer from "@/components/ui/answer"; +import { Button, ButtonText } from "@/components/ui/button"; +import Content from "@/components/ui/content"; +import { Divider } from "@/components/ui/divider"; +import UserTest from "@/components/user-test"; +import { getUserTestStatus, TestStatus } from "@/utils/get-user-test-status"; +import getUserTestTitle from "@/utils/get-user-test-title"; +import { Stack, useLocalSearchParams } from "expo-router"; +import { View } from "react-native"; + +const UserTestScreen = () => { + const { id: idParam } = useLocalSearchParams<{ id: string }>(); + const id = +idParam; + const { data: userTest } = useUserTest(id); + + if (!userTest) + return ( + <> + + + + ); + + const status = getUserTestStatus(userTest); + const displayCorrectAnswers = status !== TestStatus.InProgress; + + const handleContinueTest = () => { + + } + const isCorrectVariation = (answer: AnswerResponse, variant: QuestionVariant) => { + if(answer.is_correct) return true; + + const isCorrectVariant = (answer.question.correct_answers as number[]).includes(variant.id); + const isUserAnsweredVariant = answer.answer ? (answer.answer as number[]).includes(variant.id) : false; + + return isCorrectVariant === isUserAnsweredVariant; + } + + return ( + <> + + + + + + + {status === TestStatus.InProgress && } + + + + + {userTest.answers.map((answer) => ( + + + + {answer.question.type !== 'text' && answer.question.variants.map((variant) => ( + + + + ))} + {answer.question.type === 'text' && ( + + + + )} + + + ))} + + + + + ) +} + +export default UserTestScreen; \ No newline at end of file diff --git a/components/test.tsx b/components/test.tsx index c1d59a4..6f8767d 100644 --- a/components/test.tsx +++ b/components/test.tsx @@ -43,7 +43,7 @@ const Test = ({ test, onPress }: TestProps) => { {test.author.username} - {test.questions.length} Questions + {test.questions_count} Questions diff --git a/components/ui/answer.tsx b/components/ui/answer.tsx index 8cbaa29..ecee9d8 100644 --- a/components/ui/answer.tsx +++ b/components/ui/answer.tsx @@ -91,7 +91,7 @@ const Answer = ({ return style; }, [displayCorrect, displayUserAnswer, isAnsweredByUser, isCorrectAnswer, isTextType, userAnsweredCorrectly]); const textColor = useMemo(() => { - if(stylePanel) + if(stylePanel === styles.correctAnswer || stylePanel === styles.incorrectAnswer) return "#ffffff"; else return undefined; diff --git a/components/ui/button-section.tsx b/components/ui/button-section.tsx new file mode 100644 index 0000000..88322af --- /dev/null +++ b/components/ui/button-section.tsx @@ -0,0 +1,18 @@ +import { ComponentProps } from "react"; +import { Button } from "./button"; + +import Panel from "./panel"; + +type ButtonSectionProps = ComponentProps; + +const ButtonSection = ({children, ...rest }: ButtonSectionProps) => { + return ( + + + + ) +} + +export default ButtonSection; \ No newline at end of file diff --git a/components/ui/custom-select/index.tsx b/components/ui/custom-select/index.tsx index cba7a90..36b8398 100644 --- a/components/ui/custom-select/index.tsx +++ b/components/ui/custom-select/index.tsx @@ -14,6 +14,7 @@ interface CustomSelectProps { defaultValue?: string; selectedValue?:string; onValueChange?: (value: string) => void; + className?: string; } const CustomSelect = ({ @@ -22,6 +23,7 @@ const CustomSelect = ({ defaultValue, selectedValue, noneOption, + className, placeholder = "Select option" }: CustomSelectProps) => { const optionsComputed = useMemo(() => { @@ -33,7 +35,7 @@ const CustomSelect = ({ const backgroundColor = useThemeColor({ }, "background"); return ( - diff --git a/components/ui/panel.tsx b/components/ui/panel.tsx index fac6de0..94c6bc9 100644 --- a/components/ui/panel.tsx +++ b/components/ui/panel.tsx @@ -6,13 +6,14 @@ interface PanelProps { children?: ReactNode; className?: string; style?: StyleProp; + withPadding?: boolean; } -const Panel = ({children, className, style}: PanelProps) => { +const Panel = ({children, className, style, withPadding = true}: PanelProps) => { const backgroundColor = useThemeColor({ }, 'background'); return ( - + {children} ) @@ -20,10 +21,12 @@ const Panel = ({children, className, style}: PanelProps) => { const styles = StyleSheet.create({ panel: { - padding: 15, borderRadius: 5, boxShadow: '0px 4px 5px 0px rgba(0,0,0,0.18)' }, + panelPadding: { + padding: 15, + } }); diff --git a/components/user-test.tsx b/components/user-test.tsx new file mode 100644 index 0000000..eef1254 --- /dev/null +++ b/components/user-test.tsx @@ -0,0 +1,107 @@ +import { UserTestResponse } from "@/api/types"; +import formatISODate from "@/utils/format-iso-date"; +import { getUserTestStatus } from "@/utils/get-user-test-status"; +import getUserTestTitle from "@/utils/get-user-test-title"; +import { FontAwesome5 } from "@expo/vector-icons"; +import { Pressable, StyleSheet, View } from "react-native"; +import { Badge, BadgeText } from "./ui/badge"; +import { Divider } from "./ui/divider"; +import Panel from "./ui/panel"; +import { Skeleton } from "./ui/skeleton"; +import { ThemedText } from "./ui/themed-text"; + +type BadgeType = "muted" | "success" | "info" | "warning" | "error"; +enum TestStatus { + Completed = "Completed", + Expired = "Expired", + InProgress = "In Progress", +} + +interface UserTestProps { + userTest?: UserTestResponse; + onPress?: (userTest: UserTestResponse) => void; +} + +const UserTest = ({ userTest, onPress }: UserTestProps) => { + if (!userTest) return ; + + const title = getUserTestTitle(userTest); + + const testStatus = getUserTestStatus(userTest); + let scoreType: BadgeType = "muted"; + + if (userTest.is_completed || !userTest.is_available) { + if (userTest.score >= 80) scoreType = "success"; + else if (userTest.score >= 60) scoreType = "info"; + else if (userTest.score >= 40) scoreType = "warning"; + else if (userTest.score >= 0) scoreType = "error"; + } + const successAnswers = userTest.answers.filter(answer => answer.is_correct).length; + + let testStatusType: BadgeType = "warning"; + if (testStatus === TestStatus.Completed) testStatusType = "success"; + else if (testStatus === TestStatus.Expired) testStatusType = "error"; + + return ( + onPress?.(userTest)}> + + + + + {testStatus} + + + + + {userTest.score} + + + + + + {title} + + + + + Started at: + + + {formatISODate(userTest.created_at)} + + + + + Closed at: + + + {formatISODate(userTest.closed_at)} + + + + + + + + + {userTest.answers.length} Questions + + + + {successAnswers} / {userTest.answers.length} Correct + + + + + ); +}; + +const styles = StyleSheet.create({ + testTitle: { + fontSize: 18, + fontWeight: "600", + marginBottom: 10, + }, +}); + +export default UserTest; diff --git a/utils/get-user-test-status.ts b/utils/get-user-test-status.ts new file mode 100644 index 0000000..f184899 --- /dev/null +++ b/utils/get-user-test-status.ts @@ -0,0 +1,20 @@ +import { UserTestResponse } from "@/api/types"; + +export enum TestStatus { + Completed = "Completed", + Expired = "Expired", + InProgress = "In Progress" +} + +export const getUserTestStatus = (userTest: UserTestResponse): TestStatus => { + let status: TestStatus = TestStatus.Completed; + + if(!userTest.is_available && !userTest.is_completed) + return TestStatus.Expired; + + if(!userTest.is_completed) + return TestStatus.InProgress; + + + return status +} \ No newline at end of file diff --git a/utils/get-user-test-title.ts b/utils/get-user-test-title.ts new file mode 100644 index 0000000..8d9068a --- /dev/null +++ b/utils/get-user-test-title.ts @@ -0,0 +1,9 @@ +import { UserTestResponse } from "@/api/types"; + +const getUserTestTitle = (userTest: UserTestResponse): string => { + return userTest.test_id !== null + ? `Test #${userTest.id}` + : `Random Test #${userTest.id}`; +} + +export default getUserTestTitle; \ No newline at end of file