From 9568cb06b974105649660baf805ce9f829714902 Mon Sep 17 00:00:00 2001 From: Stepan Date: Tue, 6 Jan 2026 20:18:23 +0100 Subject: [PATCH] Completed UserTEst doing --- api/_client.ts | 19 ++ api/userTests.ts | 33 ++- app/(tabs)/index.tsx | 2 +- app/tests/[id].tsx | 6 +- app/user-tests/[id].tsx | 4 +- app/user-tests/doing/[id].tsx | 272 ++++++++++++++++++++++++ components/ui/alert-dialog/index.tsx | 296 +++++++++++++++++++++++++++ components/ui/answer.tsx | 6 +- components/ui/content.tsx | 4 +- components/ui/textarea/index.tsx | 94 +++++++++ 10 files changed, 723 insertions(+), 13 deletions(-) create mode 100644 app/user-tests/doing/[id].tsx create mode 100644 components/ui/alert-dialog/index.tsx create mode 100644 components/ui/textarea/index.tsx diff --git a/api/_client.ts b/api/_client.ts index bfaad0c..8f07244 100644 --- a/api/_client.ts +++ b/api/_client.ts @@ -123,5 +123,24 @@ export const post_start_test = async (test_id: number) => { } ) + return response.data; +} + +export const post_test_complete = async (test_id: number) => { + const response = await axiosInstance.post<{ data: UserTestResponse }>( + `/api/user-tests/${test_id}/complete/`, + ) + + return response.data; +} + +export const post_test_answer = async (answerId: number, answer: (string | number)[]) => { + const response = await axiosInstance.post( + `/api/user-test-answers/${answerId}/submit/`, + { + answer + } + ); + return response.data; } \ No newline at end of file diff --git a/api/userTests.ts b/api/userTests.ts index 85abf60..ad6e758 100644 --- a/api/userTests.ts +++ b/api/userTests.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AxiosError } from "axios"; -import { get_my_user_tests, get_user_test, post_start_random_test, post_start_test } from "./_client"; +import { get_my_user_tests, get_user_test, post_start_random_test, post_start_test, post_test_answer, post_test_complete } from "./_client"; +import { UserTestResponse } from "./types"; export const useMyTests = () => { return useQuery({ @@ -30,7 +31,7 @@ interface RandomUserTestAttrs { export const useRandomUserTestMutation = () => { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: async ({ min, max, category_id }: RandomUserTestAttrs) => { const response = await post_start_random_test(min, max, category_id); @@ -49,7 +50,7 @@ interface StartTestAttrs { export const useStartTestMutation = () => { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: async ({ test_id }: StartTestAttrs) => { const response = await post_start_test(test_id); @@ -60,4 +61,30 @@ export const useStartTestMutation = () => { return response.data; } }) +} + +interface AnswerAttrs { + answerId: number; + answer: (string | number)[]; +} +export const useAnswerMutation = () => { + return useMutation({ + mutationFn: ({ answerId, answer }) => post_test_answer(answerId, answer) + }); +} + +interface CompleteTestAttrs { + testId: number; +} +export const useCompleteTestMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({testId}) => post_test_complete(testId), + onSuccess: (_, { testId }) => { + queryClient.invalidateQueries({ + queryKey: ["user-test", testId] + }); + } + }); } \ No newline at end of file diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index c30e80e..aff1149 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -34,7 +34,7 @@ export default function HomeScreen() { category_id: catId }, { onSuccess: (data) => { - toast.show(`New user test created: ${data.id}` , { type: "success" }) + router.push(`/user-tests/doing/${data.id}`); }, onError: (error) => { toast.show(getErrorAxiosMessage(error), { type: "danger" }) diff --git a/app/tests/[id].tsx b/app/tests/[id].tsx index d06ee72..d826ebc 100644 --- a/app/tests/[id].tsx +++ b/app/tests/[id].tsx @@ -6,7 +6,7 @@ import { ButtonText } from "@/components/ui/button"; import Content from "@/components/ui/content"; import CustomButton from "@/components/ui/custom-button"; import getErrorAxiosMessage from "@/utils/get-error-axios-message"; -import { Stack, useLocalSearchParams } from "expo-router"; +import { router, Stack, useLocalSearchParams } from "expo-router"; import { View } from "react-native"; import { useToast } from "react-native-toast-notifications"; @@ -31,7 +31,9 @@ const TestScreen = () => { test_id: data.id, }, { - onSuccess: (data) => {}, + onSuccess: (data) => { + router.push(`/user-tests/doing/${data.id}`); + }, onError: (error) => { toast.show(getErrorAxiosMessage(error), { type: "danger" }); }, diff --git a/app/user-tests/[id].tsx b/app/user-tests/[id].tsx index 2291a1c..0103996 100644 --- a/app/user-tests/[id].tsx +++ b/app/user-tests/[id].tsx @@ -8,7 +8,7 @@ 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 { router, Stack, useLocalSearchParams } from "expo-router"; import { View } from "react-native"; const UserTestScreen = () => { @@ -28,7 +28,7 @@ const UserTestScreen = () => { const displayCorrectAnswers = status !== TestStatus.InProgress; const handleContinueTest = () => { - + router.push(`/user-tests/doing/${userTest.id}`); } const isCorrectVariation = (answer: AnswerResponse, variant: QuestionVariant) => { if(answer.is_correct) return true; diff --git a/app/user-tests/doing/[id].tsx b/app/user-tests/doing/[id].tsx new file mode 100644 index 0000000..41e0a33 --- /dev/null +++ b/app/user-tests/doing/[id].tsx @@ -0,0 +1,272 @@ +import { QuestionTypes } from "@/api/types"; +import { + useAnswerMutation, + useCompleteTestMutation, + useUserTest, +} from "@/api/userTests"; +import Question from "@/components/question"; +import { + AlertDialog, + AlertDialogBackdrop, + AlertDialogBody, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, +} from "@/components/ui/alert-dialog"; +import Answer from "@/components/ui/answer"; +import { Button, ButtonText } from "@/components/ui/button"; +import Content from "@/components/ui/content"; +import CustomButton from "@/components/ui/custom-button"; +import { Divider } from "@/components/ui/divider"; +import Panel from "@/components/ui/panel"; +import { Textarea, TextareaInput } from "@/components/ui/textarea"; +import { ThemedText } from "@/components/ui/themed-text"; +import getErrorAxiosMessage from "@/utils/get-error-axios-message"; +import getUserTestTitle from "@/utils/get-user-test-title"; +import { router, Stack, useLocalSearchParams } from "expo-router"; +import { useEffect, useState } from "react"; +import { ScrollView, StyleSheet, View } from "react-native"; +import { useToast } from "react-native-toast-notifications"; + +const DoingUserTestScreen = () => { + const { id: idParam } = useLocalSearchParams<{ id: string }>(); + const [answer, setAnswer] = useState<(string | number)[]>([]); + const [page, setPage] = useState(0); + const [openCompleteDialog, setOpenCompleteDialog] = useState(false); + const id = +idParam; + const { data: userTest, refetch: refetchUserTest } = useUserTest(id); + const { mutateAsync: mutateAsyncAnswer, isPending: answerPending } = + useAnswerMutation(); + const { mutate: mutateCompleteTest, isPending: completePending } = useCompleteTestMutation(); + const toast = useToast(); + useEffect(() => { + if (!userTest) return; + + setAnswer(userTest.answers[page].answer || []); + }, [userTest, page]); + + if (!userTest) + return ( + <> + + + + + + ); + + const currentAnswer = userTest.answers[page]; + const currentQuestion = currentAnswer.question; + const pages = Array.from({ length: userTest.answers.length }).map( + (_, i) => i + ); + const countAnsweredQuestion = userTest.answers.filter( + (answer) => answer.answer + ).length; + const isAllAnswered = countAnsweredQuestion === userTest.answers.length; + const isLastPage = page === pages.length - 1; + const userTextAnswer = answer ? (answer[0] as string) : ""; + const alertDialogMessage = isAllAnswered + ? "Finish the test to see your results." + : "Warning: You haven't answered all questions. Are you sure you want to finish?"; + + const handleSetAnswer = (value: number | string) => { + if (currentQuestion.type === QuestionTypes.Text) { + setAnswer([value]); + } + if (currentQuestion.type === QuestionTypes.Single) { + setAnswer([value as number]); + } + if (currentQuestion.type === QuestionTypes.Multiple) { + if ((answer as number[]).includes(value as number)) { + setAnswer((prev) => prev.filter((v) => v !== value)); + } else { + setAnswer((prev) => { + const set = new Set(prev as number[]); + set.add(value as number); + return Array.from(set); + }); + } + } + }; + const handleSaveAnswer = async () => { + let ans = answer; + + if (currentQuestion.type === QuestionTypes.Text && answer) { + ans = [(answer[0] as string).trim()]; + } + + try { + await mutateAsyncAnswer( + { + answerId: currentAnswer.id, + answer: ans, + }, + { + onSuccess: () => { + toast.show("Your answer was saved", { type: "success" }); + if(!isLastPage) + goPage(page + 1); + }, + onError: (error) => { + toast.show(getErrorAxiosMessage(error), { type: "danger" }); + }, + } + ); + } catch {} + await refetchUserTest(); + }; + const handleCompleteTest = () => { + mutateCompleteTest({ + testId: userTest.id + }, { + onSuccess: () => { + router.replace(`/user-tests/${userTest.id}`); + }, + onError: (error) => { + toast.show(getErrorAxiosMessage(error), { type: "danger" }); + } + }) + }; + const goPage = (page: number) => { + setAnswer([]); + setPage(page); + }; + + return ( + <> + + + + + + {pages.map((p) => ( + + ))} + + + + + + + + + + {currentQuestion.variants.map((variant) => ( + handleSetAnswer(variant.id)} + displayUserAnswer={true} + /> + ))} + {currentQuestion.type === QuestionTypes.Text && ( + + + Your answer: + + + + )} + + + + Save answer + + + + + {(isAllAnswered || isLastPage) && ( + + )} + + + + setOpenCompleteDialog(false)} + > + + + + Complete test + + + {alertDialogMessage} + + + + + Complete + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: "flex-start", + }, + top: { + width: "100%", + marginBottom: 15, + }, + topScrollView: { + flexDirection: "row", + alignItems: "flex-start", + gap: 10, + }, + content: { + flex: 1, + width: "100%", + }, +}); + +export default DoingUserTestScreen; diff --git a/components/ui/alert-dialog/index.tsx b/components/ui/alert-dialog/index.tsx new file mode 100644 index 0000000..f605e51 --- /dev/null +++ b/components/ui/alert-dialog/index.tsx @@ -0,0 +1,296 @@ +'use client'; +import React from 'react'; +import { createAlertDialog } from '@gluestack-ui/core/alert-dialog/creator'; +import { tva } from '@gluestack-ui/utils/nativewind-utils'; +import { + withStyleContext, + useStyleContext, +} from '@gluestack-ui/utils/nativewind-utils'; + +import { cssInterop } from 'nativewind'; +import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils'; +import { + Motion, + AnimatePresence, + createMotionAnimatedComponent, + MotionComponentProps, +} from '@legendapp/motion'; +import { View, Pressable, ScrollView, ViewStyle } from 'react-native'; + +const SCOPE = 'ALERT_DIALOG'; + +const RootComponent = withStyleContext(View, SCOPE); + +type IMotionViewProps = React.ComponentProps & + MotionComponentProps; + +const MotionView = Motion.View as React.ComponentType; + +type IAnimatedPressableProps = React.ComponentProps & + MotionComponentProps; + +const AnimatedPressable = createMotionAnimatedComponent( + Pressable +) as React.ComponentType; + +const UIAccessibleAlertDialog = createAlertDialog({ + Root: RootComponent, + Body: ScrollView, + Content: MotionView, + CloseButton: Pressable, + Header: View, + Footer: View, + Backdrop: AnimatedPressable, + AnimatePresence: AnimatePresence, +}); + +cssInterop(MotionView, { className: 'style' }); +cssInterop(AnimatedPressable, { className: 'style' }); + +const alertDialogStyle = tva({ + base: 'group/modal w-full h-full justify-center items-center web:pointer-events-none', + parentVariants: { + size: { + xs: '', + sm: '', + md: '', + lg: '', + full: '', + }, + }, +}); + +const alertDialogContentStyle = tva({ + base: 'bg-background-0 rounded-lg overflow-hidden border border-outline-100 p-6', + parentVariants: { + size: { + xs: 'w-[60%] max-w-[360px]', + sm: 'w-[70%] max-w-[420px]', + md: 'w-[80%] max-w-[510px]', + lg: 'w-[90%] max-w-[640px]', + full: 'w-full', + }, + }, +}); + +const alertDialogCloseButtonStyle = tva({ + base: 'group/alert-dialog-close-button z-10 rounded-sm p-2 data-[focus-visible=true]:bg-background-100 web:cursor-pointer outline-0', +}); + +const alertDialogHeaderStyle = tva({ + base: 'justify-between items-center flex-row', +}); + +const alertDialogFooterStyle = tva({ + base: 'flex-row justify-end items-center gap-3', +}); + +const alertDialogBodyStyle = tva({ base: '' }); + +const alertDialogBackdropStyle = tva({ + base: 'absolute left-0 top-0 right-0 bottom-0 bg-background-dark web:cursor-default', +}); + +type IAlertDialogProps = React.ComponentPropsWithoutRef< + typeof UIAccessibleAlertDialog +> & + VariantProps; + +type IAlertDialogContentProps = React.ComponentPropsWithoutRef< + typeof UIAccessibleAlertDialog.Content +> & + VariantProps & { className?: string }; + +type IAlertDialogCloseButtonProps = React.ComponentPropsWithoutRef< + typeof UIAccessibleAlertDialog.CloseButton +> & + VariantProps; + +type IAlertDialogHeaderProps = React.ComponentPropsWithoutRef< + typeof UIAccessibleAlertDialog.Header +> & + VariantProps; + +type IAlertDialogFooterProps = React.ComponentPropsWithoutRef< + typeof UIAccessibleAlertDialog.Footer +> & + VariantProps; + +type IAlertDialogBodyProps = React.ComponentPropsWithoutRef< + typeof UIAccessibleAlertDialog.Body +> & + VariantProps; + +type IAlertDialogBackdropProps = React.ComponentPropsWithoutRef< + typeof UIAccessibleAlertDialog.Backdrop +> & + VariantProps & { className?: string }; + +const AlertDialog = React.forwardRef< + React.ComponentRef, + IAlertDialogProps +>(function AlertDialog({ className, size = 'md', ...props }, ref) { + return ( + + ); +}); + +const AlertDialogContent = React.forwardRef< + React.ComponentRef, + IAlertDialogContentProps +>(function AlertDialogContent({ className, size, ...props }, ref) { + const { size: parentSize } = useStyleContext(SCOPE); + + return ( + + ); +}); + +const AlertDialogCloseButton = React.forwardRef< + React.ComponentRef, + IAlertDialogCloseButtonProps +>(function AlertDialogCloseButton({ className, ...props }, ref) { + return ( + + ); +}); + +const AlertDialogHeader = React.forwardRef< + React.ComponentRef, + IAlertDialogHeaderProps +>(function AlertDialogHeader({ className, ...props }, ref) { + return ( + + ); +}); + +const AlertDialogFooter = React.forwardRef< + React.ComponentRef, + IAlertDialogFooterProps +>(function AlertDialogFooter({ className, ...props }, ref) { + return ( + + ); +}); + +const AlertDialogBody = React.forwardRef< + React.ComponentRef, + IAlertDialogBodyProps +>(function AlertDialogBody({ className, ...props }, ref) { + return ( + + ); +}); + +const AlertDialogBackdrop = React.forwardRef< + React.ComponentRef, + IAlertDialogBackdropProps +>(function AlertDialogBackdrop({ className, ...props }, ref) { + return ( + + ); +}); + +AlertDialog.displayName = 'AlertDialog'; +AlertDialogContent.displayName = 'AlertDialogContent'; +AlertDialogCloseButton.displayName = 'AlertDialogCloseButton'; +AlertDialogHeader.displayName = 'AlertDialogHeader'; +AlertDialogFooter.displayName = 'AlertDialogFooter'; +AlertDialogBody.displayName = 'AlertDialogBody'; +AlertDialogBackdrop.displayName = 'AlertDialogBackdrop'; + +export { + AlertDialog, + AlertDialogContent, + AlertDialogCloseButton, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogBody, + AlertDialogBackdrop, +}; diff --git a/components/ui/answer.tsx b/components/ui/answer.tsx index ecee9d8..01f51e7 100644 --- a/components/ui/answer.tsx +++ b/components/ui/answer.tsx @@ -10,8 +10,8 @@ interface AnswerProps { answer?: QuestionVariant; isTextType?: boolean; - userAnswers?: string[] | number[]; - correctAnswers?: string[] | number[]; + userAnswers?: (string | number)[]; + correctAnswers?: (string | number)[]; isCorrectUserAnswer?: boolean; @@ -100,7 +100,7 @@ const Answer = ({ if (!answer) return ; return ( - + {isTextType ? ( diff --git a/components/ui/content.tsx b/components/ui/content.tsx index 06427ca..2fa18ae 100644 --- a/components/ui/content.tsx +++ b/components/ui/content.tsx @@ -6,9 +6,9 @@ interface ContentProps extends ScrollViewProps { children?: React.ReactNode; } -const Content = forwardRef(({ children, ...rest }, ref) => { +const Content = forwardRef(({ children, style, ...rest }, ref) => { return ( - + {children} ); diff --git a/components/ui/textarea/index.tsx b/components/ui/textarea/index.tsx new file mode 100644 index 0000000..a385dee --- /dev/null +++ b/components/ui/textarea/index.tsx @@ -0,0 +1,94 @@ +'use client'; +import React from 'react'; +import { createTextarea } from '@gluestack-ui/core/textarea/creator'; +import { View, TextInput } from 'react-native'; +import { tva } from '@gluestack-ui/utils/nativewind-utils'; +import { + withStyleContext, + useStyleContext, +} from '@gluestack-ui/utils/nativewind-utils'; +import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils'; + +const SCOPE = 'TEXTAREA'; +const UITextarea = createTextarea({ + Root: withStyleContext(View, SCOPE), + Input: TextInput, +}); + +const textareaStyle = tva({ + base: 'w-full h-[100px] border border-background-300 rounded data-[hover=true]:border-outline-400 data-[focus=true]:border-primary-700 data-[focus=true]:data-[hover=true]:border-primary-700 data-[disabled=true]:opacity-40 data-[disabled=true]:bg-background-50 data-[disabled=true]:data-[hover=true]:border-background-300', + + variants: { + variant: { + default: + 'data-[focus=true]:border-primary-700 data-[focus=true]:web:ring-1 data-[focus=true]:web:ring-inset data-[focus=true]:web:ring-indicator-primary data-[invalid=true]:border-error-700 data-[invalid=true]:web:ring-1 data-[invalid=true]:web:ring-inset data-[invalid=true]:web:ring-indicator-error data-[invalid=true]:data-[hover=true]:border-error-700 data-[invalid=true]:data-[focus=true]:data-[hover=true]:border-primary-700 data-[invalid=true]:data-[focus=true]:data-[hover=true]:web:ring-1 data-[invalid=true]:data-[focus=true]:data-[hover=true]:web:ring-inset data-[invalid=true]:data-[focus=true]:data-[hover=true]:web:ring-indicator-primary data-[invalid=true]:data-[disabled=true]:data-[hover=true]:border-error-700 data-[invalid=true]:data-[disabled=true]:data-[hover=true]:web:ring-1 data-[invalid=true]:data-[disabled=true]:data-[hover=true]:web:ring-inset data-[invalid=true]:data-[disabled=true]:data-[hover=true]:web:ring-indicator-error ', + }, + size: { + sm: '', + md: '', + lg: '', + xl: '', + }, + }, +}); + +const textareaInputStyle = tva({ + base: 'p-2 web:outline-0 web:outline-none flex-1 color-typography-900 placeholder:text-typography-500 web:cursor-text web:data-[disabled=true]:cursor-not-allowed', + parentVariants: { + size: { + sm: 'text-sm', + md: 'text-base', + lg: 'text-lg', + xl: 'text-xl', + }, + }, +}); + +type ITextareaProps = React.ComponentProps & + VariantProps; + +const Textarea = React.forwardRef< + React.ComponentRef, + ITextareaProps +>(function Textarea( + { className, variant = 'default', size = 'md', ...props }, + ref +) { + return ( + + ); +}); + +type ITextareaInputProps = React.ComponentProps & + VariantProps; + +const TextareaInput = React.forwardRef< + React.ComponentRef, + ITextareaInputProps +>(function TextareaInput({ className, ...props }, ref) { + const { size: parentSize } = useStyleContext(SCOPE); + + return ( + + ); +}); + +Textarea.displayName = 'Textarea'; +TextareaInput.displayName = 'TextareaInput'; + +export { Textarea, TextareaInput };