950 lines
33 KiB
TypeScript

// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, OnDestroy, ViewChild, ChangeDetectorRef, ViewChildren, QueryList, ElementRef } from '@angular/core';
import { IonContent } from '@ionic/angular';
import { Subscription } from 'rxjs';
import { CoreIonLoadingElement } from '@classes/ion-loading';
import { CoreQuestionComponent } from '@features/question/components/question/question';
import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
import { CoreQuestionBehaviourButton, CoreQuestionHelper } from '@features/question/services/question-helper';
import { CoreNavigator } from '@services/navigator';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreSync } from '@services/sync';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { ModalController, Translate } from '@singletons';
import { CoreEvents } from '@singletons/events';
import { AddonModQuizAutoSave } from '../../classes/auto-save';
import {
AddonModQuizNavigationModalComponent,
AddonModQuizNavigationModalReturn,
AddonModQuizNavigationQuestion,
} from '../../components/navigation-modal/navigation-modal';
import {
AddonModQuiz,
AddonModQuizAttemptWSData,
AddonModQuizGetAttemptAccessInformationWSResponse,
AddonModQuizGetQuizAccessInformationWSResponse,
AddonModQuizQuizWSData,
} from '../../services/quiz';
import { AddonModQuizHelper } from '../../services/quiz-helper';
import { AddonModQuizSync } from '../../services/quiz-sync';
import { CanLeave } from '@guards/can-leave';
import { CoreForms } from '@singletons/form';
import { CoreDom } from '@singletons/dom';
import { CoreTime } from '@singletons/time';
import { CoreDirectivesRegistry } from '@singletons/directives-registry';
import { CoreWSError } from '@classes/errors/wserror';
import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics';
import { ADDON_MOD_QUIZ_ATTEMPT_FINISHED_EVENT, AddonModQuizAttemptStates, ADDON_MOD_QUIZ_COMPONENT } from '../../constants';
/**
* Page that allows attempting a quiz.
*/
@Component({
selector: 'page-addon-mod-quiz-player',
templateUrl: 'player.html',
styleUrls: ['player.scss'],
})
export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave {
@ViewChild(IonContent) content?: IonContent;
@ViewChildren(CoreQuestionComponent) questionComponents?: QueryList<CoreQuestionComponent>;
@ViewChild('quizForm') formElement?: ElementRef;
quiz?: AddonModQuizQuizWSData; // The quiz the attempt belongs to.
attempt?: QuizAttempt; // The attempt being attempted.
moduleUrl?: string; // URL to the module in the site.
component = ADDON_MOD_QUIZ_COMPONENT; // Component to link the files to.
loaded = false; // Whether data has been loaded.
quizAborted = false; // Whether the quiz was aborted due to an error.
offline = false; // Whether the quiz is being attempted in offline mode.
navigation: AddonModQuizNavigationQuestion[] = []; // List of questions to navigate them.
questions: QuizQuestion[] = []; // Questions of the current page.
nextPage = -2; // Next page.
previousPage = -1; // Previous page.
showSummary = false; // Whether the attempt summary should be displayed.
summaryQuestions: CoreQuestionQuestionParsed[] = []; // The questions to display in the summary.
canReturn = false; // Whether the user can return to a page after seeing the summary.
preventSubmitMessages: string[] = []; // List of messages explaining why the quiz cannot be submitted.
endTime?: number; // The time when the attempt must be finished.
autoSaveError = false; // Whether there's been an error in auto-save.
isSequential = false; // Whether quiz navigation is sequential.
readableTimeLimit?: string; // Time limit in a readable format.
dueDateWarning?: string; // Warning about due date.
courseId!: number; // The course ID the quiz belongs to.
cmId!: number; // Course module ID.
protected preflightData: Record<string, string> = {}; // Preflight data to attempt the quiz.
protected quizAccessInfo?: AddonModQuizGetQuizAccessInformationWSResponse; // Quiz access information.
protected attemptAccessInfo?: AddonModQuizGetAttemptAccessInformationWSResponse; // Attempt access info.
protected lastAttempt?: QuizAttempt; // Last user attempt before a new one is created (if needed).
protected newAttempt = false; // Whether the user is starting a new attempt.
protected quizDataLoaded = false; // Whether the quiz data has been loaded.
protected timeUpCalled = false; // Whether the time up function has been called.
protected autoSave!: AddonModQuizAutoSave; // Class to auto-save answers every certain time.
protected autoSaveErrorSubscription?: Subscription; // To be notified when an error happens in auto-save.
protected forceLeave = false; // If true, don't perform any check when leaving the view.
protected reloadNavigation = false; // Whether navigation needs to be reloaded because some data was sent to server.
constructor(
protected changeDetector: ChangeDetectorRef,
protected elementRef: ElementRef,
) {
}
/**
* @inheritdoc
*/
ngOnInit(): void {
try {
this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId');
this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId');
this.moduleUrl = CoreNavigator.getRouteParam('moduleUrl');
} catch (error) {
CoreDomUtils.showErrorModal(error);
CoreNavigator.back();
return;
}
// Create the auto save instance.
this.autoSave = new AddonModQuizAutoSave(
'addon-mod_quiz-player-form',
'#addon-mod_quiz-connection-error-button',
);
// Start the player when the page is loaded.
this.start();
// Listen for errors on auto-save.
this.autoSaveErrorSubscription = this.autoSave.onError().subscribe((error) => {
this.autoSaveError = error;
this.changeDetector.detectChanges();
});
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.stopAutoSave();
if (this.quiz) {
// Unblock the quiz so it can be synced.
CoreSync.unblockOperation(ADDON_MOD_QUIZ_COMPONENT, this.quiz.id);
}
}
/**
* Check if we can leave the page or not.
*
* @returns Resolved if we can leave it, rejected if not.
*/
async canLeave(): Promise<boolean> {
if (this.forceLeave || this.quizAborted || !this.questions.length || this.showSummary) {
return true;
}
// Save answers.
const modal = await CoreDomUtils.showModalLoading('core.sending', true);
try {
await this.processAttempt(false, false);
} catch (error) {
// Save attempt failed. Show confirmation.
modal.dismiss();
await CoreDomUtils.showConfirm(Translate.instant('addon.mod_quiz.confirmleavequizonerror'));
CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId());
} finally {
modal.dismiss();
}
return true;
}
/**
* Runs when the page is about to leave and no longer be the active page.
*/
async ionViewWillLeave(): Promise<void> {
// Close any modal if present.
const modal = await ModalController.getTop();
modal?.dismiss();
}
/**
* Abort the quiz.
*/
abortQuiz(): void {
this.quizAborted = true;
}
/**
* A behaviour button in a question was clicked (Check, Redo, ...).
*
* @param button Clicked button.
*/
async behaviourButtonClicked(button: CoreQuestionBehaviourButton): Promise<void> {
if (!this.quiz || !this.attempt) {
return;
}
let modal: CoreIonLoadingElement | undefined;
try {
// Confirm that the user really wants to do it.
await CoreDomUtils.showConfirm(Translate.instant('core.areyousure'));
modal = await CoreDomUtils.showModalLoading('core.sending', true);
// Get the answers.
const answers = await this.prepareAnswers(this.quiz.coursemodule);
// Add the clicked button data.
answers[button.name] = button.value;
// Behaviour checks are always in online.
await AddonModQuiz.processAttempt(this.quiz, this.attempt, answers, this.preflightData);
this.reloadNavigation = true; // Data sent to server, navigation should be reloaded.
// Reload the current page.
const scrollElement = await this.content?.getScrollElement();
const scrollTop = scrollElement?.scrollTop || -1;
this.loaded = false;
this.content?.scrollToTop(); // Scroll top so the spinner is seen.
try {
await this.loadPage(this.attempt.currentpage ?? 0);
} finally {
this.loaded = true;
if (scrollTop != -1) {
// Wait for content to be rendered.
setTimeout(() => {
this.content?.scrollToPoint(0, scrollTop);
}, 50);
}
}
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'Error performing action.');
} finally {
modal?.dismiss();
}
}
/**
* Change the current page. If slot is supplied, try to scroll to that question.
*
* @param page Page to load. -1 means summary.
* @param fromModal Whether the page was selected using the navigation modal.
* @param slot Slot of the question to scroll to.
* @returns Promise resolved when done.
*/
async changePage(page: number, fromModal?: boolean, slot?: number): Promise<void> {
if (!this.attempt) {
return;
}
if (page != -1 && (this.attempt.state === AddonModQuizAttemptStates.OVERDUE || this.attempt.finishedOffline)) {
// We can't load a page if overdue or the local attempt is finished.
return;
} else if (page == this.attempt.currentpage && !this.showSummary && slot !== undefined) {
// Navigating to a question in the current page.
await this.scrollToQuestion(slot);
return;
} else if (
(page == this.attempt.currentpage && !this.showSummary) ||
(fromModal && this.isSequential && page != this.attempt.currentpage && page !== this.nextPage)
) {
// If the user is navigating to the current page we do nothing.
// Also, in sequential quizzes we can only navigate to the current page.
return;
} else if (page === -1 && this.showSummary) {
// Summary already shown.
return;
}
this.content?.scrollToTop();
// First try to save the attempt data. We only save it if we're not seeing the summary.
if (!this.showSummary) {
const modal = await CoreDomUtils.showModalLoading('core.sending', true);
try {
await this.processAttempt(false, false);
modal.dismiss();
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorsaveattempt', true);
modal.dismiss();
return;
}
this.reloadNavigation = true; // Data sent to server, navigation should be reloaded.
}
this.loaded = false;
try {
// Attempt data successfully saved, load the page or summary.
// Stop checking for changes during page change.
this.autoSave.stopCheckChangesProcess();
if (page === -1) {
await this.loadSummary();
} else {
await this.loadPage(page);
}
} catch (error) {
// If the user isn't seeing the summary, start the check again.
if (!this.showSummary && this.quiz) {
this.autoSave.startCheckChangesProcess(this.quiz, this.attempt, this.preflightData, this.offline);
}
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquestions', true);
} finally {
this.loaded = true;
if (slot !== undefined) {
// Scroll to the question.
await this.scrollToQuestion(slot);
}
}
}
/**
* Convenience function to get the quiz data.
*
* @returns Promise resolved when done.
*/
protected async fetchData(): Promise<void> {
this.quiz = await AddonModQuiz.getQuiz(this.courseId, this.cmId);
// Block the quiz so it cannot be synced.
CoreSync.blockOperation(ADDON_MOD_QUIZ_COMPONENT, this.quiz.id);
// Wait for any ongoing sync to finish. We won't sync a quiz while it's being played.
await AddonModQuizSync.waitForSync(this.quiz.id);
this.isSequential = AddonModQuiz.isNavigationSequential(this.quiz);
if (AddonModQuiz.isQuizOffline(this.quiz)) {
// Quiz supports offline.
this.offline = true;
} else {
// Quiz doesn't support offline right now, but maybe it did and then the setting was changed.
// If we have an unfinished offline attempt then we'll use offline mode.
this.offline = await AddonModQuiz.isLastAttemptOfflineUnfinished(this.quiz);
}
if (this.quiz.timelimit && this.quiz.timelimit > 0) {
this.readableTimeLimit = CoreTime.formatTime(this.quiz.timelimit);
}
// Get access information for the quiz.
this.quizAccessInfo = await AddonModQuiz.getQuizAccessInformation(this.quiz.id, {
cmId: this.quiz.coursemodule,
readingStrategy: this.offline ? CoreSitesReadingStrategy.PREFER_CACHE : CoreSitesReadingStrategy.ONLY_NETWORK,
});
// Get user attempts to determine last attempt.
const attempts = await AddonModQuiz.getUserAttempts(this.quiz.id, {
cmId: this.quiz.coursemodule,
readingStrategy: this.offline ? CoreSitesReadingStrategy.PREFER_CACHE : CoreSitesReadingStrategy.ONLY_NETWORK,
});
if (!attempts.length) {
// There are no attempts, start a new one.
this.newAttempt = true;
return;
}
// Get the last attempt. If it's finished, start a new one.
this.lastAttempt = attempts[attempts.length - 1];
this.lastAttempt.finishedOffline = await AddonModQuiz.isAttemptFinishedOffline(this.lastAttempt.id);
this.newAttempt = AddonModQuiz.isAttemptCompleted(this.lastAttempt.state);
}
/**
* Finish an attempt, either by timeup or because the user clicked to finish it.
*
* @param userFinish Whether the user clicked to finish the attempt.
* @param timeUp Whether the quiz time is up.
* @returns Promise resolved when done.
*/
async finishAttempt(userFinish?: boolean, timeUp?: boolean): Promise<void> {
if (!this.quiz || !this.attempt) {
return;
}
let modal: CoreIonLoadingElement | undefined;
try {
// Show confirm if the user clicked the finish button and the quiz is in progress.
if (!timeUp && this.attempt.state === AddonModQuizAttemptStates.IN_PROGRESS) {
let message = Translate.instant('addon.mod_quiz.confirmclose');
const unansweredCount = this.summaryQuestions
.filter(question => AddonModQuiz.isQuestionUnanswered(question))
.length;
if (!this.isSequential && unansweredCount > 0) {
const warning = Translate.instant(
'addon.mod_quiz.submission_confirmation_unanswered',
{ $a: unansweredCount },
);
message += `
<ion-card class="core-warning-card">
<ion-item>
<ion-label>
${ warning }
</ion-label>
</ion-item>
</ion-card>
`;
}
await CoreDomUtils.showConfirm(
message,
Translate.instant('addon.mod_quiz.submitallandfinish'),
Translate.instant('core.submit'),
);
}
modal = await CoreDomUtils.showModalLoading('core.sending', true);
await this.processAttempt(userFinish, timeUp);
// Trigger an event to notify the attempt was finished.
CoreEvents.trigger(ADDON_MOD_QUIZ_ATTEMPT_FINISHED_EVENT, {
quizId: this.quiz.id,
attemptId: this.attempt.id,
synced: !this.offline,
}, CoreSites.getCurrentSiteId());
CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'quiz' });
if (!timeUp || !this.quiz.graceperiod) {
// Leave the player.
this.forceLeave = true;
CoreNavigator.back();
} else {
// Stay in player to show summary.
this.stopAutoSave();
this.clearTimer();
await this.refreshAttempt();
await this.loadSummary();
}
} catch (error) {
// eslint-disable-next-line promise/catch-or-return
CoreDomUtils
.showErrorModalDefault(error, 'addon.mod_quiz.errorsaveattempt', true)
.then(async alert => {
await alert?.onWillDismiss();
if (error instanceof CoreWSError && error.errorcode === 'attemptalreadyclosed') {
CoreNavigator.back();
}
return;
});
} finally {
modal?.dismiss();
}
}
/**
* Fix sequence checks of current page.
*
* @returns Promise resolved when done.
*/
protected async fixSequenceChecks(): Promise<void> {
if (!this.attempt) {
return;
}
// Get current page data again to get the latest sequencechecks.
const data = await AddonModQuiz.getAttemptData(this.attempt.id, this.attempt.currentpage ?? 0, this.preflightData, {
cmId: this.quiz?.coursemodule,
readingStrategy: this.offline ? CoreSitesReadingStrategy.PREFER_CACHE : CoreSitesReadingStrategy.ONLY_NETWORK,
});
const newSequenceChecks: Record<number, { name: string; value: string }> = {};
data.questions.forEach((question) => {
const sequenceCheck = CoreQuestionHelper.getQuestionSequenceCheckFromHtml(question.html);
if (sequenceCheck) {
newSequenceChecks[question.slot] = sequenceCheck;
}
});
// Notify the new sequence checks to the components.
this.questionComponents?.forEach((component) => {
component.updateSequenceCheck(newSequenceChecks);
});
}
/**
* Get the input answers.
*
* @returns Object with the answers.
*/
protected getAnswers(): CoreQuestionsAnswers {
return CoreQuestionHelper.getAnswersFromForm(document.forms['addon-mod_quiz-player-form']);
}
/**
* Initializes the timer if enabled.
*/
protected initTimer(): void {
if (!this.quizAccessInfo || !this.attempt || !this.attemptAccessInfo?.endtime || this.attemptAccessInfo.endtime < 0) {
return;
}
// Quiz has an end time. Check if time left should be shown.
const shouldShowTime = AddonModQuiz.shouldShowTimeLeft(
this.quizAccessInfo.activerulenames,
this.attempt,
this.attemptAccessInfo.endtime,
);
if (shouldShowTime) {
this.endTime = this.attemptAccessInfo.endtime;
} else {
delete this.endTime;
}
}
/**
* Remove timer info.
*/
protected clearTimer(): void {
delete this.endTime;
}
/**
* Load a page questions.
*
* @param page The page to load.
* @returns Promise resolved when done.
*/
protected async loadPage(page: number): Promise<void> {
if (!this.quiz || !this.attempt) {
return;
}
if (this.isSequential) {
await this.logViewPage(page);
}
const data = await AddonModQuiz.getAttemptData(this.attempt.id, page, this.preflightData, {
cmId: this.quiz.coursemodule,
readingStrategy: this.offline ? CoreSitesReadingStrategy.PREFER_CACHE : CoreSitesReadingStrategy.ONLY_NETWORK,
});
// Update attempt, status could change during the execution.
this.attempt = data.attempt;
this.attempt.currentpage = page;
this.questions = data.questions;
this.nextPage = data.nextpage;
this.previousPage = this.isSequential ? -1 : page - 1;
this.showSummary = false;
this.questions.forEach((question) => {
// Get the readable mark for each question.
question.readableMark = AddonModQuizHelper.getQuestionMarkFromHtml(question.html);
// Extract the question info box.
CoreQuestionHelper.extractQuestionInfoBox(question, '.info');
// Check if the question is blocked. If it is, treat it as a description question.
if (AddonModQuiz.isQuestionBlocked(question)) {
question.type = 'description';
}
});
// Mark the page as viewed.
if (!this.isSequential) {
await this.logViewPage(page);
}
// Start looking for changes.
this.autoSave.startCheckChangesProcess(this.quiz, this.attempt, this.preflightData, this.offline);
}
/**
* Log view a page.
*
* @param page Page viewed.
*/
protected async logViewPage(page: number): Promise<void> {
if (!this.quiz || !this.attempt) {
return;
}
await CoreUtils.ignoreErrors(AddonModQuiz.logViewAttempt(this.attempt.id, page, this.preflightData, this.offline));
CoreAnalytics.logEvent({
type: CoreAnalyticsEventType.VIEW_ITEM,
ws: 'mod_quiz_view_attempt',
name: this.quiz.name,
data: { id: this.attempt.id, quizid: this.quiz.id, page, category: 'quiz' },
url: `/mod/quiz/attempt.php?attempt=${this.attempt.id}&cmid=${this.cmId}` + (page > 0 ? `&page=${page}` : ''),
});
}
/**
* Log view summary.
*/
protected async logViewSummary(): Promise<void> {
if (!this.quiz || !this.attempt) {
return;
}
await CoreUtils.ignoreErrors(
AddonModQuiz.logViewAttemptSummary(this.attempt.id, this.preflightData, this.quiz.id),
);
CoreAnalytics.logEvent({
type: CoreAnalyticsEventType.VIEW_ITEM,
ws: 'mod_quiz_view_attempt_summary',
name: this.quiz.name,
data: { id: this.attempt.id, quizid: this.quiz.id, category: 'quiz' },
url: `/mod/quiz/summary.php?attempt=${this.attempt.id}&cmid=${this.cmId}`,
});
}
/**
* Refresh attempt data.
*/
protected async refreshAttempt(): Promise<void> {
if (!this.quiz) {
return;
}
const attempts = await AddonModQuiz.getUserAttempts(this.quiz.id, {
cmId: this.quiz.coursemodule,
readingStrategy: this.offline ? CoreSitesReadingStrategy.PREFER_CACHE : CoreSitesReadingStrategy.ONLY_NETWORK,
});
this.attempt = attempts.find(attempt => attempt.id === this.attempt?.id);
}
/**
* Load attempt summary.
*
* @returns Promise resolved when done.
*/
protected async loadSummary(): Promise<void> {
if (!this.quiz || !this.attempt) {
return;
}
this.summaryQuestions = [];
this.summaryQuestions = await AddonModQuiz.getAttemptSummary(this.attempt.id, this.preflightData, {
cmId: this.quiz.coursemodule,
loadLocal: this.offline,
readingStrategy: this.offline ? CoreSitesReadingStrategy.PREFER_CACHE : CoreSitesReadingStrategy.ONLY_NETWORK,
});
this.showSummary = true;
this.canReturn = this.attempt.state === AddonModQuizAttemptStates.IN_PROGRESS && !this.attempt.finishedOffline;
this.preventSubmitMessages = AddonModQuiz.getPreventSubmitMessages(this.summaryQuestions);
this.dueDateWarning = AddonModQuiz.getAttemptDueDateWarning(this.quiz, this.attempt);
this.logViewSummary();
}
/**
* Load data to navigate the questions using the navigation modal.
*
* @returns Promise resolved when done.
*/
protected async loadNavigation(): Promise<void> {
if (!this.attempt) {
return;
}
// We use the attempt summary to build the navigation because it contains all the questions.
this.navigation = await AddonModQuiz.getAttemptSummary(this.attempt.id, this.preflightData, {
cmId: this.quiz?.coursemodule,
loadLocal: this.offline,
readingStrategy: this.offline ? CoreSitesReadingStrategy.PREFER_CACHE : CoreSitesReadingStrategy.ONLY_NETWORK,
});
this.navigation.forEach((question) => {
question.stateClass = CoreQuestionHelper.getQuestionStateClass(question.state || '');
});
}
/**
* Open the navigation modal.
*
* @returns Promise resolved when done.
*/
async openNavigation(): Promise<void> {
if (this.reloadNavigation) {
// Some data has changed, reload the navigation.
const modal = await CoreDomUtils.showModalLoading();
await CoreUtils.ignoreErrors(this.loadNavigation());
modal.dismiss();
this.reloadNavigation = false;
}
// Create the navigation modal.
const modalData = await CoreDomUtils.openSideModal<AddonModQuizNavigationModalReturn>({
component: AddonModQuizNavigationModalComponent,
componentProps: {
navigation: this.navigation,
summaryShown: this.showSummary,
currentPage: this.attempt?.currentpage,
nextPage: this.nextPage,
isReview: false,
isSequential: this.isSequential,
},
});
if (!modalData) {
return;
}
this.changePage(modalData.page, true, modalData.slot);
}
/**
* Prepare the answers to be sent for the attempt.
*
* @param componentId Component ID.
* @returns Promise resolved with the answers.
*/
protected prepareAnswers(componentId: number): Promise<CoreQuestionsAnswers> {
return CoreQuestionHelper.prepareAnswers(
this.questions,
this.getAnswers(),
this.offline,
this.component,
componentId,
);
}
/**
* Process attempt.
*
* @param userFinish Whether the user clicked to finish the attempt.
* @param timeUp Whether the quiz time is up.
* @param retrying Whether we're retrying the change.
* @returns Promise resolved when done.
*/
protected async processAttempt(userFinish?: boolean, timeUp?: boolean, retrying?: boolean): Promise<void> {
if (!this.quiz || !this.attempt) {
return;
}
// Get the answers to send.
let answers: CoreQuestionsAnswers = {};
if (!this.showSummary) {
answers = await this.prepareAnswers(this.quiz.coursemodule);
}
try {
// Send the answers.
await AddonModQuiz.processAttempt(
this.quiz,
this.attempt,
answers,
this.preflightData,
userFinish,
timeUp,
this.offline,
);
} catch (error) {
if (!error || error.errorcode != 'submissionoutofsequencefriendlymessage') {
throw error;
}
try {
// There was an error with the sequence check. Try to ammend it.
await this.fixSequenceChecks();
} catch {
throw error;
}
if (retrying) {
// We're already retrying, don't send the data again because it could cause an infinite loop.
throw error;
}
// Sequence checks updated, try to send the data again.
return this.processAttempt(userFinish, timeUp, true);
}
// Answers saved, cancel auto save.
this.autoSave.cancelAutoSave();
this.autoSave.hideAutoSaveError();
if (this.formElement) {
CoreForms.triggerFormSubmittedEvent(this.formElement, !this.offline, CoreSites.getCurrentSiteId());
}
return CoreQuestionHelper.clearTmpData(this.questions, this.component, this.quiz.coursemodule);
}
/**
* Scroll to a certain question.
*
* @param slot Slot of the question to scroll to.
*/
protected async scrollToQuestion(slot: number): Promise<void> {
await CoreUtils.nextTick();
await CoreDirectivesRegistry.waitDirectivesReady(this.elementRef.nativeElement, 'core-question');
await CoreDom.scrollToElement(
this.elementRef.nativeElement,
'#addon-mod_quiz-question-' + slot,
);
}
/**
* Show connection error.
*
* @param ev Click event.
*/
showConnectionError(ev: Event): void {
this.autoSave.showAutoSaveError(ev);
}
/**
* Convenience function to start the player.
*/
async start(): Promise<void> {
try {
this.loaded = false;
if (!this.quizDataLoaded) {
// Fetch data.
await this.fetchData();
this.quizDataLoaded = true;
}
// Quiz data has been loaded, try to start or continue.
await this.startOrContinueAttempt();
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquiz', true);
} finally {
this.loaded = true;
}
}
/**
* Start or continue an attempt.
*
* @returns Promise resolved when done.
*/
protected async startOrContinueAttempt(): Promise<void> {
if (!this.quiz || !this.quizAccessInfo) {
return;
}
let attempt = this.newAttempt ? undefined : this.lastAttempt;
// Get the preflight data and start attempt if needed.
attempt = await AddonModQuizHelper.getAndCheckPreflightData(
this.quiz,
this.quizAccessInfo,
this.preflightData,
attempt,
this.offline,
false,
'addon.mod_quiz.startattempt',
);
// Re-fetch attempt access information with the right attempt (might have changed because a new attempt was created).
this.attemptAccessInfo = await AddonModQuiz.getAttemptAccessInformation(this.quiz.id, attempt.id, {
cmId: this.quiz.coursemodule,
readingStrategy: this.offline ? CoreSitesReadingStrategy.PREFER_CACHE : CoreSitesReadingStrategy.ONLY_NETWORK,
});
this.attempt = attempt;
await this.loadNavigation();
if (this.attempt.state !== AddonModQuizAttemptStates.OVERDUE && !this.attempt.finishedOffline) {
// Attempt not overdue and not finished in offline, load page.
await this.loadPage(this.attempt.currentpage ?? 0);
this.initTimer();
} else {
// Attempt is overdue or finished in offline, we can only load the summary.
await this.loadSummary();
}
}
/**
* Quiz time has finished.
*/
timeUp(): void {
if (this.timeUpCalled) {
return;
}
this.timeUpCalled = true;
this.finishAttempt(false, true);
}
/**
* Stop auto-saving answers.
*/
protected stopAutoSave(): void {
this.autoSave.cancelAutoSave();
this.autoSave.stopCheckChangesProcess();
this.autoSaveErrorSubscription?.unsubscribe();
}
}
/**
* Question with some calculated data for the view.
*/
type QuizQuestion = CoreQuestionQuestionParsed & {
readableMark?: string;
};
/**
* Attempt with some calculated data for the view.
*/
type QuizAttempt = AddonModQuizAttemptWSData & {
finishedOffline?: boolean;
};