MOBILE-3651 quiz: Implement player page
parent
916dc14401
commit
1c443b183b
|
@ -99,7 +99,7 @@
|
|||
<ng-container *ngSwitchCase="'multichoice'">
|
||||
<!-- Single choice. -->
|
||||
<ion-radio-group *ngIf="!question.multi" [formControlName]="question.controlName">
|
||||
<ion-item class="ion-text-wrap" *ngFor="let option of question.options" lines="none">
|
||||
<ion-item class="ion-text-wrap" *ngFor="let option of question.options">
|
||||
<ion-label>
|
||||
<core-format-text [component]="component" [componentId]="lesson.coursemodule"
|
||||
[text]="option.text" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
|
||||
|
@ -113,7 +113,7 @@
|
|||
|
||||
<!-- Multiple choice. -->
|
||||
<ng-container *ngIf="question.multi">
|
||||
<ion-item class="ion-text-wrap" *ngFor="let option of question.options" lines="none">
|
||||
<ion-item class="ion-text-wrap" *ngFor="let option of question.options">
|
||||
<ion-label>
|
||||
<core-format-text [component]="component" [componentId]="lesson?.coursemodule"
|
||||
[text]="option.text" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
|
||||
|
|
|
@ -27,7 +27,7 @@ import { CoreUrlUtils } from '@services/utils/url';
|
|||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { CoreWSExternalFile } from '@services/ws';
|
||||
import { ModalController, Translate } from '@singletons';
|
||||
import { CoreEvents } from '@singletons/events';
|
||||
import { CoreEventActivityDataSentData, CoreEvents } from '@singletons/events';
|
||||
import { AddonModLessonMenuModalPage } from '../../components/menu-modal/menu-modal';
|
||||
import {
|
||||
AddonModLesson,
|
||||
|
@ -409,7 +409,7 @@ export class AddonModLessonPlayerPage implements OnInit, OnDestroy, CanLeave {
|
|||
this.messages = this.messages.concat(data.messages);
|
||||
this.processData = undefined;
|
||||
|
||||
CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'lesson' });
|
||||
CoreEvents.trigger<CoreEventActivityDataSentData>(CoreEvents.ACTIVITY_DATA_SENT, { module: 'lesson' });
|
||||
|
||||
// Format activity link if present.
|
||||
if (this.eolData.activitylink) {
|
||||
|
|
|
@ -0,0 +1,254 @@
|
|||
// (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 { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { CoreQuestionHelper } from '@features/question/services/question-helper';
|
||||
import { CoreQuestionsAnswers } from '@features/question/services/question';
|
||||
import { PopoverController } from '@singletons';
|
||||
import { CoreLogger } from '@singletons/logger';
|
||||
import { AddonModQuizConnectionErrorComponent } from '../components/connection-error/connection-error';
|
||||
import { AddonModQuiz, AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '../services/quiz';
|
||||
|
||||
/**
|
||||
* Class to support auto-save in quiz. Every certain seconds, it will check if there are changes in the current page answers
|
||||
* and, if so, it will save them automatically.
|
||||
*/
|
||||
export class AddonModQuizAutoSave {
|
||||
|
||||
protected readonly CHECK_CHANGES_INTERVAL = 5000;
|
||||
|
||||
protected logger: CoreLogger;
|
||||
protected checkChangesInterval?: number; // Interval to check if there are changes in the answers.
|
||||
protected loadPreviousAnswersTimeout?: number; // Timeout to load previous answers.
|
||||
protected autoSaveTimeout?: number; // Timeout to auto-save the answers.
|
||||
protected popover?: HTMLIonPopoverElement; // Popover to display there's been an error.
|
||||
protected popoverShown = false; // Whether the popover is shown.
|
||||
protected previousAnswers?: CoreQuestionsAnswers; // The previous answers, to check if answers have changed.
|
||||
protected errorObservable: BehaviorSubject<boolean>; // An observable to notify if there's been an error.
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param formName Name of the form where the answers are stored.
|
||||
* @param buttonSelector Selector to find the button to show the connection error.
|
||||
*/
|
||||
constructor(
|
||||
protected formName: string,
|
||||
protected buttonSelector: string,
|
||||
) {
|
||||
this.logger = CoreLogger.getInstance('AddonModQuizAutoSave');
|
||||
|
||||
// Create the observable to notify if an error happened.
|
||||
this.errorObservable = new BehaviorSubject<boolean>(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a pending auto save.
|
||||
*/
|
||||
cancelAutoSave(): void {
|
||||
clearTimeout(this.autoSaveTimeout);
|
||||
this.autoSaveTimeout = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the answers have changed in a page.
|
||||
*
|
||||
* @param quiz Quiz.
|
||||
* @param attempt Attempt.
|
||||
* @param preflightData Preflight data.
|
||||
* @param offline Whether the quiz is being attempted in offline mode.
|
||||
*/
|
||||
checkChanges(
|
||||
quiz: AddonModQuizQuizWSData,
|
||||
attempt: AddonModQuizAttemptWSData,
|
||||
preflightData: Record<string, string>,
|
||||
offline?: boolean,
|
||||
): void {
|
||||
if (this.autoSaveTimeout) {
|
||||
// We already have an auto save pending, no need to check changes.
|
||||
return;
|
||||
}
|
||||
|
||||
const answers = this.getAnswers();
|
||||
|
||||
if (!this.previousAnswers) {
|
||||
// Previous answers isn't set, set it now.
|
||||
this.previousAnswers = answers;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if answers have changed.
|
||||
let equal = true;
|
||||
|
||||
for (const name in answers) {
|
||||
if (this.previousAnswers[name] != answers[name]) {
|
||||
equal = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!equal) {
|
||||
this.setAutoSaveTimer(quiz, attempt, preflightData, offline);
|
||||
}
|
||||
|
||||
this.previousAnswers = answers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get answers from a form.
|
||||
*
|
||||
* @return Answers.
|
||||
*/
|
||||
protected getAnswers(): CoreQuestionsAnswers {
|
||||
return CoreQuestionHelper.instance.getAnswersFromForm(document.forms[this.formName]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the auto save error.
|
||||
*/
|
||||
hideAutoSaveError(): void {
|
||||
this.errorObservable.next(false);
|
||||
this.popover?.dismiss();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable that will notify when an error happens or stops.
|
||||
* It will send true when there's an error, and false when the error has been ammended.
|
||||
*
|
||||
* @return Observable.
|
||||
*/
|
||||
onError(): BehaviorSubject<boolean> {
|
||||
return this.errorObservable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule an auto save process if it's not scheduled already.
|
||||
*
|
||||
* @param quiz Quiz.
|
||||
* @param attempt Attempt.
|
||||
* @param preflightData Preflight data.
|
||||
* @param offline Whether the quiz is being attempted in offline mode.
|
||||
*/
|
||||
setAutoSaveTimer(
|
||||
quiz: AddonModQuizQuizWSData,
|
||||
attempt: AddonModQuizAttemptWSData,
|
||||
preflightData: Record<string, string>,
|
||||
offline?: boolean,
|
||||
): void {
|
||||
// Don't schedule if already shceduled or quiz is almost closed.
|
||||
if (!quiz.autosaveperiod || this.autoSaveTimeout || AddonModQuiz.instance.isAttemptTimeNearlyOver(quiz, attempt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule save.
|
||||
this.autoSaveTimeout = window.setTimeout(async () => {
|
||||
const answers = this.getAnswers();
|
||||
this.cancelAutoSave();
|
||||
this.previousAnswers = answers; // Update previous answers to match what we're sending to the server.
|
||||
|
||||
try {
|
||||
await AddonModQuiz.instance.saveAttempt(quiz, attempt, answers, preflightData, offline);
|
||||
|
||||
// Save successful, we can hide the connection error if it was shown.
|
||||
this.hideAutoSaveError();
|
||||
} catch (error) {
|
||||
// Error auto-saving. Show error and set timer again.
|
||||
this.logger.warn('Error auto-saving data.', error);
|
||||
|
||||
// If there was no error already, show the error message.
|
||||
if (!this.errorObservable.getValue()) {
|
||||
this.errorObservable.next(true);
|
||||
this.showAutoSaveError();
|
||||
}
|
||||
|
||||
// Try again.
|
||||
this.setAutoSaveTimer(quiz, attempt, preflightData, offline);
|
||||
}
|
||||
}, quiz.autosaveperiod * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an error popover due to an auto save error.
|
||||
*/
|
||||
async showAutoSaveError(ev?: Event): Promise<void> {
|
||||
// Don't show popover if it was already shown.
|
||||
if (this.popoverShown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const event: unknown = ev || {
|
||||
// Cannot use new Event() because event's target property is readonly
|
||||
target: document.querySelector(this.buttonSelector),
|
||||
stopPropagation: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
preventDefault: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
};
|
||||
this.popoverShown = true;
|
||||
|
||||
this.popover = await PopoverController.instance.create({
|
||||
component: AddonModQuizConnectionErrorComponent,
|
||||
event: <Event> event,
|
||||
});
|
||||
await this.popover.present();
|
||||
|
||||
await this.popover.onDidDismiss();
|
||||
|
||||
this.popoverShown = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a process to periodically check changes in answers.
|
||||
*
|
||||
* @param quiz Quiz.
|
||||
* @param attempt Attempt.
|
||||
* @param preflightData Preflight data.
|
||||
* @param offline Whether the quiz is being attempted in offline mode.
|
||||
*/
|
||||
startCheckChangesProcess(
|
||||
quiz: AddonModQuizQuizWSData,
|
||||
attempt: AddonModQuizAttemptWSData,
|
||||
preflightData: Record<string, string>,
|
||||
offline?: boolean,
|
||||
): void {
|
||||
if (this.checkChangesInterval || !quiz.autosaveperiod) {
|
||||
// We already have the interval in place or the quiz has autosave disabled.
|
||||
return;
|
||||
}
|
||||
|
||||
this.previousAnswers = undefined;
|
||||
|
||||
// Load initial answers in 2.5 seconds so the first check interval finds them already loaded.
|
||||
this.loadPreviousAnswersTimeout = window.setTimeout(() => {
|
||||
this.checkChanges(quiz, attempt, preflightData, offline);
|
||||
}, 2500);
|
||||
|
||||
// Check changes every certain time.
|
||||
this.checkChangesInterval = window.setInterval(() => {
|
||||
this.checkChanges(quiz, attempt, preflightData, offline);
|
||||
}, this.CHECK_CHANGES_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the periodical check for changes.
|
||||
*/
|
||||
stopCheckChangesProcess(): void {
|
||||
clearTimeout(this.loadPreviousAnswersTimeout);
|
||||
clearInterval(this.checkChangesInterval);
|
||||
|
||||
this.loadPreviousAnswersTimeout = undefined;
|
||||
this.checkChangesInterval = undefined;
|
||||
}
|
||||
|
||||
}
|
|
@ -18,11 +18,15 @@ import { CoreSharedModule } from '@/core/shared.module';
|
|||
import { CoreCourseComponentsModule } from '@features/course/components/components.module';
|
||||
import { AddonModQuizConnectionErrorComponent } from './connection-error/connection-error';
|
||||
import { AddonModQuizIndexComponent } from './index/index';
|
||||
import { AddonModQuizNavigationModalComponent } from './navigation-modal/navigation-modal';
|
||||
import { AddonModQuizPreflightModalComponent } from './preflight-modal/preflight-modal';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AddonModQuizIndexComponent,
|
||||
AddonModQuizConnectionErrorComponent,
|
||||
AddonModQuizNavigationModalComponent,
|
||||
AddonModQuizPreflightModalComponent,
|
||||
],
|
||||
imports: [
|
||||
CoreSharedModule,
|
||||
|
@ -33,6 +37,8 @@ import { AddonModQuizIndexComponent } from './index/index';
|
|||
exports: [
|
||||
AddonModQuizIndexComponent,
|
||||
AddonModQuizConnectionErrorComponent,
|
||||
AddonModQuizNavigationModalComponent,
|
||||
AddonModQuizPreflightModalComponent,
|
||||
],
|
||||
})
|
||||
export class AddonModQuizComponentsModule {}
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
<ion-item class="ion-text-wrap">
|
||||
<ion-label>{{ "addon.mod_quiz.connectionerror" | translate }}</ion-label>
|
||||
</ion-item>
|
|
@ -0,0 +1,7 @@
|
|||
:host {
|
||||
background-color: var(--red-light);
|
||||
|
||||
.item {
|
||||
--background: var(--red-light);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
// (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 } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Component that displays a quiz entry page.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'addon-mod-quiz-connection-error',
|
||||
templateUrl: 'connection-error.html',
|
||||
styleUrls: ['connection-error.scss'],
|
||||
})
|
||||
export class AddonModQuizConnectionErrorComponent {
|
||||
|
||||
}
|
|
@ -21,6 +21,7 @@ import { CoreCourse } from '@features/course/services/course';
|
|||
import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate';
|
||||
import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate';
|
||||
import { IonContent } from '@ionic/angular';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { CoreTextUtils } from '@services/utils/text';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
|
@ -464,7 +465,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
|
|||
this.content?.scrollToTop();
|
||||
|
||||
await promise;
|
||||
await CoreUtils.instance.ignoreErrors(this.refreshContent());
|
||||
await CoreUtils.instance.ignoreErrors(this.refreshContent(true));
|
||||
|
||||
this.loaded = true;
|
||||
this.refreshIcon = CoreConstants.ICON_REFRESH;
|
||||
|
@ -533,7 +534,11 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
|
|||
protected openQuiz(): void {
|
||||
this.hasPlayed = true;
|
||||
|
||||
// @todo this.navCtrl.push('player', {courseId: this.courseId, quizId: this.quiz.id, moduleUrl: this.module.url});
|
||||
CoreNavigator.instance.navigate(`../../player/${this.courseId}/${this.quiz!.id}`, {
|
||||
params: {
|
||||
moduleUrl: this.module?.url,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>{{ 'addon.mod_quiz.quiznavigation' | translate }}</ion-title>
|
||||
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
|
||||
<ion-icon slot="icon-only" name="fas-times"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content class="addon-mod_quiz-navigation-modal">
|
||||
<nav>
|
||||
<ion-list>
|
||||
<!-- In player, show button to finish attempt. -->
|
||||
<ion-item button class="ion-text-wrap" *ngIf="!isReview" (click)="loadPage(-1)" detail="true">
|
||||
<ion-label>{{ 'addon.mod_quiz.finishattemptdots' | translate }}</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- In review we can toggle between all questions in same page or one page at a time. -->
|
||||
<ion-item button class="ion-text-wrap" *ngIf="isReview && numPages > 1" (click)="switchMode()" detail="true">
|
||||
<ion-label>
|
||||
<span *ngIf="!showAll">{{ 'addon.mod_quiz.showall' | translate }}</span>
|
||||
<span *ngIf="showAll">{{ 'addon.mod_quiz.showeachpage' | translate }}</span>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item button class="ion-text-wrap {{question.stateClass}}" *ngFor="let question of navigation"
|
||||
[ngClass]='{"core-selected-item": !summaryShown && currentPage == question.page}'
|
||||
(click)="loadPage(question.page, question.slot)" detail="true">
|
||||
|
||||
<ion-label>
|
||||
<span *ngIf="question.number">{{ 'core.question.questionno' | translate:{$a: question.number} }}</span>
|
||||
<span *ngIf="!question.number">{{ 'core.question.information' | translate }}</span>
|
||||
</ion-label>
|
||||
|
||||
<ion-icon *ngIf="!question.number" name="fas-info-circle" slot="end"></ion-icon>
|
||||
<ion-icon *ngIf="question.stateClass == 'core-question-requiresgrading'" name="fas-question-circle"
|
||||
[attr.aria-label]="question.status" slot="end">
|
||||
</ion-icon>
|
||||
<ion-icon *ngIf="question.stateClass == 'core-question-correct'" name="fas-check" color="success"
|
||||
[attr.aria-label]="question.status" slot="end">
|
||||
</ion-icon>
|
||||
<ion-icon *ngIf="question.stateClass == 'core-question-partiallycorrect'" name="fas-check-square"
|
||||
color="warning" [attr.aria-label]="question.status" slot="end">
|
||||
</ion-icon>
|
||||
<ion-icon *ngIf="question.stateClass == 'core-question-incorrect' ||
|
||||
question.stateClass == 'core-question-notanswered'" name="fas-times" color="danger"
|
||||
[attr.aria-label]="question.status" slot="end">
|
||||
</ion-icon>
|
||||
</ion-item>
|
||||
|
||||
<!-- In player, show button to finish attempt. -->
|
||||
<ion-item button class="ion-text-wrap" *ngIf="!isReview" (click)="loadPage(-1)" detail="true">
|
||||
<ion-label>{{ 'addon.mod_quiz.finishattemptdots' | translate }}</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- In review we can toggle between all questions in same page or one page at a time. -->
|
||||
<ion-item button class="ion-text-wrap" *ngIf="isReview && numPages > 1" (click)="switchMode()" detail="true">
|
||||
<ion-label>
|
||||
<span *ngIf="!showAll">{{ 'addon.mod_quiz.showall' | translate }}</span>
|
||||
<span *ngIf="showAll">{{ 'addon.mod_quiz.showeachpage' | translate }}</span>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
</nav>
|
||||
</ion-content>
|
|
@ -0,0 +1,76 @@
|
|||
// (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, Input } from '@angular/core';
|
||||
|
||||
import { CoreQuestionQuestionParsed } from '@features/question/services/question';
|
||||
import { ModalController } from '@singletons';
|
||||
|
||||
/**
|
||||
* Modal that renders the quiz navigation.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'addon-mod-quiz-navigation-modal',
|
||||
templateUrl: 'navigation-modal.html',
|
||||
})
|
||||
export class AddonModQuizNavigationModalComponent {
|
||||
|
||||
static readonly CHANGE_PAGE = 1;
|
||||
static readonly SWITCH_MODE = 2;
|
||||
|
||||
@Input() navigation?: AddonModQuizNavigationQuestion[]; // Whether the user is reviewing the attempt.
|
||||
@Input() summaryShown?: boolean; // Whether summary is currently being shown.
|
||||
@Input() currentPage?: number; // Current page.
|
||||
@Input() isReview?: boolean; // Whether the user is reviewing the attempt.
|
||||
@Input() numPages = 0; // Num of pages for review mode.
|
||||
@Input() showAll?: boolean; // Whether to show all questions in same page or not for review mode.
|
||||
|
||||
/**
|
||||
* Close modal.
|
||||
*/
|
||||
closeModal(): void {
|
||||
ModalController.instance.dismiss();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a certain page.
|
||||
*
|
||||
* @param page The page to load.
|
||||
* @param slot Slot of the question to scroll to.
|
||||
*/
|
||||
loadPage(page: number, slot?: number): void {
|
||||
ModalController.instance.dismiss({
|
||||
action: AddonModQuizNavigationModalComponent.CHANGE_PAGE,
|
||||
page,
|
||||
slot,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch mode in review.
|
||||
*/
|
||||
switchMode(): void {
|
||||
ModalController.instance.dismiss({
|
||||
action: AddonModQuizNavigationModalComponent.SWITCH_MODE,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Question for the navigation menu with some calculated data.
|
||||
*/
|
||||
export type AddonModQuizNavigationQuestion = CoreQuestionQuestionParsed & {
|
||||
stateClass?: string;
|
||||
};
|
|
@ -0,0 +1,32 @@
|
|||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>{{ title | translate }}</ion-title>
|
||||
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
|
||||
<ion-icon slot="icon-only" name="fas-times"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content class="addon-mod_quiz-preflight-modal">
|
||||
<core-loading [hideUntil]="loaded">
|
||||
<form [formGroup]="preflightForm" (ngSubmit)="sendData($event)" #preflightFormEl>
|
||||
<ion-list>
|
||||
<!-- Access rules. -->
|
||||
<ng-container *ngFor="let data of accessRulesData; let last = last">
|
||||
<core-dynamic-component [component]="data.component" [data]="data.data">
|
||||
<p class="ion-padding">Couldn't find the directive to render this access rule.</p>
|
||||
</core-dynamic-component>
|
||||
<ion-item-divider *ngIf="!last"><ion-label></ion-label></ion-item-divider>
|
||||
</ng-container>
|
||||
|
||||
<ion-button expand="block" type="submit" class="ion-margin">
|
||||
{{ title | translate }}
|
||||
</ion-button>
|
||||
<!-- Remove this once Ionic fixes this bug: https://github.com/ionic-team/ionic-framework/issues/19368 -->
|
||||
<input type="submit" class="core-submit-hidden-enter" />
|
||||
</ion-list>
|
||||
</form>
|
||||
</core-loading>
|
||||
</ion-content>
|
|
@ -0,0 +1,138 @@
|
|||
// (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, ViewChild, ElementRef, Input, Type } from '@angular/core';
|
||||
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||
import { IonContent } from '@ionic/angular';
|
||||
import { CoreSites } from '@services/sites';
|
||||
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { ModalController, Translate } from '@singletons';
|
||||
import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate';
|
||||
import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '../../services/quiz';
|
||||
|
||||
/**
|
||||
* Modal that renders the access rules for a quiz.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'page-addon-mod-quiz-preflight-modal',
|
||||
templateUrl: 'preflight-modal.html',
|
||||
})
|
||||
export class AddonModQuizPreflightModalComponent implements OnInit {
|
||||
|
||||
@ViewChild(IonContent) content?: IonContent;
|
||||
@ViewChild('preflightFormEl') formElement?: ElementRef;
|
||||
|
||||
@Input() title!: string;
|
||||
@Input() quiz?: AddonModQuizQuizWSData;
|
||||
@Input() attempt?: AddonModQuizAttemptWSData;
|
||||
@Input() prefetch?: boolean;
|
||||
@Input() siteId!: string;
|
||||
@Input() rules!: string[];
|
||||
|
||||
preflightForm: FormGroup;
|
||||
accessRulesData: { component: Type<unknown>; data: Record<string, unknown>}[] = []; // Component and data for each access rule.
|
||||
loaded = false;
|
||||
|
||||
constructor(
|
||||
formBuilder: FormBuilder,
|
||||
) {
|
||||
// Create an empty form group. The controls will be added by the access rules components.
|
||||
this.preflightForm = formBuilder.group({});
|
||||
}
|
||||
|
||||
/**
|
||||
* Component being initialized.
|
||||
*/
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.title = this.title || Translate.instance.instant('addon.mod_quiz.startattempt');
|
||||
this.siteId = this.siteId || CoreSites.instance.getCurrentSiteId();
|
||||
this.rules = this.rules || [];
|
||||
|
||||
if (!this.quiz) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(this.rules.map(async (rule) => {
|
||||
// Check if preflight is required for rule and, if so, get the component to render it.
|
||||
const required = await AddonModQuizAccessRuleDelegate.instance.isPreflightCheckRequiredForRule(
|
||||
rule,
|
||||
this.quiz!,
|
||||
this.attempt,
|
||||
this.prefetch,
|
||||
this.siteId,
|
||||
);
|
||||
|
||||
if (!required) {
|
||||
return;
|
||||
}
|
||||
|
||||
const component = await AddonModQuizAccessRuleDelegate.instance.getPreflightComponent(rule);
|
||||
if (!component) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.accessRulesData.push({
|
||||
component: component,
|
||||
data: {
|
||||
rule: rule,
|
||||
quiz: this.quiz,
|
||||
attempt: this.attempt,
|
||||
prefetch: this.prefetch,
|
||||
form: this.preflightForm,
|
||||
siteId: this.siteId,
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'Error loading rules');
|
||||
} finally {
|
||||
this.loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the data is valid and send it back.
|
||||
*
|
||||
* @param e Event.
|
||||
*/
|
||||
sendData(e: Event): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!this.preflightForm.valid) {
|
||||
// Form not valid. Scroll to the first element with errors.
|
||||
if (!CoreDomUtils.instance.scrollToInputError(this.content)) {
|
||||
// Input not found, show an error modal.
|
||||
CoreDomUtils.instance.showErrorModal('core.errorinvalidform', true);
|
||||
}
|
||||
} else {
|
||||
CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, false, this.siteId);
|
||||
|
||||
ModalController.instance.dismiss(this.preflightForm.value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close modal.
|
||||
*/
|
||||
closeModal(): void {
|
||||
CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, this.siteId);
|
||||
|
||||
ModalController.instance.dismiss();
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,189 @@
|
|||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>
|
||||
<core-format-text *ngIf="quiz" [text]="quiz.name" contextLevel="module" [contextInstanceId]="quiz.coursemodule"
|
||||
[courseId]="courseId">
|
||||
</core-format-text>
|
||||
</ion-title>
|
||||
|
||||
<ion-buttons slot="end">
|
||||
<ion-button id="addon-mod_quiz-connection-error-button" [hidden]="!autoSaveError" (click)="showConnectionError($event)"
|
||||
[attr.aria-label]="'core.error' | translate">
|
||||
<ion-icon name="fas-exclamation-circle" slot="icon-only"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button *ngIf="navigation.length" [attr.aria-label]="'addon.mod_quiz.opentoc' | translate"
|
||||
(click)="openNavigation()">
|
||||
<ion-icon name="fas-bookmark" slot="icon-only"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<!-- Navigation arrows and time left. -->
|
||||
<ion-toolbar *ngIf="loaded && endTime && questions.length && !quizAborted && !showSummary" color="light" slot="fixed">
|
||||
<ion-title>
|
||||
<core-timer [endTime]="endTime" (finished)="timeUp()" [timerText]="'addon.mod_quiz.timeleft' | translate"
|
||||
[align]="'center'">
|
||||
</core-timer>
|
||||
</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button *ngIf="previousPage >= 0" (click)="changePage(previousPage)" [title]="'core.previous' | translate">
|
||||
<ion-icon name="fas-chevron-left" slot="icon-only"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button *ngIf="nextPage >= -1" (click)="changePage(nextPage)" [title]="'core.next' | translate">
|
||||
<ion-icon name="fas-chevron-right" slot="icon-only"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
|
||||
<core-loading [hideUntil]="loaded">
|
||||
<!-- Navigation arrows if there's no timer. -->
|
||||
<ion-toolbar *ngIf="!endTime && questions.length && !quizAborted && !showSummary" color="light">
|
||||
<ion-buttons slot="end">
|
||||
<ion-button *ngIf="previousPage >= 0" (click)="changePage(previousPage)" [title]="'core.previous' | translate">
|
||||
<ion-icon name="fas-chevron-left" slot="icon-only"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button *ngIf="nextPage >= -1" (click)="changePage(nextPage)" [title]="'core.next' | translate">
|
||||
<ion-icon name="fas-chevron-right" slot="icon-only"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
|
||||
<!-- Button to start attempting. -->
|
||||
<ion-button *ngIf="!attempt" expand="block" class="ion-margin" (click)="start()">
|
||||
{{ 'addon.mod_quiz.startattempt' | translate }}
|
||||
</ion-button>
|
||||
|
||||
<!-- Questions -->
|
||||
<form name="addon-mod_quiz-player-form" *ngIf="questions.length && !quizAborted && !showSummary" #quizForm>
|
||||
<div *ngFor="let question of questions">
|
||||
<ion-card id="addon-mod_quiz-question-{{question.slot}}">
|
||||
<!-- "Header" of the question. -->
|
||||
<ion-item-divider>
|
||||
<ion-label>
|
||||
<h2 *ngIf="question.number" class="inline">
|
||||
{{ 'core.question.questionno' | translate:{$a: question.number} }}
|
||||
</h2>
|
||||
<h2 *ngIf="!question.number" class="inline">{{ 'core.question.information' | translate }}</h2>
|
||||
</ion-label>
|
||||
<div *ngIf="question.status || question.readableMark" slot="end"
|
||||
class="ion-text-wrap ion-margin-horizontal addon-mod_quiz-question-note">
|
||||
<p *ngIf="question.status" class="block">{{question.status}}</p>
|
||||
<p *ngIf="question.readableMark">{{ question.readableMark }}</p>
|
||||
</div>
|
||||
</ion-item-divider>
|
||||
|
||||
<!-- Body of the question. -->
|
||||
<core-question class="ion-text-wrap" [question]="question" [component]="component"
|
||||
[componentId]="quiz!.coursemodule" [attemptId]="attempt!.id" [usageId]="attempt!.uniqueid"
|
||||
[offlineEnabled]="offline" contextLevel="module" [contextInstanceId]="quiz!.coursemodule"
|
||||
[courseId]="courseId" [preferredBehaviour]="quiz!.preferredbehaviour" [review]="false"
|
||||
(onAbort)="abortQuiz()" (buttonClicked)="behaviourButtonClicked($event)">
|
||||
</core-question>
|
||||
</ion-card>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Go to next or previous page. -->
|
||||
<ion-grid class="ion-text-wrap" *ngIf="questions.length && !quizAborted && !showSummary">
|
||||
<ion-row>
|
||||
<ion-col *ngIf="previousPage >= 0" >
|
||||
<ion-button expand="block" color="light" (click)="changePage(previousPage)">
|
||||
<ion-icon name="fas-chevron-left" slot="start"></ion-icon>
|
||||
{{ 'core.previous' | translate }}
|
||||
</ion-button>
|
||||
</ion-col>
|
||||
<ion-col *ngIf="nextPage >= -1">
|
||||
<ion-button expand="block" (click)="changePage(nextPage)">
|
||||
{{ 'core.next' | translate }}
|
||||
<ion-icon name="fas-chevron-right" slot="end"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
|
||||
<!-- Summary -->
|
||||
<ion-card *ngIf="!quizAborted && showSummary && summaryQuestions.length" class="addon-mod_quiz-table">
|
||||
<ion-card-header class="ion-text-wrap">
|
||||
<ion-card-title>{{ 'addon.mod_quiz.summaryofattempt' | translate }}</ion-card-title>
|
||||
</ion-card-header>
|
||||
|
||||
<!-- "Header" of the summary table. -->
|
||||
<ion-item class="ion-text-wrap">
|
||||
<ion-label>
|
||||
<ion-row class="ion-align-items-center">
|
||||
<ion-col size="3" class="ion-text-center ion-hide-md-down">
|
||||
<strong>{{ 'addon.mod_quiz.question' | translate }}</strong>
|
||||
</ion-col>
|
||||
<ion-col size="3" class="ion-text-center ion-hide-md-up"><strong>#</strong></ion-col>
|
||||
<ion-col size="9" class="ion-text-center">
|
||||
<strong>{{ 'addon.mod_quiz.status' | translate }}</strong>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- List of questions of the summary table. -->
|
||||
<ng-container *ngFor="let question of summaryQuestions">
|
||||
<ion-item *ngIf="question.number" (click)="changePage(question.page, false, question.slot)"
|
||||
[attr.aria-label]="'core.question.questionno' | translate:{$a: question.number}"
|
||||
[detail]="!isSequential && canReturn" [attr.button]="!isSequential && canReturn ? true : null">
|
||||
<ion-label>
|
||||
<ion-row class="ion-align-items-center">
|
||||
<ion-col size="3" class="ion-text-center">{{ question.number }}</ion-col>
|
||||
<ion-col size="9" class="ion-text-center ion-text-wrap">{{ question.status }}</ion-col>
|
||||
</ion-row>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
|
||||
<!-- Button to return to last page seen. -->
|
||||
<ion-button *ngIf="canReturn" expand="block" class="ion-margin" (click)="changePage(attempt!.currentpage!)">
|
||||
{{ 'addon.mod_quiz.returnattempt' | translate }}
|
||||
</ion-button>
|
||||
|
||||
<!-- Due date warning. -->
|
||||
<ion-item class="ion-text-wrap" *ngIf="dueDateWarning">
|
||||
<ion-label>{{ dueDateWarning }}</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- Time left (if quiz is timed). -->
|
||||
<core-timer *ngIf="endTime" [endTime]="endTime" (finished)="timeUp()"
|
||||
[timerText]="'addon.mod_quiz.timeleft' | translate">
|
||||
</core-timer>
|
||||
|
||||
<!-- List of messages explaining why the quiz cannot be submitted. -->
|
||||
<ion-item class="ion-text-wrap" *ngIf="preventSubmitMessages.length">
|
||||
<ion-label>
|
||||
<h3 class="item-heading">{{ 'addon.mod_quiz.cannotsubmitquizdueto' | translate }}</h3>
|
||||
<p *ngFor="let message of preventSubmitMessages">{{message}}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-button *ngIf="preventSubmitMessages.length" expand="block" [href]="moduleUrl" core-link>
|
||||
{{ 'core.openinbrowser' | translate }}
|
||||
<ion-icon name="fas-external-link-alt" slot="end"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
<!-- Button to submit the quiz. -->
|
||||
<ion-button *ngIf="!attempt!.finishedOffline && !preventSubmitMessages.length" expand="block"
|
||||
class="ion-margin" (click)="finishAttempt(true)">
|
||||
{{ 'addon.mod_quiz.submitallandfinish' | translate }}
|
||||
</ion-button>
|
||||
</ion-card>
|
||||
|
||||
<!-- Quiz aborted -->
|
||||
<ion-card *ngIf="attempt && ((!questions.length && !showSummary) || quizAborted)">
|
||||
<ion-item class="ion-text-wrap">
|
||||
<ion-label>{{ 'addon.mod_quiz.errorparsequestions' | translate }}</ion-label>
|
||||
</ion-item>
|
||||
<ion-button expand="block" class="ion-margin" [href]="moduleUrl" core-link>
|
||||
{{ 'core.openinbrowser' | translate }}
|
||||
<ion-icon name="fas-external-link-alt" slot="end"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-card>
|
||||
</core-loading>
|
||||
</ion-content>
|
|
@ -0,0 +1,42 @@
|
|||
// (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 { CoreSharedModule } from '@/core/shared.module';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
|
||||
import { CoreQuestionComponentsModule } from '@features/question/components/components.module';
|
||||
import { CanLeaveGuard } from '@guards/can-leave';
|
||||
import { AddonModQuizPlayerPage } from './player';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AddonModQuizPlayerPage,
|
||||
canDeactivate: [CanLeaveGuard],
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild(routes),
|
||||
CoreSharedModule,
|
||||
CoreQuestionComponentsModule,
|
||||
],
|
||||
declarations: [
|
||||
AddonModQuizPlayerPage,
|
||||
],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AddonModQuizPlayerPageModule {}
|
|
@ -0,0 +1,10 @@
|
|||
:host {
|
||||
.addon-mod_quiz-question-note p {
|
||||
margin-top: 2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
ion-toolbar {
|
||||
border-bottom: 1px solid var(--gray);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,782 @@
|
|||
// (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 { CoreTimeUtils } from '@services/utils/time';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { ModalController, Translate } from '@singletons';
|
||||
import { CoreEventActivityDataSentData, CoreEvents } from '@singletons/events';
|
||||
import { AddonModQuizAutoSave } from '../../classes/auto-save';
|
||||
import {
|
||||
AddonModQuizNavigationModalComponent,
|
||||
AddonModQuizNavigationQuestion,
|
||||
} from '../../components/navigation-modal/navigation-modal';
|
||||
import {
|
||||
AddonModQuiz,
|
||||
AddonModQuizAttemptFinishedData,
|
||||
AddonModQuizAttemptWSData,
|
||||
AddonModQuizGetAttemptAccessInformationWSResponse,
|
||||
AddonModQuizGetQuizAccessInformationWSResponse,
|
||||
AddonModQuizProvider,
|
||||
AddonModQuizQuizWSData,
|
||||
} from '../../services/quiz';
|
||||
import { AddonModQuizAttempt, AddonModQuizHelper } from '../../services/quiz-helper';
|
||||
import { AddonModQuizSync } from '../../services/quiz-sync';
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
||||
@ViewChild(IonContent) content?: IonContent;
|
||||
@ViewChildren(CoreQuestionComponent) questionComponents?: QueryList<CoreQuestionComponent>;
|
||||
@ViewChild('quizForm') formElement?: ElementRef;
|
||||
|
||||
quiz?: AddonModQuizQuizWSData; // The quiz the attempt belongs to.
|
||||
attempt?: AddonModQuizAttempt; // The attempt being attempted.
|
||||
moduleUrl?: string; // URL to the module in the site.
|
||||
component = AddonModQuizProvider.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.
|
||||
|
||||
protected quizId!: number; // Quiz ID to attempt.
|
||||
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?: AddonModQuizAttemptWSData; // 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,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Component being initialized.
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.quizId = CoreNavigator.instance.getRouteNumberParam('quizId')!;
|
||||
this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!;
|
||||
this.moduleUrl = CoreNavigator.instance.getRouteParam('moduleUrl');
|
||||
|
||||
// Block the quiz so it cannot be synced.
|
||||
CoreSync.instance.blockOperation(AddonModQuizProvider.COMPONENT, this.quizId);
|
||||
|
||||
// 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();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Component being destroyed.
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
// Stop auto save.
|
||||
this.autoSave.cancelAutoSave();
|
||||
this.autoSave.stopCheckChangesProcess();
|
||||
this.autoSaveErrorSubscription?.unsubscribe();
|
||||
|
||||
// Unblock the quiz so it can be synced.
|
||||
CoreSync.instance.unblockOperation(AddonModQuizProvider.COMPONENT, this.quizId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we can leave the page or not.
|
||||
*
|
||||
* @return Resolved if we can leave it, rejected if not.
|
||||
*/
|
||||
async ionViewCanLeave(): Promise<void> {
|
||||
if (this.forceLeave || this.quizAborted || !this.questions.length || this.showSummary) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save answers.
|
||||
const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true);
|
||||
|
||||
try {
|
||||
await this.processAttempt(false, false);
|
||||
} catch (error) {
|
||||
// Save attempt failed. Show confirmation.
|
||||
modal.dismiss();
|
||||
|
||||
await CoreDomUtils.instance.showConfirm(Translate.instance.instant('addon.mod_quiz.confirmleavequizonerror'));
|
||||
|
||||
CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId());
|
||||
} finally {
|
||||
modal.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.instance.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> {
|
||||
let modal: CoreIonLoadingElement | undefined;
|
||||
|
||||
try {
|
||||
// Confirm that the user really wants to do it.
|
||||
await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.areyousure'));
|
||||
|
||||
modal = await CoreDomUtils.instance.showModalLoading('core.sending', true);
|
||||
|
||||
// Get the answers.
|
||||
const answers = await this.prepareAnswers();
|
||||
|
||||
// Add the clicked button data.
|
||||
answers[button.name] = button.value;
|
||||
|
||||
// Behaviour checks are always in online.
|
||||
await AddonModQuiz.instance.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;
|
||||
const scrollLeft = scrollElement?.scrollLeft || -1;
|
||||
|
||||
this.loaded = false;
|
||||
this.content?.scrollToTop(); // Scroll top so the spinner is seen.
|
||||
|
||||
try {
|
||||
await this.loadPage(this.attempt!.currentpage!);
|
||||
} finally {
|
||||
this.loaded = true;
|
||||
if (scrollTop != -1 && scrollLeft != -1) {
|
||||
this.content?.scrollToPoint(scrollLeft, scrollTop);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.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.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async changePage(page: number, fromModal?: boolean, slot?: number): Promise<void> {
|
||||
if (!this.attempt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (page != -1 && (this.attempt.state == AddonModQuizProvider.ATTEMPT_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 && typeof slot != 'undefined') {
|
||||
// Navigating to a question in the current page.
|
||||
this.scrollToQuestion(slot);
|
||||
|
||||
return;
|
||||
} else if ((page == this.attempt.currentpage && !this.showSummary) || (fromModal && this.isSequential && page != -1)) {
|
||||
// If the user is navigating to the current page we do nothing.
|
||||
// Also, in sequential quizzes we don't allow navigating using the modal except for finishing the quiz (summary).
|
||||
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.instance.showModalLoading('core.sending', true);
|
||||
|
||||
try {
|
||||
await this.processAttempt(false, false);
|
||||
|
||||
modal.dismiss();
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.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.autoSave.startCheckChangesProcess(this.quiz!, this.attempt, this.preflightData, this.offline);
|
||||
}
|
||||
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquestions', true);
|
||||
} finally {
|
||||
this.loaded = true;
|
||||
|
||||
if (typeof slot != 'undefined') {
|
||||
// Scroll to the question. Give some time to the questions to render.
|
||||
setTimeout(() => {
|
||||
this.scrollToQuestion(slot);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to get the quiz data.
|
||||
*
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async fetchData(): Promise<void> {
|
||||
try {
|
||||
// Wait for any ongoing sync to finish. We won't sync a quiz while it's being played.
|
||||
await AddonModQuizSync.instance.waitForSync(this.quizId);
|
||||
|
||||
// Sync finished, now get the quiz.
|
||||
this.quiz = await AddonModQuiz.instance.getQuizById(this.courseId, this.quizId);
|
||||
|
||||
this.isSequential = AddonModQuiz.instance.isNavigationSequential(this.quiz);
|
||||
|
||||
if (AddonModQuiz.instance.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.instance.isLastAttemptOfflineUnfinished(this.quiz);
|
||||
}
|
||||
|
||||
if (this.quiz!.timelimit && this.quiz!.timelimit > 0) {
|
||||
this.readableTimeLimit = CoreTimeUtils.instance.formatTime(this.quiz.timelimit);
|
||||
}
|
||||
|
||||
// Get access information for the quiz.
|
||||
this.quizAccessInfo = await AddonModQuiz.instance.getQuizAccessInformation(this.quiz.id, {
|
||||
cmId: this.quiz.coursemodule,
|
||||
readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork,
|
||||
});
|
||||
|
||||
// Get user attempts to determine last attempt.
|
||||
const attempts = await AddonModQuiz.instance.getUserAttempts(this.quiz.id, {
|
||||
cmId: this.quiz.coursemodule,
|
||||
readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork,
|
||||
});
|
||||
|
||||
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 = await AddonModQuizHelper.instance.setAttemptCalculatedData(
|
||||
this.quiz,
|
||||
attempts[attempts.length - 1],
|
||||
false,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
this.newAttempt = AddonModQuiz.instance.isAttemptFinished(this.lastAttempt.state);
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquiz', true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async finishAttempt(userFinish?: boolean, timeUp?: boolean): Promise<void> {
|
||||
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 == AddonModQuizProvider.ATTEMPT_IN_PROGRESS) {
|
||||
await CoreDomUtils.instance.showConfirm(Translate.instance.instant('addon.mod_quiz.confirmclose'));
|
||||
}
|
||||
|
||||
modal = await CoreDomUtils.instance.showModalLoading('core.sending', true);
|
||||
|
||||
await this.processAttempt(userFinish, timeUp);
|
||||
|
||||
// Trigger an event to notify the attempt was finished.
|
||||
CoreEvents.trigger<AddonModQuizAttemptFinishedData>(AddonModQuizProvider.ATTEMPT_FINISHED_EVENT, {
|
||||
quizId: this.quizId,
|
||||
attemptId: this.attempt!.id,
|
||||
synced: !this.offline,
|
||||
}, CoreSites.instance.getCurrentSiteId());
|
||||
|
||||
CoreEvents.trigger<CoreEventActivityDataSentData>(CoreEvents.ACTIVITY_DATA_SENT, { module: 'quiz' });
|
||||
|
||||
// Leave the player.
|
||||
this.forceLeave = true;
|
||||
CoreNavigator.instance.back();
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_quiz.errorsaveattempt', true);
|
||||
} finally {
|
||||
modal?.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix sequence checks of current page.
|
||||
*
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async fixSequenceChecks(): Promise<void> {
|
||||
// Get current page data again to get the latest sequencechecks.
|
||||
const data = await AddonModQuiz.instance.getAttemptData(this.attempt!.id, this.attempt!.currentpage!, this.preflightData, {
|
||||
cmId: this.quiz!.coursemodule,
|
||||
readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork,
|
||||
});
|
||||
|
||||
const newSequenceChecks: Record<number, { name: string; value: string }> = {};
|
||||
|
||||
data.questions.forEach((question) => {
|
||||
const sequenceCheck = CoreQuestionHelper.instance.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.
|
||||
*
|
||||
* @return Object with the answers.
|
||||
*/
|
||||
protected getAnswers(): CoreQuestionsAnswers {
|
||||
return CoreQuestionHelper.instance.getAnswersFromForm(document.forms['addon-mod_quiz-player-form']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the timer if enabled.
|
||||
*/
|
||||
protected initTimer(): void {
|
||||
if (!this.attemptAccessInfo?.endtime || this.attemptAccessInfo.endtime < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Quiz has an end time. Check if time left should be shown.
|
||||
const shouldShowTime = AddonModQuiz.instance.shouldShowTimeLeft(
|
||||
this.quizAccessInfo!.activerulenames,
|
||||
this.attempt!,
|
||||
this.attemptAccessInfo.endtime,
|
||||
);
|
||||
|
||||
if (shouldShowTime) {
|
||||
this.endTime = this.attemptAccessInfo.endtime;
|
||||
} else {
|
||||
delete this.endTime;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a page questions.
|
||||
*
|
||||
* @param page The page to load.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async loadPage(page: number): Promise<void> {
|
||||
const data = await AddonModQuiz.instance.getAttemptData(this.attempt!.id, page, this.preflightData, {
|
||||
cmId: this.quiz!.coursemodule,
|
||||
readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork,
|
||||
});
|
||||
|
||||
// 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.instance.getQuestionMarkFromHtml(question.html);
|
||||
|
||||
// Extract the question info box.
|
||||
CoreQuestionHelper.instance.extractQuestionInfoBox(question, '.info');
|
||||
|
||||
// Check if the question is blocked. If it is, treat it as a description question.
|
||||
if (AddonModQuiz.instance.isQuestionBlocked(question)) {
|
||||
question.type = 'description';
|
||||
}
|
||||
});
|
||||
|
||||
// Mark the page as viewed.
|
||||
CoreUtils.instance.ignoreErrors(
|
||||
AddonModQuiz.instance.logViewAttempt(this.attempt.id, page, this.preflightData, this.offline, this.quiz),
|
||||
);
|
||||
|
||||
// Start looking for changes.
|
||||
this.autoSave.startCheckChangesProcess(this.quiz!, this.attempt, this.preflightData, this.offline);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load attempt summary.
|
||||
*
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async loadSummary(): Promise<void> {
|
||||
this.summaryQuestions = [];
|
||||
|
||||
this.summaryQuestions = await AddonModQuiz.instance.getAttemptSummary(this.attempt!.id, this.preflightData, {
|
||||
cmId: this.quiz!.coursemodule,
|
||||
loadLocal: this.offline,
|
||||
readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork,
|
||||
});
|
||||
|
||||
this.showSummary = true;
|
||||
this.canReturn = this.attempt!.state == AddonModQuizProvider.ATTEMPT_IN_PROGRESS && !this.attempt!.finishedOffline;
|
||||
this.preventSubmitMessages = AddonModQuiz.instance.getPreventSubmitMessages(this.summaryQuestions);
|
||||
|
||||
this.dueDateWarning = AddonModQuiz.instance.getAttemptDueDateWarning(this.quiz!, this.attempt!);
|
||||
|
||||
// Log summary as viewed.
|
||||
CoreUtils.instance.ignoreErrors(
|
||||
AddonModQuiz.instance.logViewAttemptSummary(this.attempt!.id, this.preflightData, this.quizId, this.quiz!.name),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load data to navigate the questions using the navigation modal.
|
||||
*
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async loadNavigation(): Promise<void> {
|
||||
// We use the attempt summary to build the navigation because it contains all the questions.
|
||||
this.navigation = await AddonModQuiz.instance.getAttemptSummary(this.attempt!.id, this.preflightData, {
|
||||
cmId: this.quiz!.coursemodule,
|
||||
loadLocal: this.offline,
|
||||
readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork,
|
||||
});
|
||||
|
||||
this.navigation.forEach((question) => {
|
||||
question.stateClass = CoreQuestionHelper.instance.getQuestionStateClass(question.state || '');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the navigation modal.
|
||||
*
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async openNavigation(): Promise<void> {
|
||||
|
||||
if (this.reloadNavigation) {
|
||||
// Some data has changed, reload the navigation.
|
||||
const modal = await CoreDomUtils.instance.showModalLoading();
|
||||
|
||||
await CoreUtils.instance.ignoreErrors(this.loadNavigation());
|
||||
|
||||
modal.dismiss();
|
||||
this.reloadNavigation = false;
|
||||
}
|
||||
|
||||
// Create the navigation modal.
|
||||
const modal = await ModalController.instance.create({
|
||||
component: AddonModQuizNavigationModalComponent,
|
||||
componentProps: {
|
||||
navigation: this.navigation,
|
||||
summaryShown: this.showSummary,
|
||||
currentPage: this.attempt?.currentpage,
|
||||
isReview: false,
|
||||
},
|
||||
cssClass: 'core-modal-lateral',
|
||||
showBackdrop: true,
|
||||
backdropDismiss: true,
|
||||
// @todo enterAnimation: 'core-modal-lateral-transition',
|
||||
// @todo leaveAnimation: 'core-modal-lateral-transition',
|
||||
});
|
||||
|
||||
await modal.present();
|
||||
|
||||
const result = await modal.onWillDismiss();
|
||||
|
||||
if (result.data && result.data.action == AddonModQuizNavigationModalComponent.CHANGE_PAGE) {
|
||||
this.changePage(result.data.page, true, result.data.slot);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the answers to be sent for the attempt.
|
||||
*
|
||||
* @return Promise resolved with the answers.
|
||||
*/
|
||||
protected prepareAnswers(): Promise<CoreQuestionsAnswers> {
|
||||
return CoreQuestionHelper.instance.prepareAnswers(
|
||||
this.questions,
|
||||
this.getAnswers(),
|
||||
this.offline,
|
||||
this.component,
|
||||
this.quiz!.coursemodule,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async processAttempt(userFinish?: boolean, timeUp?: boolean, retrying?: boolean): Promise<void> {
|
||||
// Get the answers to send.
|
||||
let answers: CoreQuestionsAnswers = {};
|
||||
|
||||
if (!this.showSummary) {
|
||||
answers = await this.prepareAnswers();
|
||||
}
|
||||
|
||||
try {
|
||||
// Send the answers.
|
||||
await AddonModQuiz.instance.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) {
|
||||
CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, !this.offline, CoreSites.instance.getCurrentSiteId());
|
||||
}
|
||||
|
||||
return CoreQuestionHelper.instance.clearTmpData(this.questions, this.component, this.quiz!.coursemodule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to a certain question.
|
||||
*
|
||||
* @param slot Slot of the question to scroll to.
|
||||
*/
|
||||
protected scrollToQuestion(slot: number): void {
|
||||
if (this.content) {
|
||||
CoreDomUtils.instance.scrollToElementBySelector(this.content, '#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();
|
||||
} finally {
|
||||
this.loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or continue an attempt.
|
||||
*
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async startOrContinueAttempt(): Promise<void> {
|
||||
try {
|
||||
let attempt = this.newAttempt ? undefined : this.lastAttempt;
|
||||
|
||||
// Get the preflight data and start attempt if needed.
|
||||
attempt = await AddonModQuizHelper.instance.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.instance.getAttemptAccessInformation(this.quiz!.id, attempt.id, {
|
||||
cmId: this.quiz!.coursemodule,
|
||||
readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork,
|
||||
});
|
||||
|
||||
this.attempt = attempt;
|
||||
|
||||
await this.loadNavigation();
|
||||
|
||||
if (this.attempt.state != AddonModQuizProvider.ATTEMPT_OVERDUE && !this.attempt.finishedOffline) {
|
||||
// Attempt not overdue and not finished in offline, load page.
|
||||
await this.loadPage(this.attempt.currentpage!);
|
||||
|
||||
this.initTimer();
|
||||
} else {
|
||||
// Attempt is overdue or finished in offline, we can only load the summary.
|
||||
await this.loadSummary();
|
||||
}
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquestions', true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quiz time has finished.
|
||||
*/
|
||||
timeUp(): void {
|
||||
if (this.timeUpCalled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timeUpCalled = true;
|
||||
this.finishAttempt(false, true);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Question with some calculated data for the view.
|
||||
*/
|
||||
type QuizQuestion = CoreQuestionQuestionParsed & {
|
||||
readableMark?: string;
|
||||
};
|
|
@ -20,6 +20,10 @@ const routes: Routes = [
|
|||
path: ':courseId/:cmdId',
|
||||
loadChildren: () => import('./pages/index/index.module').then( m => m.AddonModQuizIndexPageModule),
|
||||
},
|
||||
{
|
||||
path: 'player/:courseId/:quizId',
|
||||
loadChildren: () => import('./pages/player/player.module').then( m => m.AddonModQuizPlayerPageModule),
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
</ion-item>
|
||||
|
||||
<!-- Edit. -->
|
||||
<ion-item *ngIf="edit && field && field.shortname && form" text-wrap [formGroup]="form">
|
||||
<ion-item *ngIf="edit && field && field.shortname && form" class="ion-text-wrap" [formGroup]="form">
|
||||
<ion-label position="stacked">
|
||||
<span [core-mark-required]="required">{{ field.name }}</span>
|
||||
</ion-label>
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
</ion-item>
|
||||
|
||||
<!-- Edit. -->
|
||||
<ion-item *ngIf="edit && field && field.shortname && form" text-wrap [formGroup]="form">
|
||||
<ion-item *ngIf="edit && field && field.shortname && form" class="ion-text-wrap" [formGroup]="form">
|
||||
<ion-label position="stacked">
|
||||
<span [core-mark-required]="required">{{ field.name }}</span>
|
||||
</ion-label>
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
</ion-item>
|
||||
|
||||
<!-- Edit. -->
|
||||
<ion-item *ngIf="edit && field && field.shortname && form" text-wrap [formGroup]="form">
|
||||
<ion-item *ngIf="edit && field && field.shortname && form" class="ion-text-wrap" [formGroup]="form">
|
||||
<ion-label position="stacked">
|
||||
<span [core-mark-required]="required">{{ field.name }}</span>
|
||||
</ion-label>
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
</ion-item>
|
||||
|
||||
<!-- Edit. -->
|
||||
<ion-item *ngIf="edit && field && field.shortname" text-wrap [formGroup]="form">
|
||||
<ion-item *ngIf="edit && field && field.shortname" class="ion-text-wrap" [formGroup]="form">
|
||||
<ion-label position="stacked">
|
||||
<span [core-mark-required]="required">{{ field.name }}</span>
|
||||
<core-input-errors [control]="control"></core-input-errors>
|
||||
|
|
|
@ -69,13 +69,16 @@ export class CoreSite {
|
|||
|
||||
// Versions of Moodle releases.
|
||||
protected readonly MOODLE_RELEASES = {
|
||||
3.1: 2016052300,
|
||||
3.2: 2016120500,
|
||||
3.3: 2017051503,
|
||||
3.4: 2017111300,
|
||||
3.5: 2018051700,
|
||||
3.6: 2018120300,
|
||||
3.7: 2019052000,
|
||||
'3.1': 2016052300,
|
||||
'3.2': 2016120500,
|
||||
'3.3': 2017051503,
|
||||
'3.4': 2017111300,
|
||||
'3.5': 2018051700,
|
||||
'3.6': 2018120300,
|
||||
'3.7': 2019052000,
|
||||
'3.8': 2019111800,
|
||||
'3.9': 2020061500,
|
||||
'3.10': 2020110900,
|
||||
};
|
||||
|
||||
// Possible cache update frequencies.
|
||||
|
|
|
@ -32,7 +32,7 @@ import { Translate } from '@singletons';
|
|||
*
|
||||
* Example usage:
|
||||
*
|
||||
* <ion-item text-wrap>
|
||||
* <ion-item class="ion-text-wrap">
|
||||
* <ion-label stacked core-mark-required="true">{{ 'core.login.username' | translate }}</ion-label>
|
||||
* <ion-input type="text" name="username" formControlName="username"></ion-input>
|
||||
* <core-input-errors item-content [control]="myForm.controls.username" [errorMessages]="usernameErrors"></core-input-errors>
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
<ion-content class="ion-padding">
|
||||
<form (ngSubmit)="submitPassword($event)" #enrolPasswordForm>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<ion-label></ion-label>
|
||||
<core-show-password name="password">
|
||||
<ion-input
|
||||
class="ion-text-wrap core-ioninput-password"
|
||||
|
@ -27,7 +27,6 @@
|
|||
[clearOnEdit]="false">
|
||||
</ion-input>
|
||||
</core-show-password>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<div class="ion-padding">
|
||||
<ion-button expand="block" [disabled]="!password" type="submit">{{ 'core.courses.enrolme' | translate }}</ion-button>
|
||||
|
|
|
@ -34,6 +34,7 @@ import { CoreFileUploaderDelegate } from './fileuploader-delegate';
|
|||
import { CoreCaptureError } from '@classes/errors/captureerror';
|
||||
import { CoreIonLoadingElement } from '@classes/ion-loading';
|
||||
import { CoreWSUploadFileResult } from '@services/ws';
|
||||
import { CoreSites } from '@services/sites';
|
||||
|
||||
/**
|
||||
* Helper service to upload files.
|
||||
|
@ -738,6 +739,14 @@ export class CoreFileUploaderHelperProvider {
|
|||
allowOffline?: boolean,
|
||||
name?: string,
|
||||
): Promise<CoreWSUploadFileResult | FileEntry> {
|
||||
if (maxSize === 0) {
|
||||
const siteInfo = CoreSites.instance.getCurrentSite()?.getInfo();
|
||||
|
||||
if (siteInfo && siteInfo.usermaxuploadfilesize) {
|
||||
maxSize = siteInfo.usermaxuploadfilesize;
|
||||
}
|
||||
}
|
||||
|
||||
if (maxSize !== undefined && maxSize != -1 && file.size > maxSize) {
|
||||
throw this.createMaxBytesError(maxSize, file.name);
|
||||
}
|
||||
|
|
|
@ -38,14 +38,13 @@
|
|||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item *ngIf="siteChecked && !isBrowserSSO" class="ion-margin-bottom">
|
||||
<ion-label>
|
||||
<ion-label></ion-label>
|
||||
<core-show-password name="password">
|
||||
<ion-input name="password" type="password" placeholder="{{ 'core.login.password' | translate }}"
|
||||
formControlName="password" [clearOnEdit]="false" autocomplete="current-password" enterkeyhint="go"
|
||||
required="true">
|
||||
</ion-input>
|
||||
</core-show-password>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-button expand="block" type="submit" [disabled]="siteChecked && !isBrowserSSO && !credForm.valid"
|
||||
class="ion-margin core-login-login-button">
|
||||
|
|
|
@ -39,14 +39,13 @@
|
|||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-margin-bottom">
|
||||
<ion-label>
|
||||
<ion-label></ion-label>
|
||||
<core-show-password name="password">
|
||||
<ion-input class="core-ioninput-password" name="password" type="password"
|
||||
placeholder="{{ 'core.login.password' | translate }}" formControlName="password" [clearOnEdit]="false"
|
||||
autocomplete="current-password" enterkeyhint="go" required="true">
|
||||
</ion-input>
|
||||
</core-show-password>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-grid class="ion-padding">
|
||||
<ion-row>
|
||||
|
|
|
@ -53,7 +53,7 @@
|
|||
<ion-icon name="fas-pen"></ion-icon>
|
||||
</ion-thumbnail>
|
||||
<ion-label>
|
||||
<h2 text-wrap>{{ 'core.login.yourenteredsite' | translate }}</h2>
|
||||
<h2 class="ion-text-wrap">{{ 'core.login.yourenteredsite' | translate }}</h2>
|
||||
<p>{{enteredSiteUrl.noProtocolUrl}}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
@ -75,7 +75,7 @@
|
|||
|
||||
<ion-item *ngIf="siteSelector == 'url'" lines="none">
|
||||
<ion-label>
|
||||
<ion-button expand="block" [disabled]="!siteForm.valid" text-wrap>
|
||||
<ion-button expand="block" [disabled]="!siteForm.valid" class="ion-text-wrap">
|
||||
{{ 'core.login.connect' | translate }}
|
||||
</ion-button>
|
||||
</ion-label>
|
||||
|
@ -123,7 +123,7 @@
|
|||
<img src="assets/icon/icon.png" *ngIf="!site.imageurl" class="core-login-default-icon">
|
||||
</ion-thumbnail>
|
||||
<ion-label>
|
||||
<h2 *ngIf="site.title" text-wrap>{{site.title}}</h2>
|
||||
<h2 *ngIf="site.title" class="ion-text-wrap">{{site.title}}</h2>
|
||||
<p *ngIf="site.noProtocolUrl">{{site.noProtocolUrl}}</p>
|
||||
<p *ngIf="site.location">{{site.location}}</p>
|
||||
</ion-label>
|
||||
|
|
|
@ -143,6 +143,7 @@
|
|||
"loadmore": "Load more",
|
||||
"location": "Location",
|
||||
"lostconnection": "Your authentication token is invalid or has expired. You will have to reconnect to the site.",
|
||||
"maxfilesize": "Maximum size for new files: {{$a}}",
|
||||
"maxsizeandattachments": "Maximum file size: {{$a.size}}, maximum number of files: {{$a.attachments}}",
|
||||
"min": "min",
|
||||
"mins": "mins",
|
||||
|
|
|
@ -2838,10 +2838,9 @@ export class CoreFilepoolProvider {
|
|||
let previousStatus: string | undefined;
|
||||
// Search current status to set it as previous status.
|
||||
try {
|
||||
const entry = <CoreFilepoolPackageEntry> site.getDb().getRecord(PACKAGES_TABLE_NAME, { id: packageId });
|
||||
if (typeof extra == 'undefined' || extra === null) {
|
||||
extra = entry.extra;
|
||||
}
|
||||
const entry = await site.getDb().getRecord<CoreFilepoolPackageEntry>(PACKAGES_TABLE_NAME, { id: packageId });
|
||||
|
||||
extra = extra ?? entry.extra;
|
||||
if (typeof downloadTime == 'undefined') {
|
||||
// Keep previous download time.
|
||||
downloadTime = entry.downloadTime;
|
||||
|
|
|
@ -141,6 +141,8 @@ $colors-dark: (
|
|||
|
||||
$core-course-image-background: #81ecec, #74b9ff, #a29bfe, #dfe6e9, #00b894, #0984e3, #b2bec3, #fdcb6e, #fd79a8, #6c5ce7 !default;
|
||||
|
||||
$core-dd-question-colors: $white, $blue-light, #DCDCDC, #D8BFD8, #87CEFA, #DAA520, #FFD700, #F0E68C !default;
|
||||
|
||||
/*
|
||||
* Layout Breakpoints
|
||||
*
|
||||
|
|
|
@ -432,3 +432,8 @@ ion-button.core-button-select {
|
|||
font-weight: normal;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
// Monospaced font.
|
||||
.core-monospaced {
|
||||
font-family: Andale Mono,Monaco,Courier New,DejaVu Sans Mono,monospace;
|
||||
}
|
||||
|
|
|
@ -38,7 +38,6 @@
|
|||
--yellow: #{$yellow};
|
||||
--yellow-light: #{$yellow-light};
|
||||
--yellow-dark: #{$yellow-dark};
|
||||
|
||||
--purple: #{$purple};
|
||||
|
||||
--core-color: #{$core-color};
|
||||
|
@ -188,4 +187,23 @@
|
|||
|
||||
--core-menu-box-shadow-end: var(--custom-menu-box-shadow-end, -4px 0px 16px rgba(0, 0, 0, 0.18));
|
||||
--core-menu-box-shadow-start: var(--custom-menu-box-shadow-start, 4px 0px 16px rgba(0, 0, 0, 0.18));
|
||||
|
||||
--core-question-correct-color: var(--green-dark);
|
||||
--core-question-correct-color-bg: var(--green-light);
|
||||
--core-question-incorrect-color: var(--red);
|
||||
--core-question-incorrect-color-bg: var(--red-light);
|
||||
--core-question-feedback-color: var(--yellow-dark);
|
||||
--core-question-feedback-color-bg: var(--yellow-light);
|
||||
--core-question-warning-color: var(--red);
|
||||
--core-question-saved-color-bg: var(--gray-light);
|
||||
|
||||
--core-question-state-correct-color: var(--green-light);
|
||||
--core-question-state-partial-color: var(--yellow-light);
|
||||
--core-question-state-partial-text: var(--yellow);
|
||||
--core-question-state-incorrect-color: var(--red-light);
|
||||
|
||||
--core-question-feedback-color: var(--yellow-dark);
|
||||
--core-question-feedback-background-color: var(--yellow-light);
|
||||
|
||||
--core-dd-question-selected-shadow: 2px 2px 4px var(--gray-dark);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue