MOBILE-3651 quiz: Implement player page
This commit is contained in:
		
							parent
							
								
									916dc14401
								
							
						
					
					
						commit
						1c443b183b
					
				| @ -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" | ||||||
|  | |||||||
| @ -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) { | ||||||
|  | |||||||
							
								
								
									
										254
									
								
								src/addons/mod/quiz/classes/auto-save.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										254
									
								
								src/addons/mod/quiz/classes/auto-save.ts
									
									
									
									
									
										Normal 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; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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 {} | ||||||
|  | |||||||
| @ -0,0 +1,3 @@ | |||||||
|  | <ion-item class="ion-text-wrap"> | ||||||
|  |     <ion-label>{{ "addon.mod_quiz.connectionerror" | translate }}</ion-label> | ||||||
|  | </ion-item> | ||||||
| @ -0,0 +1,7 @@ | |||||||
|  | :host { | ||||||
|  |     background-color: var(--red-light); | ||||||
|  | 
 | ||||||
|  |     .item { | ||||||
|  |         --background: var(--red-light); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -0,0 +1,27 @@ | |||||||
|  | // (C) Copyright 2015 Moodle Pty Ltd.
 | ||||||
|  | //
 | ||||||
|  | // Licensed under the Apache License, Version 2.0 (the "License");
 | ||||||
|  | // you may not use this file except in compliance with the License.
 | ||||||
|  | // You may obtain a copy of the License at
 | ||||||
|  | //
 | ||||||
|  | //     http://www.apache.org/licenses/LICENSE-2.0
 | ||||||
|  | //
 | ||||||
|  | // Unless required by applicable law or agreed to in writing, software
 | ||||||
|  | // distributed under the License is distributed on an "AS IS" BASIS,
 | ||||||
|  | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||||
|  | // See the License for the specific language governing permissions and
 | ||||||
|  | // limitations under the License.
 | ||||||
|  | 
 | ||||||
|  | import { Component } from '@angular/core'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Component that displays a quiz entry page. | ||||||
|  |  */ | ||||||
|  | @Component({ | ||||||
|  |     selector: 'addon-mod-quiz-connection-error', | ||||||
|  |     templateUrl: 'connection-error.html', | ||||||
|  |     styleUrls: ['connection-error.scss'], | ||||||
|  | }) | ||||||
|  | export class AddonModQuizConnectionErrorComponent { | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -21,6 +21,7 @@ import { CoreCourse } from '@features/course/services/course'; | |||||||
| import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; | import { 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, | ||||||
|  |             }, | ||||||
|  |         }); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|  | |||||||
| @ -0,0 +1,67 @@ | |||||||
|  | <ion-header> | ||||||
|  |     <ion-toolbar> | ||||||
|  |         <ion-title>{{ 'addon.mod_quiz.quiznavigation' | translate }}</ion-title> | ||||||
|  | 
 | ||||||
|  |         <ion-buttons slot="end"> | ||||||
|  |             <ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate"> | ||||||
|  |                 <ion-icon slot="icon-only" name="fas-times"></ion-icon> | ||||||
|  |             </ion-button> | ||||||
|  |         </ion-buttons> | ||||||
|  |     </ion-toolbar> | ||||||
|  | </ion-header> | ||||||
|  | <ion-content class="addon-mod_quiz-navigation-modal"> | ||||||
|  |     <nav> | ||||||
|  |         <ion-list> | ||||||
|  |             <!-- In player, show button to finish attempt. --> | ||||||
|  |             <ion-item button class="ion-text-wrap" *ngIf="!isReview" (click)="loadPage(-1)" detail="true"> | ||||||
|  |                 <ion-label>{{ 'addon.mod_quiz.finishattemptdots' | translate }}</ion-label> | ||||||
|  |             </ion-item> | ||||||
|  | 
 | ||||||
|  |             <!-- In review we can toggle between all questions in same page or one page at a time. --> | ||||||
|  |             <ion-item button class="ion-text-wrap" *ngIf="isReview && numPages > 1" (click)="switchMode()" detail="true"> | ||||||
|  |                 <ion-label> | ||||||
|  |                     <span *ngIf="!showAll">{{ 'addon.mod_quiz.showall' | translate }}</span> | ||||||
|  |                     <span *ngIf="showAll">{{ 'addon.mod_quiz.showeachpage' | translate }}</span> | ||||||
|  |                 </ion-label> | ||||||
|  |             </ion-item> | ||||||
|  | 
 | ||||||
|  |             <ion-item button class="ion-text-wrap {{question.stateClass}}" *ngFor="let question of navigation" | ||||||
|  |                 [ngClass]='{"core-selected-item": !summaryShown && currentPage == question.page}' | ||||||
|  |                 (click)="loadPage(question.page, question.slot)" detail="true"> | ||||||
|  | 
 | ||||||
|  |                 <ion-label> | ||||||
|  |                     <span *ngIf="question.number">{{ 'core.question.questionno' | translate:{$a: question.number} }}</span> | ||||||
|  |                     <span *ngIf="!question.number">{{ 'core.question.information' | translate }}</span> | ||||||
|  |                 </ion-label> | ||||||
|  | 
 | ||||||
|  |                 <ion-icon *ngIf="!question.number" name="fas-info-circle" slot="end"></ion-icon> | ||||||
|  |                 <ion-icon *ngIf="question.stateClass == 'core-question-requiresgrading'" name="fas-question-circle" | ||||||
|  |                     [attr.aria-label]="question.status" slot="end"> | ||||||
|  |                 </ion-icon> | ||||||
|  |                 <ion-icon *ngIf="question.stateClass == 'core-question-correct'" name="fas-check" color="success" | ||||||
|  |                     [attr.aria-label]="question.status" slot="end"> | ||||||
|  |                 </ion-icon> | ||||||
|  |                 <ion-icon *ngIf="question.stateClass == 'core-question-partiallycorrect'" name="fas-check-square" | ||||||
|  |                     color="warning" [attr.aria-label]="question.status" slot="end"> | ||||||
|  |                 </ion-icon> | ||||||
|  |                 <ion-icon *ngIf="question.stateClass == 'core-question-incorrect' || | ||||||
|  |                     question.stateClass == 'core-question-notanswered'" name="fas-times" color="danger" | ||||||
|  |                     [attr.aria-label]="question.status" slot="end"> | ||||||
|  |                 </ion-icon> | ||||||
|  |             </ion-item> | ||||||
|  | 
 | ||||||
|  |             <!-- In player, show button to finish attempt. --> | ||||||
|  |             <ion-item button class="ion-text-wrap" *ngIf="!isReview" (click)="loadPage(-1)" detail="true"> | ||||||
|  |                 <ion-label>{{ 'addon.mod_quiz.finishattemptdots' | translate }}</ion-label> | ||||||
|  |             </ion-item> | ||||||
|  | 
 | ||||||
|  |             <!-- In review we can toggle between all questions in same page or one page at a time. --> | ||||||
|  |             <ion-item button class="ion-text-wrap" *ngIf="isReview && numPages > 1" (click)="switchMode()" detail="true"> | ||||||
|  |                 <ion-label> | ||||||
|  |                     <span *ngIf="!showAll">{{ 'addon.mod_quiz.showall' | translate }}</span> | ||||||
|  |                     <span *ngIf="showAll">{{ 'addon.mod_quiz.showeachpage' | translate }}</span> | ||||||
|  |                 </ion-label> | ||||||
|  |             </ion-item> | ||||||
|  |         </ion-list> | ||||||
|  |     </nav> | ||||||
|  | </ion-content> | ||||||
| @ -0,0 +1,76 @@ | |||||||
|  | // (C) Copyright 2015 Moodle Pty Ltd.
 | ||||||
|  | //
 | ||||||
|  | // Licensed under the Apache License, Version 2.0 (the "License");
 | ||||||
|  | // you may not use this file except in compliance with the License.
 | ||||||
|  | // You may obtain a copy of the License at
 | ||||||
|  | //
 | ||||||
|  | //     http://www.apache.org/licenses/LICENSE-2.0
 | ||||||
|  | //
 | ||||||
|  | // Unless required by applicable law or agreed to in writing, software
 | ||||||
|  | // distributed under the License is distributed on an "AS IS" BASIS,
 | ||||||
|  | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||||
|  | // See the License for the specific language governing permissions and
 | ||||||
|  | // limitations under the License.
 | ||||||
|  | 
 | ||||||
|  | import { Component, Input } from '@angular/core'; | ||||||
|  | 
 | ||||||
|  | import { CoreQuestionQuestionParsed } from '@features/question/services/question'; | ||||||
|  | import { ModalController } from '@singletons'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Modal that renders the quiz navigation. | ||||||
|  |  */ | ||||||
|  | @Component({ | ||||||
|  |     selector: 'addon-mod-quiz-navigation-modal', | ||||||
|  |     templateUrl: 'navigation-modal.html', | ||||||
|  | }) | ||||||
|  | export class AddonModQuizNavigationModalComponent { | ||||||
|  | 
 | ||||||
|  |     static readonly CHANGE_PAGE = 1; | ||||||
|  |     static readonly SWITCH_MODE = 2; | ||||||
|  | 
 | ||||||
|  |     @Input() navigation?: AddonModQuizNavigationQuestion[]; // Whether the user is reviewing the attempt.
 | ||||||
|  |     @Input() summaryShown?: boolean; // Whether summary is currently being shown.
 | ||||||
|  |     @Input() currentPage?: number; // Current page.
 | ||||||
|  |     @Input() isReview?: boolean; // Whether the user is reviewing the attempt.
 | ||||||
|  |     @Input() numPages = 0; // Num of pages for review mode.
 | ||||||
|  |     @Input() showAll?: boolean; // Whether to show all questions in same page or not for review mode.
 | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Close modal. | ||||||
|  |      */ | ||||||
|  |     closeModal(): void { | ||||||
|  |         ModalController.instance.dismiss(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Load a certain page. | ||||||
|  |      * | ||||||
|  |      * @param page The page to load. | ||||||
|  |      * @param slot Slot of the question to scroll to. | ||||||
|  |      */ | ||||||
|  |     loadPage(page: number, slot?: number): void { | ||||||
|  |         ModalController.instance.dismiss({ | ||||||
|  |             action: AddonModQuizNavigationModalComponent.CHANGE_PAGE, | ||||||
|  |             page, | ||||||
|  |             slot, | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Switch mode in review. | ||||||
|  |      */ | ||||||
|  |     switchMode(): void { | ||||||
|  |         ModalController.instance.dismiss({ | ||||||
|  |             action: AddonModQuizNavigationModalComponent.SWITCH_MODE, | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Question for the navigation menu with some calculated data. | ||||||
|  |  */ | ||||||
|  | export type AddonModQuizNavigationQuestion = CoreQuestionQuestionParsed & { | ||||||
|  |     stateClass?: string; | ||||||
|  | }; | ||||||
| @ -0,0 +1,32 @@ | |||||||
|  | <ion-header> | ||||||
|  |     <ion-toolbar> | ||||||
|  |         <ion-title>{{ title | translate }}</ion-title> | ||||||
|  | 
 | ||||||
|  |         <ion-buttons slot="end"> | ||||||
|  |             <ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate"> | ||||||
|  |                 <ion-icon slot="icon-only" name="fas-times"></ion-icon> | ||||||
|  |             </ion-button> | ||||||
|  |         </ion-buttons> | ||||||
|  |     </ion-toolbar> | ||||||
|  | </ion-header> | ||||||
|  | <ion-content class="addon-mod_quiz-preflight-modal"> | ||||||
|  |     <core-loading [hideUntil]="loaded"> | ||||||
|  |         <form [formGroup]="preflightForm" (ngSubmit)="sendData($event)" #preflightFormEl> | ||||||
|  |             <ion-list> | ||||||
|  |                 <!-- Access rules. --> | ||||||
|  |                 <ng-container *ngFor="let data of accessRulesData; let last = last"> | ||||||
|  |                     <core-dynamic-component [component]="data.component" [data]="data.data"> | ||||||
|  |                         <p class="ion-padding">Couldn't find the directive to render this access rule.</p> | ||||||
|  |                     </core-dynamic-component> | ||||||
|  |                     <ion-item-divider *ngIf="!last"><ion-label></ion-label></ion-item-divider> | ||||||
|  |                 </ng-container> | ||||||
|  | 
 | ||||||
|  |                 <ion-button expand="block" type="submit" class="ion-margin"> | ||||||
|  |                     {{ title | translate }} | ||||||
|  |                 </ion-button> | ||||||
|  |                 <!-- Remove this once Ionic fixes this bug: https://github.com/ionic-team/ionic-framework/issues/19368 --> | ||||||
|  |                 <input type="submit" class="core-submit-hidden-enter" /> | ||||||
|  |             </ion-list> | ||||||
|  |         </form> | ||||||
|  |     </core-loading> | ||||||
|  | </ion-content> | ||||||
| @ -0,0 +1,138 @@ | |||||||
|  | // (C) Copyright 2015 Moodle Pty Ltd.
 | ||||||
|  | //
 | ||||||
|  | // Licensed under the Apache License, Version 2.0 (the "License");
 | ||||||
|  | // you may not use this file except in compliance with the License.
 | ||||||
|  | // You may obtain a copy of the License at
 | ||||||
|  | //
 | ||||||
|  | //     http://www.apache.org/licenses/LICENSE-2.0
 | ||||||
|  | //
 | ||||||
|  | // Unless required by applicable law or agreed to in writing, software
 | ||||||
|  | // distributed under the License is distributed on an "AS IS" BASIS,
 | ||||||
|  | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||||
|  | // See the License for the specific language governing permissions and
 | ||||||
|  | // limitations under the License.
 | ||||||
|  | 
 | ||||||
|  | import { Component, OnInit, ViewChild, ElementRef, Input, Type } from '@angular/core'; | ||||||
|  | import { FormBuilder, FormGroup } from '@angular/forms'; | ||||||
|  | import { IonContent } from '@ionic/angular'; | ||||||
|  | import { CoreSites } from '@services/sites'; | ||||||
|  | 
 | ||||||
|  | import { CoreDomUtils } from '@services/utils/dom'; | ||||||
|  | import { ModalController, Translate } from '@singletons'; | ||||||
|  | import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate'; | ||||||
|  | import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '../../services/quiz'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Modal that renders the access rules for a quiz. | ||||||
|  |  */ | ||||||
|  | @Component({ | ||||||
|  |     selector: 'page-addon-mod-quiz-preflight-modal', | ||||||
|  |     templateUrl: 'preflight-modal.html', | ||||||
|  | }) | ||||||
|  | export class AddonModQuizPreflightModalComponent implements OnInit { | ||||||
|  | 
 | ||||||
|  |     @ViewChild(IonContent) content?: IonContent; | ||||||
|  |     @ViewChild('preflightFormEl') formElement?: ElementRef; | ||||||
|  | 
 | ||||||
|  |     @Input() title!: string; | ||||||
|  |     @Input() quiz?: AddonModQuizQuizWSData; | ||||||
|  |     @Input() attempt?: AddonModQuizAttemptWSData; | ||||||
|  |     @Input() prefetch?: boolean; | ||||||
|  |     @Input() siteId!: string; | ||||||
|  |     @Input() rules!: string[]; | ||||||
|  | 
 | ||||||
|  |     preflightForm: FormGroup; | ||||||
|  |     accessRulesData: { component: Type<unknown>; data: Record<string, unknown>}[] = []; // Component and data for each access rule.
 | ||||||
|  |     loaded = false; | ||||||
|  | 
 | ||||||
|  |     constructor( | ||||||
|  |         formBuilder: FormBuilder, | ||||||
|  |     ) { | ||||||
|  |         // Create an empty form group. The controls will be added by the access rules components.
 | ||||||
|  |         this.preflightForm = formBuilder.group({}); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Component being initialized. | ||||||
|  |      */ | ||||||
|  |     async ngOnInit(): Promise<void> { | ||||||
|  |         this.title = this.title || Translate.instance.instant('addon.mod_quiz.startattempt'); | ||||||
|  |         this.siteId = this.siteId || CoreSites.instance.getCurrentSiteId(); | ||||||
|  |         this.rules = this.rules || []; | ||||||
|  | 
 | ||||||
|  |         if (!this.quiz) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         try { | ||||||
|  |             await Promise.all(this.rules.map(async (rule) => { | ||||||
|  |                 // Check if preflight is required for rule and, if so, get the component to render it.
 | ||||||
|  |                 const required = await AddonModQuizAccessRuleDelegate.instance.isPreflightCheckRequiredForRule( | ||||||
|  |                     rule, | ||||||
|  |                     this.quiz!, | ||||||
|  |                     this.attempt, | ||||||
|  |                     this.prefetch, | ||||||
|  |                     this.siteId, | ||||||
|  |                 ); | ||||||
|  | 
 | ||||||
|  |                 if (!required) { | ||||||
|  |                     return; | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 const component = await AddonModQuizAccessRuleDelegate.instance.getPreflightComponent(rule); | ||||||
|  |                 if (!component) { | ||||||
|  |                     return; | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 this.accessRulesData.push({ | ||||||
|  |                     component: component, | ||||||
|  |                     data: { | ||||||
|  |                         rule: rule, | ||||||
|  |                         quiz: this.quiz, | ||||||
|  |                         attempt: this.attempt, | ||||||
|  |                         prefetch: this.prefetch, | ||||||
|  |                         form: this.preflightForm, | ||||||
|  |                         siteId: this.siteId, | ||||||
|  |                     }, | ||||||
|  |                 }); | ||||||
|  |             })); | ||||||
|  | 
 | ||||||
|  |         } catch (error) { | ||||||
|  |             CoreDomUtils.instance.showErrorModalDefault(error, 'Error loading rules'); | ||||||
|  |         } finally { | ||||||
|  |             this.loaded = true; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Check that the data is valid and send it back. | ||||||
|  |      * | ||||||
|  |      * @param e Event. | ||||||
|  |      */ | ||||||
|  |     sendData(e: Event): void { | ||||||
|  |         e.preventDefault(); | ||||||
|  |         e.stopPropagation(); | ||||||
|  | 
 | ||||||
|  |         if (!this.preflightForm.valid) { | ||||||
|  |             // Form not valid. Scroll to the first element with errors.
 | ||||||
|  |             if (!CoreDomUtils.instance.scrollToInputError(this.content)) { | ||||||
|  |                 // Input not found, show an error modal.
 | ||||||
|  |                 CoreDomUtils.instance.showErrorModal('core.errorinvalidform', true); | ||||||
|  |             } | ||||||
|  |         } else { | ||||||
|  |             CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, false, this.siteId); | ||||||
|  | 
 | ||||||
|  |             ModalController.instance.dismiss(this.preflightForm.value); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Close modal. | ||||||
|  |      */ | ||||||
|  |     closeModal(): void { | ||||||
|  |         CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, this.siteId); | ||||||
|  | 
 | ||||||
|  |         ModalController.instance.dismiss(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | } | ||||||
							
								
								
									
										189
									
								
								src/addons/mod/quiz/pages/player/player.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										189
									
								
								src/addons/mod/quiz/pages/player/player.html
									
									
									
									
									
										Normal 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> | ||||||
							
								
								
									
										42
									
								
								src/addons/mod/quiz/pages/player/player.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								src/addons/mod/quiz/pages/player/player.module.ts
									
									
									
									
									
										Normal 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 {} | ||||||
							
								
								
									
										10
									
								
								src/addons/mod/quiz/pages/player/player.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/addons/mod/quiz/pages/player/player.scss
									
									
									
									
									
										Normal 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); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										782
									
								
								src/addons/mod/quiz/pages/player/player.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										782
									
								
								src/addons/mod/quiz/pages/player/player.ts
									
									
									
									
									
										Normal 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; | ||||||
|  | }; | ||||||
| @ -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({ | ||||||
|  | |||||||
| @ -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> | ||||||
|  | |||||||
| @ -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> | ||||||
|  | |||||||
| @ -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> | ||||||
|  | |||||||
| @ -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> | ||||||
|  | |||||||
| @ -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.
 | ||||||
|  | |||||||
| @ -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> | ||||||
|  | |||||||
| @ -15,7 +15,7 @@ | |||||||
| <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" | ||||||
| @ -27,7 +27,6 @@ | |||||||
|                     [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> | ||||||
|  | |||||||
| @ -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); | ||||||
|         } |         } | ||||||
|  | |||||||
| @ -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"> | ||||||
|  | |||||||
| @ -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> | ||||||
|  | |||||||
| @ -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> | ||||||
|  | |||||||
| @ -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", | ||||||
|  | |||||||
| @ -2838,10 +2838,9 @@ 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; | ||||||
|  | |||||||
| @ -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 | ||||||
|  * |  * | ||||||
|  | |||||||
| @ -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; | ||||||
|  | } | ||||||
|  | |||||||
| @ -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); | ||||||
| } | } | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user