379 lines
11 KiB
TypeScript
379 lines
11 KiB
TypeScript
import { Component, OnInit, OnDestroy, inject, signal, computed, effect } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
|
import { Router, ActivatedRoute } from '@angular/router';
|
|
import { MatCardModule } from '@angular/material/card';
|
|
import { MatRadioModule } from '@angular/material/radio';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
import { MatChipsModule } from '@angular/material/chips';
|
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
import { MatInputModule } from '@angular/material/input';
|
|
import { MatDividerModule } from '@angular/material/divider';
|
|
import { Subject, takeUntil, interval } from 'rxjs';
|
|
import { QuizService } from '../../../core/services/quiz.service';
|
|
import { StorageService } from '../../../core/services/storage.service';
|
|
import { QuizAnswerSubmission, QuizAnswerResponse } from '../../../core/models/quiz.model';
|
|
import { Question } from '../../../core/models/question.model';
|
|
|
|
@Component({
|
|
selector: 'app-quiz-question',
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
ReactiveFormsModule,
|
|
MatCardModule,
|
|
MatRadioModule,
|
|
MatButtonModule,
|
|
MatIconModule,
|
|
MatProgressBarModule,
|
|
MatProgressSpinnerModule,
|
|
MatChipsModule,
|
|
MatFormFieldModule,
|
|
MatInputModule,
|
|
MatDividerModule
|
|
],
|
|
templateUrl: './quiz-question.html',
|
|
styleUrls: ['./quiz-question.scss']
|
|
})
|
|
export class QuizQuestionComponent implements OnInit, OnDestroy {
|
|
private readonly fb = inject(FormBuilder);
|
|
private readonly router = inject(Router);
|
|
private readonly route = inject(ActivatedRoute);
|
|
private readonly quizService = inject(QuizService);
|
|
private readonly storageService = inject(StorageService);
|
|
private readonly destroy$ = new Subject<void>();
|
|
|
|
// Session ID from route
|
|
sessionId: string = '';
|
|
|
|
// Form
|
|
answerForm!: FormGroup;
|
|
|
|
// State signals
|
|
readonly activeSession = this.quizService.activeSession;
|
|
readonly isSubmittingAnswer = this.quizService.isSubmittingAnswer;
|
|
readonly questions = this.quizService.questions;
|
|
|
|
// Current question state
|
|
readonly currentQuestionIndex = computed(() => this.activeSession()?.currentQuestionIndex ?? 0);
|
|
readonly totalQuestions = computed(() => this.activeSession()?.totalQuestions ?? 0);
|
|
readonly currentQuestion = signal<Question | null>(null);
|
|
|
|
// Answer feedback state
|
|
readonly answerSubmitted = signal<boolean>(false);
|
|
readonly answerResult = signal<QuizAnswerResponse | null>(null);
|
|
readonly showExplanation = signal<boolean>(false);
|
|
|
|
// Timer state (for timed quizzes)
|
|
readonly timeRemaining = signal<number>(0); // in seconds
|
|
readonly timerRunning = signal<boolean>(false);
|
|
|
|
// Progress
|
|
readonly progress = computed(() => {
|
|
const total = this.totalQuestions();
|
|
const current = this.currentQuestionIndex();
|
|
return total > 0 ? (current / total) * 100 : 0;
|
|
});
|
|
|
|
readonly currentScore = computed(() => this.activeSession()?.score ?? 0);
|
|
readonly correctAnswers = computed(() => this.activeSession()?.correctAnswers ?? 0);
|
|
|
|
// Computed values
|
|
readonly isLastQuestion = computed(() => {
|
|
return this.currentQuestionIndex() >= this.totalQuestions() - 1;
|
|
});
|
|
|
|
readonly canSubmitAnswer = computed(() => {
|
|
return this.answerForm?.valid && !this.answerSubmitted() && !this.isSubmittingAnswer();
|
|
});
|
|
|
|
readonly questionTypeLabel = computed(() => {
|
|
const type = this.currentQuestion()?.questionType;
|
|
switch (type) {
|
|
case 'multiple_choice': return 'Multiple Choice';
|
|
case 'true_false': return 'True/False';
|
|
case 'written': return 'Written Answer';
|
|
default: return '';
|
|
}
|
|
});
|
|
|
|
ngOnInit(): void {
|
|
this.sessionId = this.route.snapshot.params['sessionId'];
|
|
|
|
if (!this.sessionId) {
|
|
this.router.navigate(['/quiz/setup']);
|
|
return;
|
|
}
|
|
|
|
this.initForm();
|
|
this.loadQuizSession();
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.destroy$.next();
|
|
this.destroy$.complete();
|
|
this.timerRunning.set(false);
|
|
}
|
|
|
|
/**
|
|
* Initialize answer form
|
|
*/
|
|
private initForm(): void {
|
|
this.answerForm = this.fb.group({
|
|
answer: ['', Validators.required]
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Load quiz session and questions
|
|
*/
|
|
private loadQuizSession(): void {
|
|
// Check if we have an active session with questions already loaded
|
|
const activeSession = this.activeSession();
|
|
const questions = this.questions();
|
|
|
|
if (activeSession && activeSession.id === this.sessionId && questions.length > 0) {
|
|
// Session and questions already loaded from quiz start
|
|
if (activeSession.status === 'completed') {
|
|
this.router.navigate(['/quiz', this.sessionId, 'results']);
|
|
return;
|
|
}
|
|
|
|
this.loadCurrentQuestion();
|
|
|
|
// Start timer for timed quizzes
|
|
if (activeSession.quizType === 'timed' && activeSession.timeSpent) {
|
|
const timeLimit = this.calculateTimeLimit(activeSession.totalQuestions);
|
|
const remaining = (timeLimit * 60) - (activeSession.timeSpent || 0);
|
|
if (remaining > 0) {
|
|
this.startTimer(remaining);
|
|
}
|
|
}
|
|
} else {
|
|
// Try to restore session from server
|
|
this.restoreSessionFromServer();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restore session from server if page was refreshed
|
|
*/
|
|
private restoreSessionFromServer(): void {
|
|
this.quizService.restoreSession(this.sessionId)
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe({
|
|
next: ({ session, hasQuestions }) => {
|
|
if (session.status === 'completed') {
|
|
this.router.navigate(['/quiz', this.sessionId, 'results']);
|
|
return;
|
|
}
|
|
|
|
if (!hasQuestions) {
|
|
// Questions need to be fetched separately
|
|
// For now, redirect to setup as we can't continue without questions
|
|
// In a production app, you would fetch questions from the backend
|
|
console.warn('Session restored but questions not available');
|
|
this.router.navigate(['/quiz/setup']);
|
|
return;
|
|
}
|
|
|
|
// Session restored successfully
|
|
this.loadCurrentQuestion();
|
|
|
|
// Start timer for timed quizzes
|
|
if (session.quizType === 'timed' && session.timeSpent) {
|
|
const timeLimit = this.calculateTimeLimit(session.totalQuestions);
|
|
const remaining = (timeLimit * 60) - (session.timeSpent || 0);
|
|
if (remaining > 0) {
|
|
this.startTimer(remaining);
|
|
}
|
|
}
|
|
},
|
|
error: () => {
|
|
// Session not found or error occurred
|
|
this.router.navigate(['/quiz/setup']);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Calculate time limit for timed quiz
|
|
*/
|
|
private calculateTimeLimit(questionCount: number): number {
|
|
return questionCount * 1.5; // 1.5 minutes per question
|
|
}
|
|
|
|
/**
|
|
* Load current question based on session index
|
|
*/
|
|
private loadCurrentQuestion(): void {
|
|
const index = this.currentQuestionIndex();
|
|
const allQuestions = this.questions();
|
|
|
|
if (index < allQuestions.length) {
|
|
this.currentQuestion.set(allQuestions[index]);
|
|
this.answerSubmitted.set(false);
|
|
this.answerResult.set(null);
|
|
this.showExplanation.set(false);
|
|
this.answerForm.reset();
|
|
this.answerForm.enable();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start timer for timed quiz
|
|
*/
|
|
private startTimer(initialTime: number): void {
|
|
this.timeRemaining.set(initialTime);
|
|
this.timerRunning.set(true);
|
|
|
|
interval(1000)
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe(() => {
|
|
const current = this.timeRemaining();
|
|
if (current > 0 && this.timerRunning()) {
|
|
this.timeRemaining.set(current - 1);
|
|
} else if (current === 0) {
|
|
this.timerRunning.set(false);
|
|
this.autoCompleteQuiz();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Auto-complete quiz when timer expires
|
|
*/
|
|
private autoCompleteQuiz(): void {
|
|
this.quizService.completeQuiz(this.sessionId)
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe();
|
|
}
|
|
|
|
/**
|
|
* Submit answer
|
|
*/
|
|
submitAnswer(): void {
|
|
if (!this.canSubmitAnswer()) {
|
|
return;
|
|
}
|
|
|
|
const question = this.currentQuestion();
|
|
if (!question) return;
|
|
|
|
const answer = this.answerForm.get('answer')?.value;
|
|
|
|
const submission: QuizAnswerSubmission = {
|
|
questionId: question.id,
|
|
answer: answer,
|
|
quizSessionId: this.sessionId
|
|
};
|
|
|
|
this.quizService.submitAnswer(submission)
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe({
|
|
next: (response) => {
|
|
this.answerSubmitted.set(true);
|
|
this.answerResult.set(response);
|
|
this.showExplanation.set(true);
|
|
this.answerForm.disable();
|
|
},
|
|
error: (error) => {
|
|
console.error('Failed to submit answer:', error);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Move to next question
|
|
*/
|
|
nextQuestion(): void {
|
|
if (this.isLastQuestion()) {
|
|
// Complete quiz
|
|
this.completeQuiz();
|
|
} else {
|
|
// Load next question
|
|
this.loadCurrentQuestion();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Complete quiz
|
|
*/
|
|
completeQuiz(): void {
|
|
this.timerRunning.set(false);
|
|
this.quizService.completeQuiz(this.sessionId)
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe();
|
|
}
|
|
|
|
/**
|
|
* Get feedback icon based on answer correctness
|
|
*/
|
|
getFeedbackIcon(): string {
|
|
const result = this.answerResult();
|
|
return result?.isCorrect ? 'check_circle' : 'cancel';
|
|
}
|
|
|
|
/**
|
|
* Get feedback color
|
|
*/
|
|
getFeedbackColor(): string {
|
|
const result = this.answerResult();
|
|
return result?.isCorrect ? '#4CAF50' : '#f44336';
|
|
}
|
|
|
|
/**
|
|
* Get feedback message
|
|
*/
|
|
getFeedbackMessage(): string {
|
|
const result = this.answerResult();
|
|
if (!result) return '';
|
|
return result.isCorrect ? 'Correct!' : 'Incorrect';
|
|
}
|
|
|
|
/**
|
|
* Format time remaining (MM:SS)
|
|
*/
|
|
formatTime(seconds: number): string {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
/**
|
|
* Get difficulty color
|
|
*/
|
|
getDifficultyColor(difficulty: string): string {
|
|
switch (difficulty) {
|
|
case 'easy': return '#4CAF50';
|
|
case 'medium': return '#FF9800';
|
|
case 'hard': return '#f44336';
|
|
default: return '#9E9E9E';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if answer is multiple choice
|
|
*/
|
|
isMultipleChoice(): boolean {
|
|
return this.currentQuestion()?.questionType === 'multiple_choice';
|
|
}
|
|
|
|
/**
|
|
* Check if answer is true/false
|
|
*/
|
|
isTrueFalse(): boolean {
|
|
return this.currentQuestion()?.questionType === 'true_false';
|
|
}
|
|
|
|
/**
|
|
* Check if answer is written
|
|
*/
|
|
isWritten(): boolean {
|
|
return this.currentQuestion()?.questionType === 'written';
|
|
}
|
|
}
|