diff --git a/src/addons/mod/lesson/pages/player/player.html b/src/addons/mod/lesson/pages/player/player.html index a3ad6de00..e534fbc27 100644 --- a/src/addons/mod/lesson/pages/player/player.html +++ b/src/addons/mod/lesson/pages/player/player.html @@ -99,7 +99,7 @@ - + - + (CoreEvents.ACTIVITY_DATA_SENT, { module: 'lesson' }); // Format activity link if present. if (this.eolData.activitylink) { diff --git a/src/addons/mod/quiz/classes/auto-save.ts b/src/addons/mod/quiz/classes/auto-save.ts new file mode 100644 index 000000000..981d4bd89 --- /dev/null +++ b/src/addons/mod/quiz/classes/auto-save.ts @@ -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; // 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(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, + 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 { + 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, + 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 { + // 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, + }); + 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, + 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; + } + +} diff --git a/src/addons/mod/quiz/components/components.module.ts b/src/addons/mod/quiz/components/components.module.ts index ef510db4b..9b167b237 100644 --- a/src/addons/mod/quiz/components/components.module.ts +++ b/src/addons/mod/quiz/components/components.module.ts @@ -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 {} diff --git a/src/addons/mod/quiz/components/connection-error/connection-error.html b/src/addons/mod/quiz/components/connection-error/connection-error.html new file mode 100644 index 000000000..97d6d770b --- /dev/null +++ b/src/addons/mod/quiz/components/connection-error/connection-error.html @@ -0,0 +1,3 @@ + + {{ "addon.mod_quiz.connectionerror" | translate }} + diff --git a/src/addons/mod/quiz/components/connection-error/connection-error.scss b/src/addons/mod/quiz/components/connection-error/connection-error.scss new file mode 100644 index 000000000..7658c1e7e --- /dev/null +++ b/src/addons/mod/quiz/components/connection-error/connection-error.scss @@ -0,0 +1,7 @@ +:host { + background-color: var(--red-light); + + .item { + --background: var(--red-light); + } +} diff --git a/src/addons/mod/quiz/components/connection-error/connection-error.ts b/src/addons/mod/quiz/components/connection-error/connection-error.ts new file mode 100644 index 000000000..eedc9dcf0 --- /dev/null +++ b/src/addons/mod/quiz/components/connection-error/connection-error.ts @@ -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 { + +} diff --git a/src/addons/mod/quiz/components/index/index.ts b/src/addons/mod/quiz/components/index/index.ts index 19912e9bb..d7535b99b 100644 --- a/src/addons/mod/quiz/components/index/index.ts +++ b/src/addons/mod/quiz/components/index/index.ts @@ -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, + }, + }); } /** diff --git a/src/addons/mod/quiz/components/navigation-modal/navigation-modal.html b/src/addons/mod/quiz/components/navigation-modal/navigation-modal.html new file mode 100644 index 000000000..6b9e116af --- /dev/null +++ b/src/addons/mod/quiz/components/navigation-modal/navigation-modal.html @@ -0,0 +1,67 @@ + + + {{ 'addon.mod_quiz.quiznavigation' | translate }} + + + + + + + + + + + diff --git a/src/addons/mod/quiz/components/navigation-modal/navigation-modal.ts b/src/addons/mod/quiz/components/navigation-modal/navigation-modal.ts new file mode 100644 index 000000000..d081e47c1 --- /dev/null +++ b/src/addons/mod/quiz/components/navigation-modal/navigation-modal.ts @@ -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; +}; diff --git a/src/addons/mod/quiz/components/preflight-modal/preflight-modal.html b/src/addons/mod/quiz/components/preflight-modal/preflight-modal.html new file mode 100644 index 000000000..959cf7d7e --- /dev/null +++ b/src/addons/mod/quiz/components/preflight-modal/preflight-modal.html @@ -0,0 +1,32 @@ + + + {{ title | translate }} + + + + + + + + + + +
+ + + + +

Couldn't find the directive to render this access rule.

+
+ +
+ + + {{ title | translate }} + + + +
+
+
+
diff --git a/src/addons/mod/quiz/components/preflight-modal/preflight-modal.ts b/src/addons/mod/quiz/components/preflight-modal/preflight-modal.ts new file mode 100644 index 000000000..f15da32c7 --- /dev/null +++ b/src/addons/mod/quiz/components/preflight-modal/preflight-modal.ts @@ -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; data: Record}[] = []; // 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 { + 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(); + } + +} diff --git a/src/addons/mod/quiz/pages/player/player.html b/src/addons/mod/quiz/pages/player/player.html new file mode 100644 index 000000000..8c8f39b12 --- /dev/null +++ b/src/addons/mod/quiz/pages/player/player.html @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ 'addon.mod_quiz.startattempt' | translate }} + + + +
+
+ + + + +

+ {{ 'core.question.questionno' | translate:{$a: question.number} }} +

+

{{ 'core.question.information' | translate }}

+
+
+

{{question.status}}

+

{{ question.readableMark }}

+
+
+ + + + +
+
+
+ + + + + + + + {{ 'core.previous' | translate }} + + + + + {{ 'core.next' | translate }} + + + + + + + + + + {{ 'addon.mod_quiz.summaryofattempt' | translate }} + + + + + + + + {{ 'addon.mod_quiz.question' | translate }} + + # + + {{ 'addon.mod_quiz.status' | translate }} + + + + + + + + + + + {{ question.number }} + {{ question.status }} + + + + + + + + {{ 'addon.mod_quiz.returnattempt' | translate }} + + + + + {{ dueDateWarning }} + + + + + + + + + +

{{ 'addon.mod_quiz.cannotsubmitquizdueto' | translate }}

+

{{message}}

+
+
+ + + {{ 'core.openinbrowser' | translate }} + + + + + + {{ 'addon.mod_quiz.submitallandfinish' | translate }} + +
+ + + + + {{ 'addon.mod_quiz.errorparsequestions' | translate }} + + + {{ 'core.openinbrowser' | translate }} + + + +
+
diff --git a/src/addons/mod/quiz/pages/player/player.module.ts b/src/addons/mod/quiz/pages/player/player.module.ts new file mode 100644 index 000000000..603555962 --- /dev/null +++ b/src/addons/mod/quiz/pages/player/player.module.ts @@ -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 {} diff --git a/src/addons/mod/quiz/pages/player/player.scss b/src/addons/mod/quiz/pages/player/player.scss new file mode 100644 index 000000000..117b78119 --- /dev/null +++ b/src/addons/mod/quiz/pages/player/player.scss @@ -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); + } +} diff --git a/src/addons/mod/quiz/pages/player/player.ts b/src/addons/mod/quiz/pages/player/player.ts new file mode 100644 index 000000000..f6124b557 --- /dev/null +++ b/src/addons/mod/quiz/pages/player/player.ts @@ -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; + @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 = {}; // 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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(AddonModQuizProvider.ATTEMPT_FINISHED_EVENT, { + quizId: this.quizId, + attemptId: this.attempt!.id, + synced: !this.offline, + }, CoreSites.instance.getCurrentSiteId()); + + CoreEvents.trigger(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 { + // 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 = {}; + + 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 { + 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 { + 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 { + // 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 { + + 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 { + 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 { + // 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 { + 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 { + 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; +}; diff --git a/src/addons/mod/quiz/quiz-lazy.module.ts b/src/addons/mod/quiz/quiz-lazy.module.ts index b167bbd5b..e48b0f823 100644 --- a/src/addons/mod/quiz/quiz-lazy.module.ts +++ b/src/addons/mod/quiz/quiz-lazy.module.ts @@ -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({ diff --git a/src/addons/userprofilefield/datetime/component/addon-user-profile-field-datetime.html b/src/addons/userprofilefield/datetime/component/addon-user-profile-field-datetime.html index 7d75067a0..fb8f85417 100644 --- a/src/addons/userprofilefield/datetime/component/addon-user-profile-field-datetime.html +++ b/src/addons/userprofilefield/datetime/component/addon-user-profile-field-datetime.html @@ -7,7 +7,7 @@
- + {{ field.name }} @@ -15,4 +15,4 @@ [max]="max" [min]="min"> - \ No newline at end of file + diff --git a/src/addons/userprofilefield/menu/component/addon-user-profile-field-menu.html b/src/addons/userprofilefield/menu/component/addon-user-profile-field-menu.html index ce2e84f68..74524fc97 100644 --- a/src/addons/userprofilefield/menu/component/addon-user-profile-field-menu.html +++ b/src/addons/userprofilefield/menu/component/addon-user-profile-field-menu.html @@ -9,7 +9,7 @@
- + {{ field.name }} diff --git a/src/addons/userprofilefield/text/component/addon-user-profile-field-text.html b/src/addons/userprofilefield/text/component/addon-user-profile-field-text.html index 51d52320d..618ad2253 100644 --- a/src/addons/userprofilefield/text/component/addon-user-profile-field-text.html +++ b/src/addons/userprofilefield/text/component/addon-user-profile-field-text.html @@ -9,7 +9,7 @@ - + {{ field.name }} diff --git a/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html b/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html index 6903fb29f..0286d08ec 100644 --- a/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html +++ b/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html @@ -9,7 +9,7 @@ - + {{ field.name }} @@ -17,4 +17,4 @@ - \ No newline at end of file + diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index 3f1d67d64..2548ccda4 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -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. diff --git a/src/core/components/input-errors/input-errors.ts b/src/core/components/input-errors/input-errors.ts index 7928e6e18..a3db6a307 100644 --- a/src/core/components/input-errors/input-errors.ts +++ b/src/core/components/input-errors/input-errors.ts @@ -32,7 +32,7 @@ import { Translate } from '@singletons'; * * Example usage: * - * + * * {{ 'core.login.username' | translate }} * * diff --git a/src/core/features/courses/components/self-enrol-password/self-enrol-password.html b/src/core/features/courses/components/self-enrol-password/self-enrol-password.html index 470dc5d13..bbecd202a 100644 --- a/src/core/features/courses/components/self-enrol-password/self-enrol-password.html +++ b/src/core/features/courses/components/self-enrol-password/self-enrol-password.html @@ -15,19 +15,18 @@
- - - - - - + + + + +
{{ 'core.courses.enrolme' | translate }} diff --git a/src/core/features/fileuploader/services/fileuploader-helper.ts b/src/core/features/fileuploader/services/fileuploader-helper.ts index a303ee0bd..28b63a751 100644 --- a/src/core/features/fileuploader/services/fileuploader-helper.ts +++ b/src/core/features/fileuploader/services/fileuploader-helper.ts @@ -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 { + 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); } diff --git a/src/core/features/login/pages/credentials/credentials.html b/src/core/features/login/pages/credentials/credentials.html index 5c0e22e74..428ab492e 100644 --- a/src/core/features/login/pages/credentials/credentials.html +++ b/src/core/features/login/pages/credentials/credentials.html @@ -38,14 +38,13 @@ - - - - - - + + + + +