MOBILE-3651 quiz: Implement player page

main
Dani Palou 2021-02-18 09:19:38 +01:00
parent 916dc14401
commit 1c443b183b
33 changed files with 1735 additions and 59 deletions

View File

@ -99,7 +99,7 @@
<ng-container *ngSwitchCase="'multichoice'"> <ng-container *ngSwitchCase="'multichoice'">
<!-- Single choice. --> <!-- Single choice. -->
<ion-radio-group *ngIf="!question.multi" [formControlName]="question.controlName"> <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> <ion-label>
<core-format-text [component]="component" [componentId]="lesson.coursemodule" <core-format-text [component]="component" [componentId]="lesson.coursemodule"
[text]="option.text" contextLevel="module" [contextInstanceId]="lesson?.coursemodule" [text]="option.text" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
@ -113,7 +113,7 @@
<!-- Multiple choice. --> <!-- Multiple choice. -->
<ng-container *ngIf="question.multi"> <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> <ion-label>
<core-format-text [component]="component" [componentId]="lesson?.coursemodule" <core-format-text [component]="component" [componentId]="lesson?.coursemodule"
[text]="option.text" contextLevel="module" [contextInstanceId]="lesson?.coursemodule" [text]="option.text" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"

View File

@ -27,7 +27,7 @@ import { CoreUrlUtils } from '@services/utils/url';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { CoreWSExternalFile } from '@services/ws'; import { CoreWSExternalFile } from '@services/ws';
import { ModalController, Translate } from '@singletons'; 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 { AddonModLessonMenuModalPage } from '../../components/menu-modal/menu-modal';
import { import {
AddonModLesson, AddonModLesson,
@ -409,7 +409,7 @@ export class AddonModLessonPlayerPage implements OnInit, OnDestroy, CanLeave {
this.messages = this.messages.concat(data.messages); this.messages = this.messages.concat(data.messages);
this.processData = undefined; 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. // Format activity link if present.
if (this.eolData.activitylink) { if (this.eolData.activitylink) {

View File

@ -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;
}
}

View File

@ -18,11 +18,15 @@ import { CoreSharedModule } from '@/core/shared.module';
import { CoreCourseComponentsModule } from '@features/course/components/components.module'; import { CoreCourseComponentsModule } from '@features/course/components/components.module';
import { AddonModQuizConnectionErrorComponent } from './connection-error/connection-error'; import { AddonModQuizConnectionErrorComponent } from './connection-error/connection-error';
import { AddonModQuizIndexComponent } from './index/index'; import { AddonModQuizIndexComponent } from './index/index';
import { AddonModQuizNavigationModalComponent } from './navigation-modal/navigation-modal';
import { AddonModQuizPreflightModalComponent } from './preflight-modal/preflight-modal';
@NgModule({ @NgModule({
declarations: [ declarations: [
AddonModQuizIndexComponent, AddonModQuizIndexComponent,
AddonModQuizConnectionErrorComponent, AddonModQuizConnectionErrorComponent,
AddonModQuizNavigationModalComponent,
AddonModQuizPreflightModalComponent,
], ],
imports: [ imports: [
CoreSharedModule, CoreSharedModule,
@ -33,6 +37,8 @@ import { AddonModQuizIndexComponent } from './index/index';
exports: [ exports: [
AddonModQuizIndexComponent, AddonModQuizIndexComponent,
AddonModQuizConnectionErrorComponent, AddonModQuizConnectionErrorComponent,
AddonModQuizNavigationModalComponent,
AddonModQuizPreflightModalComponent,
], ],
}) })
export class AddonModQuizComponentsModule {} export class AddonModQuizComponentsModule {}

View File

@ -0,0 +1,3 @@
<ion-item class="ion-text-wrap">
<ion-label>{{ "addon.mod_quiz.connectionerror" | translate }}</ion-label>
</ion-item>

View File

@ -0,0 +1,7 @@
:host {
background-color: var(--red-light);
.item {
--background: var(--red-light);
}
}

View File

@ -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 {
}

View File

@ -21,6 +21,7 @@ import { CoreCourse } from '@features/course/services/course';
import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate';
import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate'; import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate';
import { IonContent } from '@ionic/angular'; import { IonContent } from '@ionic/angular';
import { CoreNavigator } from '@services/navigator';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text'; import { CoreTextUtils } from '@services/utils/text';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
@ -464,7 +465,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
this.content?.scrollToTop(); this.content?.scrollToTop();
await promise; await promise;
await CoreUtils.instance.ignoreErrors(this.refreshContent()); await CoreUtils.instance.ignoreErrors(this.refreshContent(true));
this.loaded = true; this.loaded = true;
this.refreshIcon = CoreConstants.ICON_REFRESH; this.refreshIcon = CoreConstants.ICON_REFRESH;
@ -533,7 +534,11 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
protected openQuiz(): void { protected openQuiz(): void {
this.hasPlayed = true; 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,
},
});
} }
/** /**

View File

@ -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>

View File

@ -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;
};

View File

@ -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>

View File

@ -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();
}
}

View File

@ -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>

View File

@ -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 {}

View File

@ -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);
}
}

View File

@ -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;
};

View File

@ -20,6 +20,10 @@ const routes: Routes = [
path: ':courseId/:cmdId', path: ':courseId/:cmdId',
loadChildren: () => import('./pages/index/index.module').then( m => m.AddonModQuizIndexPageModule), 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({ @NgModule({

View File

@ -7,7 +7,7 @@
</ion-item> </ion-item>
<!-- Edit. --> <!-- 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"> <ion-label position="stacked">
<span [core-mark-required]="required">{{ field.name }}</span> <span [core-mark-required]="required">{{ field.name }}</span>
</ion-label> </ion-label>

View File

@ -9,7 +9,7 @@
</ion-item> </ion-item>
<!-- Edit. --> <!-- 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"> <ion-label position="stacked">
<span [core-mark-required]="required">{{ field.name }}</span> <span [core-mark-required]="required">{{ field.name }}</span>
</ion-label> </ion-label>

View File

@ -9,7 +9,7 @@
</ion-item> </ion-item>
<!-- Edit. --> <!-- 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"> <ion-label position="stacked">
<span [core-mark-required]="required">{{ field.name }}</span> <span [core-mark-required]="required">{{ field.name }}</span>
</ion-label> </ion-label>

View File

@ -9,7 +9,7 @@
</ion-item> </ion-item>
<!-- Edit. --> <!-- 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"> <ion-label position="stacked">
<span [core-mark-required]="required">{{ field.name }}</span> <span [core-mark-required]="required">{{ field.name }}</span>
<core-input-errors [control]="control"></core-input-errors> <core-input-errors [control]="control"></core-input-errors>

View File

@ -69,13 +69,16 @@ export class CoreSite {
// Versions of Moodle releases. // Versions of Moodle releases.
protected readonly MOODLE_RELEASES = { protected readonly MOODLE_RELEASES = {
3.1: 2016052300, '3.1': 2016052300,
3.2: 2016120500, '3.2': 2016120500,
3.3: 2017051503, '3.3': 2017051503,
3.4: 2017111300, '3.4': 2017111300,
3.5: 2018051700, '3.5': 2018051700,
3.6: 2018120300, '3.6': 2018120300,
3.7: 2019052000, '3.7': 2019052000,
'3.8': 2019111800,
'3.9': 2020061500,
'3.10': 2020110900,
}; };
// Possible cache update frequencies. // Possible cache update frequencies.

View File

@ -32,7 +32,7 @@ import { Translate } from '@singletons';
* *
* Example usage: * 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-label stacked core-mark-required="true">{{ 'core.login.username' | translate }}</ion-label>
* <ion-input type="text" name="username" formControlName="username"></ion-input> * <ion-input type="text" name="username" formControlName="username"></ion-input>
* <core-input-errors item-content [control]="myForm.controls.username" [errorMessages]="usernameErrors"></core-input-errors> * <core-input-errors item-content [control]="myForm.controls.username" [errorMessages]="usernameErrors"></core-input-errors>

View File

@ -15,19 +15,18 @@
<ion-content class="ion-padding"> <ion-content class="ion-padding">
<form (ngSubmit)="submitPassword($event)" #enrolPasswordForm> <form (ngSubmit)="submitPassword($event)" #enrolPasswordForm>
<ion-item> <ion-item>
<ion-label> <ion-label></ion-label>
<core-show-password name="password"> <core-show-password name="password">
<ion-input <ion-input
class="ion-text-wrap core-ioninput-password" class="ion-text-wrap core-ioninput-password"
name="password" name="password"
type="password" type="password"
placeholder="{{ 'core.courses.password' | translate }}" placeholder="{{ 'core.courses.password' | translate }}"
[(ngModel)]="password" [(ngModel)]="password"
[core-auto-focus] [core-auto-focus]
[clearOnEdit]="false"> [clearOnEdit]="false">
</ion-input> </ion-input>
</core-show-password> </core-show-password>
</ion-label>
</ion-item> </ion-item>
<div class="ion-padding"> <div class="ion-padding">
<ion-button expand="block" [disabled]="!password" type="submit">{{ 'core.courses.enrolme' | translate }}</ion-button> <ion-button expand="block" [disabled]="!password" type="submit">{{ 'core.courses.enrolme' | translate }}</ion-button>

View File

@ -34,6 +34,7 @@ import { CoreFileUploaderDelegate } from './fileuploader-delegate';
import { CoreCaptureError } from '@classes/errors/captureerror'; import { CoreCaptureError } from '@classes/errors/captureerror';
import { CoreIonLoadingElement } from '@classes/ion-loading'; import { CoreIonLoadingElement } from '@classes/ion-loading';
import { CoreWSUploadFileResult } from '@services/ws'; import { CoreWSUploadFileResult } from '@services/ws';
import { CoreSites } from '@services/sites';
/** /**
* Helper service to upload files. * Helper service to upload files.
@ -738,6 +739,14 @@ export class CoreFileUploaderHelperProvider {
allowOffline?: boolean, allowOffline?: boolean,
name?: string, name?: string,
): Promise<CoreWSUploadFileResult | FileEntry> { ): 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) { if (maxSize !== undefined && maxSize != -1 && file.size > maxSize) {
throw this.createMaxBytesError(maxSize, file.name); throw this.createMaxBytesError(maxSize, file.name);
} }

View File

@ -38,14 +38,13 @@
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item *ngIf="siteChecked && !isBrowserSSO" class="ion-margin-bottom"> <ion-item *ngIf="siteChecked && !isBrowserSSO" class="ion-margin-bottom">
<ion-label> <ion-label></ion-label>
<core-show-password name="password"> <core-show-password name="password">
<ion-input name="password" type="password" placeholder="{{ 'core.login.password' | translate }}" <ion-input name="password" type="password" placeholder="{{ 'core.login.password' | translate }}"
formControlName="password" [clearOnEdit]="false" autocomplete="current-password" enterkeyhint="go" formControlName="password" [clearOnEdit]="false" autocomplete="current-password" enterkeyhint="go"
required="true"> required="true">
</ion-input> </ion-input>
</core-show-password> </core-show-password>
</ion-label>
</ion-item> </ion-item>
<ion-button expand="block" type="submit" [disabled]="siteChecked && !isBrowserSSO && !credForm.valid" <ion-button expand="block" type="submit" [disabled]="siteChecked && !isBrowserSSO && !credForm.valid"
class="ion-margin core-login-login-button"> class="ion-margin core-login-login-button">

View File

@ -39,14 +39,13 @@
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-margin-bottom"> <ion-item class="ion-margin-bottom">
<ion-label> <ion-label></ion-label>
<core-show-password name="password"> <core-show-password name="password">
<ion-input class="core-ioninput-password" name="password" type="password" <ion-input class="core-ioninput-password" name="password" type="password"
placeholder="{{ 'core.login.password' | translate }}" formControlName="password" [clearOnEdit]="false" placeholder="{{ 'core.login.password' | translate }}" formControlName="password" [clearOnEdit]="false"
autocomplete="current-password" enterkeyhint="go" required="true"> autocomplete="current-password" enterkeyhint="go" required="true">
</ion-input> </ion-input>
</core-show-password> </core-show-password>
</ion-label>
</ion-item> </ion-item>
<ion-grid class="ion-padding"> <ion-grid class="ion-padding">
<ion-row> <ion-row>

View File

@ -53,7 +53,7 @@
<ion-icon name="fas-pen"></ion-icon> <ion-icon name="fas-pen"></ion-icon>
</ion-thumbnail> </ion-thumbnail>
<ion-label> <ion-label>
<h2 text-wrap>{{ 'core.login.yourenteredsite' | translate }}</h2> <h2 class="ion-text-wrap">{{ 'core.login.yourenteredsite' | translate }}</h2>
<p>{{enteredSiteUrl.noProtocolUrl}}</p> <p>{{enteredSiteUrl.noProtocolUrl}}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
@ -75,7 +75,7 @@
<ion-item *ngIf="siteSelector == 'url'" lines="none"> <ion-item *ngIf="siteSelector == 'url'" lines="none">
<ion-label> <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 }} {{ 'core.login.connect' | translate }}
</ion-button> </ion-button>
</ion-label> </ion-label>
@ -123,7 +123,7 @@
<img src="assets/icon/icon.png" *ngIf="!site.imageurl" class="core-login-default-icon"> <img src="assets/icon/icon.png" *ngIf="!site.imageurl" class="core-login-default-icon">
</ion-thumbnail> </ion-thumbnail>
<ion-label> <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.noProtocolUrl">{{site.noProtocolUrl}}</p>
<p *ngIf="site.location">{{site.location}}</p> <p *ngIf="site.location">{{site.location}}</p>
</ion-label> </ion-label>

View File

@ -143,6 +143,7 @@
"loadmore": "Load more", "loadmore": "Load more",
"location": "Location", "location": "Location",
"lostconnection": "Your authentication token is invalid or has expired. You will have to reconnect to the site.", "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}}", "maxsizeandattachments": "Maximum file size: {{$a.size}}, maximum number of files: {{$a.attachments}}",
"min": "min", "min": "min",
"mins": "mins", "mins": "mins",

View File

@ -2838,16 +2838,15 @@ export class CoreFilepoolProvider {
let previousStatus: string | undefined; let previousStatus: string | undefined;
// Search current status to set it as previous status. // Search current status to set it as previous status.
try { try {
const entry = <CoreFilepoolPackageEntry> site.getDb().getRecord(PACKAGES_TABLE_NAME, { id: packageId }); const entry = await site.getDb().getRecord<CoreFilepoolPackageEntry>(PACKAGES_TABLE_NAME, { id: packageId });
if (typeof extra == 'undefined' || extra === null) {
extra = entry.extra; extra = extra ?? entry.extra;
}
if (typeof downloadTime == 'undefined') { if (typeof downloadTime == 'undefined') {
// Keep previous download time. // Keep previous download time.
downloadTime = entry.downloadTime; downloadTime = entry.downloadTime;
previousDownloadTime = entry.previousDownloadTime; previousDownloadTime = entry.previousDownloadTime;
} else { } else {
// The downloadTime will be updated, store current time as previous. // The downloadTime will be updated, store current time as previous.
previousDownloadTime = entry.downloadTime; previousDownloadTime = entry.downloadTime;
} }

View File

@ -141,6 +141,8 @@ $colors-dark: (
$core-course-image-background: #81ecec, #74b9ff, #a29bfe, #dfe6e9, #00b894, #0984e3, #b2bec3, #fdcb6e, #fd79a8, #6c5ce7 !default; $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 * Layout Breakpoints
* *

View File

@ -432,3 +432,8 @@ ion-button.core-button-select {
font-weight: normal; font-weight: normal;
font-size: 1em; font-size: 1em;
} }
// Monospaced font.
.core-monospaced {
font-family: Andale Mono,Monaco,Courier New,DejaVu Sans Mono,monospace;
}

View File

@ -38,7 +38,6 @@
--yellow: #{$yellow}; --yellow: #{$yellow};
--yellow-light: #{$yellow-light}; --yellow-light: #{$yellow-light};
--yellow-dark: #{$yellow-dark}; --yellow-dark: #{$yellow-dark};
--purple: #{$purple}; --purple: #{$purple};
--core-color: #{$core-color}; --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-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-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);
} }