MOBILE-3651 quiz: Implement player page
This commit is contained in:
		
							parent
							
								
									916dc14401
								
							
						
					
					
						commit
						1c443b183b
					
				| @ -99,7 +99,7 @@ | ||||
|                         <ng-container *ngSwitchCase="'multichoice'"> | ||||
|                             <!-- Single choice. --> | ||||
|                             <ion-radio-group *ngIf="!question.multi" [formControlName]="question.controlName"> | ||||
|                                 <ion-item class="ion-text-wrap" *ngFor="let option of question.options" lines="none"> | ||||
|                                 <ion-item class="ion-text-wrap" *ngFor="let option of question.options"> | ||||
|                                     <ion-label> | ||||
|                                         <core-format-text [component]="component" [componentId]="lesson.coursemodule" | ||||
|                                             [text]="option.text" contextLevel="module" [contextInstanceId]="lesson?.coursemodule" | ||||
| @ -113,7 +113,7 @@ | ||||
| 
 | ||||
|                             <!-- Multiple choice. --> | ||||
|                             <ng-container *ngIf="question.multi"> | ||||
|                                 <ion-item class="ion-text-wrap" *ngFor="let option of question.options" lines="none"> | ||||
|                                 <ion-item class="ion-text-wrap" *ngFor="let option of question.options"> | ||||
|                                     <ion-label> | ||||
|                                         <core-format-text [component]="component" [componentId]="lesson?.coursemodule" | ||||
|                                             [text]="option.text" contextLevel="module" [contextInstanceId]="lesson?.coursemodule" | ||||
|  | ||||
| @ -27,7 +27,7 @@ import { CoreUrlUtils } from '@services/utils/url'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { CoreWSExternalFile } from '@services/ws'; | ||||
| import { ModalController, Translate } from '@singletons'; | ||||
| import { CoreEvents } from '@singletons/events'; | ||||
| import { CoreEventActivityDataSentData, CoreEvents } from '@singletons/events'; | ||||
| import { AddonModLessonMenuModalPage } from '../../components/menu-modal/menu-modal'; | ||||
| import { | ||||
|     AddonModLesson, | ||||
| @ -409,7 +409,7 @@ export class AddonModLessonPlayerPage implements OnInit, OnDestroy, CanLeave { | ||||
|         this.messages = this.messages.concat(data.messages); | ||||
|         this.processData = undefined; | ||||
| 
 | ||||
|         CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'lesson' }); | ||||
|         CoreEvents.trigger<CoreEventActivityDataSentData>(CoreEvents.ACTIVITY_DATA_SENT, { module: 'lesson' }); | ||||
| 
 | ||||
|         // Format activity link if present.
 | ||||
|         if (this.eolData.activitylink) { | ||||
|  | ||||
							
								
								
									
										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 { AddonModQuizConnectionErrorComponent } from './connection-error/connection-error'; | ||||
| import { AddonModQuizIndexComponent } from './index/index'; | ||||
| import { AddonModQuizNavigationModalComponent } from './navigation-modal/navigation-modal'; | ||||
| import { AddonModQuizPreflightModalComponent } from './preflight-modal/preflight-modal'; | ||||
| 
 | ||||
| @NgModule({ | ||||
|     declarations: [ | ||||
|         AddonModQuizIndexComponent, | ||||
|         AddonModQuizConnectionErrorComponent, | ||||
|         AddonModQuizNavigationModalComponent, | ||||
|         AddonModQuizPreflightModalComponent, | ||||
|     ], | ||||
|     imports: [ | ||||
|         CoreSharedModule, | ||||
| @ -33,6 +37,8 @@ import { AddonModQuizIndexComponent } from './index/index'; | ||||
|     exports: [ | ||||
|         AddonModQuizIndexComponent, | ||||
|         AddonModQuizConnectionErrorComponent, | ||||
|         AddonModQuizNavigationModalComponent, | ||||
|         AddonModQuizPreflightModalComponent, | ||||
|     ], | ||||
| }) | ||||
| export class AddonModQuizComponentsModule {} | ||||
|  | ||||
| @ -0,0 +1,3 @@ | ||||
| <ion-item class="ion-text-wrap"> | ||||
|     <ion-label>{{ "addon.mod_quiz.connectionerror" | translate }}</ion-label> | ||||
| </ion-item> | ||||
| @ -0,0 +1,7 @@ | ||||
| :host { | ||||
|     background-color: var(--red-light); | ||||
| 
 | ||||
|     .item { | ||||
|         --background: var(--red-light); | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,27 @@ | ||||
| // (C) Copyright 2015 Moodle Pty Ltd.
 | ||||
| //
 | ||||
| // Licensed under the Apache License, Version 2.0 (the "License");
 | ||||
| // you may not use this file except in compliance with the License.
 | ||||
| // You may obtain a copy of the License at
 | ||||
| //
 | ||||
| //     http://www.apache.org/licenses/LICENSE-2.0
 | ||||
| //
 | ||||
| // Unless required by applicable law or agreed to in writing, software
 | ||||
| // distributed under the License is distributed on an "AS IS" BASIS,
 | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component } from '@angular/core'; | ||||
| 
 | ||||
| /** | ||||
|  * Component that displays a quiz entry page. | ||||
|  */ | ||||
| @Component({ | ||||
|     selector: 'addon-mod-quiz-connection-error', | ||||
|     templateUrl: 'connection-error.html', | ||||
|     styleUrls: ['connection-error.scss'], | ||||
| }) | ||||
| export class AddonModQuizConnectionErrorComponent { | ||||
| 
 | ||||
| } | ||||
| @ -21,6 +21,7 @@ import { CoreCourse } from '@features/course/services/course'; | ||||
| import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; | ||||
| import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate'; | ||||
| import { IonContent } from '@ionic/angular'; | ||||
| import { CoreNavigator } from '@services/navigator'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CoreTextUtils } from '@services/utils/text'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| @ -464,7 +465,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp | ||||
|         this.content?.scrollToTop(); | ||||
| 
 | ||||
|         await promise; | ||||
|         await CoreUtils.instance.ignoreErrors(this.refreshContent()); | ||||
|         await CoreUtils.instance.ignoreErrors(this.refreshContent(true)); | ||||
| 
 | ||||
|         this.loaded = true; | ||||
|         this.refreshIcon = CoreConstants.ICON_REFRESH; | ||||
| @ -533,7 +534,11 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp | ||||
|     protected openQuiz(): void { | ||||
|         this.hasPlayed = true; | ||||
| 
 | ||||
|         // @todo this.navCtrl.push('player', {courseId: this.courseId, quizId: this.quiz.id, moduleUrl: this.module.url});
 | ||||
|         CoreNavigator.instance.navigate(`../../player/${this.courseId}/${this.quiz!.id}`, { | ||||
|             params: { | ||||
|                 moduleUrl: this.module?.url, | ||||
|             }, | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | ||||
| @ -0,0 +1,67 @@ | ||||
| <ion-header> | ||||
|     <ion-toolbar> | ||||
|         <ion-title>{{ 'addon.mod_quiz.quiznavigation' | translate }}</ion-title> | ||||
| 
 | ||||
|         <ion-buttons slot="end"> | ||||
|             <ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate"> | ||||
|                 <ion-icon slot="icon-only" name="fas-times"></ion-icon> | ||||
|             </ion-button> | ||||
|         </ion-buttons> | ||||
|     </ion-toolbar> | ||||
| </ion-header> | ||||
| <ion-content class="addon-mod_quiz-navigation-modal"> | ||||
|     <nav> | ||||
|         <ion-list> | ||||
|             <!-- In player, show button to finish attempt. --> | ||||
|             <ion-item button class="ion-text-wrap" *ngIf="!isReview" (click)="loadPage(-1)" detail="true"> | ||||
|                 <ion-label>{{ 'addon.mod_quiz.finishattemptdots' | translate }}</ion-label> | ||||
|             </ion-item> | ||||
| 
 | ||||
|             <!-- In review we can toggle between all questions in same page or one page at a time. --> | ||||
|             <ion-item button class="ion-text-wrap" *ngIf="isReview && numPages > 1" (click)="switchMode()" detail="true"> | ||||
|                 <ion-label> | ||||
|                     <span *ngIf="!showAll">{{ 'addon.mod_quiz.showall' | translate }}</span> | ||||
|                     <span *ngIf="showAll">{{ 'addon.mod_quiz.showeachpage' | translate }}</span> | ||||
|                 </ion-label> | ||||
|             </ion-item> | ||||
| 
 | ||||
|             <ion-item button class="ion-text-wrap {{question.stateClass}}" *ngFor="let question of navigation" | ||||
|                 [ngClass]='{"core-selected-item": !summaryShown && currentPage == question.page}' | ||||
|                 (click)="loadPage(question.page, question.slot)" detail="true"> | ||||
| 
 | ||||
|                 <ion-label> | ||||
|                     <span *ngIf="question.number">{{ 'core.question.questionno' | translate:{$a: question.number} }}</span> | ||||
|                     <span *ngIf="!question.number">{{ 'core.question.information' | translate }}</span> | ||||
|                 </ion-label> | ||||
| 
 | ||||
|                 <ion-icon *ngIf="!question.number" name="fas-info-circle" slot="end"></ion-icon> | ||||
|                 <ion-icon *ngIf="question.stateClass == 'core-question-requiresgrading'" name="fas-question-circle" | ||||
|                     [attr.aria-label]="question.status" slot="end"> | ||||
|                 </ion-icon> | ||||
|                 <ion-icon *ngIf="question.stateClass == 'core-question-correct'" name="fas-check" color="success" | ||||
|                     [attr.aria-label]="question.status" slot="end"> | ||||
|                 </ion-icon> | ||||
|                 <ion-icon *ngIf="question.stateClass == 'core-question-partiallycorrect'" name="fas-check-square" | ||||
|                     color="warning" [attr.aria-label]="question.status" slot="end"> | ||||
|                 </ion-icon> | ||||
|                 <ion-icon *ngIf="question.stateClass == 'core-question-incorrect' || | ||||
|                     question.stateClass == 'core-question-notanswered'" name="fas-times" color="danger" | ||||
|                     [attr.aria-label]="question.status" slot="end"> | ||||
|                 </ion-icon> | ||||
|             </ion-item> | ||||
| 
 | ||||
|             <!-- In player, show button to finish attempt. --> | ||||
|             <ion-item button class="ion-text-wrap" *ngIf="!isReview" (click)="loadPage(-1)" detail="true"> | ||||
|                 <ion-label>{{ 'addon.mod_quiz.finishattemptdots' | translate }}</ion-label> | ||||
|             </ion-item> | ||||
| 
 | ||||
|             <!-- In review we can toggle between all questions in same page or one page at a time. --> | ||||
|             <ion-item button class="ion-text-wrap" *ngIf="isReview && numPages > 1" (click)="switchMode()" detail="true"> | ||||
|                 <ion-label> | ||||
|                     <span *ngIf="!showAll">{{ 'addon.mod_quiz.showall' | translate }}</span> | ||||
|                     <span *ngIf="showAll">{{ 'addon.mod_quiz.showeachpage' | translate }}</span> | ||||
|                 </ion-label> | ||||
|             </ion-item> | ||||
|         </ion-list> | ||||
|     </nav> | ||||
| </ion-content> | ||||
| @ -0,0 +1,76 @@ | ||||
| // (C) Copyright 2015 Moodle Pty Ltd.
 | ||||
| //
 | ||||
| // Licensed under the Apache License, Version 2.0 (the "License");
 | ||||
| // you may not use this file except in compliance with the License.
 | ||||
| // You may obtain a copy of the License at
 | ||||
| //
 | ||||
| //     http://www.apache.org/licenses/LICENSE-2.0
 | ||||
| //
 | ||||
| // Unless required by applicable law or agreed to in writing, software
 | ||||
| // distributed under the License is distributed on an "AS IS" BASIS,
 | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component, Input } from '@angular/core'; | ||||
| 
 | ||||
| import { CoreQuestionQuestionParsed } from '@features/question/services/question'; | ||||
| import { ModalController } from '@singletons'; | ||||
| 
 | ||||
| /** | ||||
|  * Modal that renders the quiz navigation. | ||||
|  */ | ||||
| @Component({ | ||||
|     selector: 'addon-mod-quiz-navigation-modal', | ||||
|     templateUrl: 'navigation-modal.html', | ||||
| }) | ||||
| export class AddonModQuizNavigationModalComponent { | ||||
| 
 | ||||
|     static readonly CHANGE_PAGE = 1; | ||||
|     static readonly SWITCH_MODE = 2; | ||||
| 
 | ||||
|     @Input() navigation?: AddonModQuizNavigationQuestion[]; // Whether the user is reviewing the attempt.
 | ||||
|     @Input() summaryShown?: boolean; // Whether summary is currently being shown.
 | ||||
|     @Input() currentPage?: number; // Current page.
 | ||||
|     @Input() isReview?: boolean; // Whether the user is reviewing the attempt.
 | ||||
|     @Input() numPages = 0; // Num of pages for review mode.
 | ||||
|     @Input() showAll?: boolean; // Whether to show all questions in same page or not for review mode.
 | ||||
| 
 | ||||
|     /** | ||||
|      * Close modal. | ||||
|      */ | ||||
|     closeModal(): void { | ||||
|         ModalController.instance.dismiss(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load a certain page. | ||||
|      * | ||||
|      * @param page The page to load. | ||||
|      * @param slot Slot of the question to scroll to. | ||||
|      */ | ||||
|     loadPage(page: number, slot?: number): void { | ||||
|         ModalController.instance.dismiss({ | ||||
|             action: AddonModQuizNavigationModalComponent.CHANGE_PAGE, | ||||
|             page, | ||||
|             slot, | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Switch mode in review. | ||||
|      */ | ||||
|     switchMode(): void { | ||||
|         ModalController.instance.dismiss({ | ||||
|             action: AddonModQuizNavigationModalComponent.SWITCH_MODE, | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Question for the navigation menu with some calculated data. | ||||
|  */ | ||||
| export type AddonModQuizNavigationQuestion = CoreQuestionQuestionParsed & { | ||||
|     stateClass?: string; | ||||
| }; | ||||
| @ -0,0 +1,32 @@ | ||||
| <ion-header> | ||||
|     <ion-toolbar> | ||||
|         <ion-title>{{ title | translate }}</ion-title> | ||||
| 
 | ||||
|         <ion-buttons slot="end"> | ||||
|             <ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate"> | ||||
|                 <ion-icon slot="icon-only" name="fas-times"></ion-icon> | ||||
|             </ion-button> | ||||
|         </ion-buttons> | ||||
|     </ion-toolbar> | ||||
| </ion-header> | ||||
| <ion-content class="addon-mod_quiz-preflight-modal"> | ||||
|     <core-loading [hideUntil]="loaded"> | ||||
|         <form [formGroup]="preflightForm" (ngSubmit)="sendData($event)" #preflightFormEl> | ||||
|             <ion-list> | ||||
|                 <!-- Access rules. --> | ||||
|                 <ng-container *ngFor="let data of accessRulesData; let last = last"> | ||||
|                     <core-dynamic-component [component]="data.component" [data]="data.data"> | ||||
|                         <p class="ion-padding">Couldn't find the directive to render this access rule.</p> | ||||
|                     </core-dynamic-component> | ||||
|                     <ion-item-divider *ngIf="!last"><ion-label></ion-label></ion-item-divider> | ||||
|                 </ng-container> | ||||
| 
 | ||||
|                 <ion-button expand="block" type="submit" class="ion-margin"> | ||||
|                     {{ title | translate }} | ||||
|                 </ion-button> | ||||
|                 <!-- Remove this once Ionic fixes this bug: https://github.com/ionic-team/ionic-framework/issues/19368 --> | ||||
|                 <input type="submit" class="core-submit-hidden-enter" /> | ||||
|             </ion-list> | ||||
|         </form> | ||||
|     </core-loading> | ||||
| </ion-content> | ||||
| @ -0,0 +1,138 @@ | ||||
| // (C) Copyright 2015 Moodle Pty Ltd.
 | ||||
| //
 | ||||
| // Licensed under the Apache License, Version 2.0 (the "License");
 | ||||
| // you may not use this file except in compliance with the License.
 | ||||
| // You may obtain a copy of the License at
 | ||||
| //
 | ||||
| //     http://www.apache.org/licenses/LICENSE-2.0
 | ||||
| //
 | ||||
| // Unless required by applicable law or agreed to in writing, software
 | ||||
| // distributed under the License is distributed on an "AS IS" BASIS,
 | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component, OnInit, ViewChild, ElementRef, Input, Type } from '@angular/core'; | ||||
| import { FormBuilder, FormGroup } from '@angular/forms'; | ||||
| import { IonContent } from '@ionic/angular'; | ||||
| import { CoreSites } from '@services/sites'; | ||||
| 
 | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { ModalController, Translate } from '@singletons'; | ||||
| import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate'; | ||||
| import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '../../services/quiz'; | ||||
| 
 | ||||
| /** | ||||
|  * Modal that renders the access rules for a quiz. | ||||
|  */ | ||||
| @Component({ | ||||
|     selector: 'page-addon-mod-quiz-preflight-modal', | ||||
|     templateUrl: 'preflight-modal.html', | ||||
| }) | ||||
| export class AddonModQuizPreflightModalComponent implements OnInit { | ||||
| 
 | ||||
|     @ViewChild(IonContent) content?: IonContent; | ||||
|     @ViewChild('preflightFormEl') formElement?: ElementRef; | ||||
| 
 | ||||
|     @Input() title!: string; | ||||
|     @Input() quiz?: AddonModQuizQuizWSData; | ||||
|     @Input() attempt?: AddonModQuizAttemptWSData; | ||||
|     @Input() prefetch?: boolean; | ||||
|     @Input() siteId!: string; | ||||
|     @Input() rules!: string[]; | ||||
| 
 | ||||
|     preflightForm: FormGroup; | ||||
|     accessRulesData: { component: Type<unknown>; data: Record<string, unknown>}[] = []; // Component and data for each access rule.
 | ||||
|     loaded = false; | ||||
| 
 | ||||
|     constructor( | ||||
|         formBuilder: FormBuilder, | ||||
|     ) { | ||||
|         // Create an empty form group. The controls will be added by the access rules components.
 | ||||
|         this.preflightForm = formBuilder.group({}); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Component being initialized. | ||||
|      */ | ||||
|     async ngOnInit(): Promise<void> { | ||||
|         this.title = this.title || Translate.instance.instant('addon.mod_quiz.startattempt'); | ||||
|         this.siteId = this.siteId || CoreSites.instance.getCurrentSiteId(); | ||||
|         this.rules = this.rules || []; | ||||
| 
 | ||||
|         if (!this.quiz) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         try { | ||||
|             await Promise.all(this.rules.map(async (rule) => { | ||||
|                 // Check if preflight is required for rule and, if so, get the component to render it.
 | ||||
|                 const required = await AddonModQuizAccessRuleDelegate.instance.isPreflightCheckRequiredForRule( | ||||
|                     rule, | ||||
|                     this.quiz!, | ||||
|                     this.attempt, | ||||
|                     this.prefetch, | ||||
|                     this.siteId, | ||||
|                 ); | ||||
| 
 | ||||
|                 if (!required) { | ||||
|                     return; | ||||
|                 } | ||||
| 
 | ||||
|                 const component = await AddonModQuizAccessRuleDelegate.instance.getPreflightComponent(rule); | ||||
|                 if (!component) { | ||||
|                     return; | ||||
|                 } | ||||
| 
 | ||||
|                 this.accessRulesData.push({ | ||||
|                     component: component, | ||||
|                     data: { | ||||
|                         rule: rule, | ||||
|                         quiz: this.quiz, | ||||
|                         attempt: this.attempt, | ||||
|                         prefetch: this.prefetch, | ||||
|                         form: this.preflightForm, | ||||
|                         siteId: this.siteId, | ||||
|                     }, | ||||
|                 }); | ||||
|             })); | ||||
| 
 | ||||
|         } catch (error) { | ||||
|             CoreDomUtils.instance.showErrorModalDefault(error, 'Error loading rules'); | ||||
|         } finally { | ||||
|             this.loaded = true; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Check that the data is valid and send it back. | ||||
|      * | ||||
|      * @param e Event. | ||||
|      */ | ||||
|     sendData(e: Event): void { | ||||
|         e.preventDefault(); | ||||
|         e.stopPropagation(); | ||||
| 
 | ||||
|         if (!this.preflightForm.valid) { | ||||
|             // Form not valid. Scroll to the first element with errors.
 | ||||
|             if (!CoreDomUtils.instance.scrollToInputError(this.content)) { | ||||
|                 // Input not found, show an error modal.
 | ||||
|                 CoreDomUtils.instance.showErrorModal('core.errorinvalidform', true); | ||||
|             } | ||||
|         } else { | ||||
|             CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, false, this.siteId); | ||||
| 
 | ||||
|             ModalController.instance.dismiss(this.preflightForm.value); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Close modal. | ||||
|      */ | ||||
|     closeModal(): void { | ||||
|         CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, this.siteId); | ||||
| 
 | ||||
|         ModalController.instance.dismiss(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										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', | ||||
|         loadChildren: () => import('./pages/index/index.module').then( m => m.AddonModQuizIndexPageModule), | ||||
|     }, | ||||
|     { | ||||
|         path: 'player/:courseId/:quizId', | ||||
|         loadChildren: () => import('./pages/player/player.module').then( m => m.AddonModQuizPlayerPageModule), | ||||
|     }, | ||||
| ]; | ||||
| 
 | ||||
| @NgModule({ | ||||
|  | ||||
| @ -7,7 +7,7 @@ | ||||
| </ion-item> | ||||
| 
 | ||||
| <!-- Edit. --> | ||||
| <ion-item *ngIf="edit && field && field.shortname && form" text-wrap [formGroup]="form"> | ||||
| <ion-item *ngIf="edit && field && field.shortname && form" class="ion-text-wrap" [formGroup]="form"> | ||||
|     <ion-label position="stacked"> | ||||
|         <span [core-mark-required]="required">{{ field.name }}</span> | ||||
|     </ion-label> | ||||
|  | ||||
| @ -9,7 +9,7 @@ | ||||
| </ion-item> | ||||
| 
 | ||||
| <!-- Edit. --> | ||||
| <ion-item *ngIf="edit && field && field.shortname && form" text-wrap [formGroup]="form"> | ||||
| <ion-item *ngIf="edit && field && field.shortname && form" class="ion-text-wrap" [formGroup]="form"> | ||||
|     <ion-label position="stacked"> | ||||
|         <span [core-mark-required]="required">{{ field.name }}</span> | ||||
|     </ion-label> | ||||
|  | ||||
| @ -9,7 +9,7 @@ | ||||
| </ion-item> | ||||
| 
 | ||||
| <!-- Edit. --> | ||||
| <ion-item *ngIf="edit && field && field.shortname && form" text-wrap [formGroup]="form"> | ||||
| <ion-item *ngIf="edit && field && field.shortname && form" class="ion-text-wrap" [formGroup]="form"> | ||||
|     <ion-label position="stacked"> | ||||
|         <span [core-mark-required]="required">{{ field.name }}</span> | ||||
|     </ion-label> | ||||
|  | ||||
| @ -9,7 +9,7 @@ | ||||
| </ion-item> | ||||
| 
 | ||||
| <!-- Edit. --> | ||||
| <ion-item *ngIf="edit && field && field.shortname" text-wrap [formGroup]="form"> | ||||
| <ion-item *ngIf="edit && field && field.shortname" class="ion-text-wrap" [formGroup]="form"> | ||||
|     <ion-label position="stacked"> | ||||
|         <span [core-mark-required]="required">{{ field.name }}</span> | ||||
|         <core-input-errors [control]="control"></core-input-errors> | ||||
|  | ||||
| @ -69,13 +69,16 @@ export class CoreSite { | ||||
| 
 | ||||
|     // Versions of Moodle releases.
 | ||||
|     protected readonly MOODLE_RELEASES = { | ||||
|         3.1: 2016052300, | ||||
|         3.2: 2016120500, | ||||
|         3.3: 2017051503, | ||||
|         3.4: 2017111300, | ||||
|         3.5: 2018051700, | ||||
|         3.6: 2018120300, | ||||
|         3.7: 2019052000, | ||||
|         '3.1': 2016052300, | ||||
|         '3.2': 2016120500, | ||||
|         '3.3': 2017051503, | ||||
|         '3.4': 2017111300, | ||||
|         '3.5': 2018051700, | ||||
|         '3.6': 2018120300, | ||||
|         '3.7': 2019052000, | ||||
|         '3.8': 2019111800, | ||||
|         '3.9': 2020061500, | ||||
|         '3.10': 2020110900, | ||||
|     }; | ||||
| 
 | ||||
|     // Possible cache update frequencies.
 | ||||
|  | ||||
| @ -32,7 +32,7 @@ import { Translate } from '@singletons'; | ||||
|  * | ||||
|  * Example usage: | ||||
|  * | ||||
|  * <ion-item text-wrap> | ||||
|  * <ion-item class="ion-text-wrap"> | ||||
|  *     <ion-label stacked core-mark-required="true">{{ 'core.login.username' | translate }}</ion-label> | ||||
|  *     <ion-input type="text" name="username" formControlName="username"></ion-input> | ||||
|  *     <core-input-errors item-content [control]="myForm.controls.username" [errorMessages]="usernameErrors"></core-input-errors> | ||||
|  | ||||
| @ -15,7 +15,7 @@ | ||||
| <ion-content class="ion-padding"> | ||||
|     <form (ngSubmit)="submitPassword($event)" #enrolPasswordForm> | ||||
|         <ion-item> | ||||
|             <ion-label> | ||||
|             <ion-label></ion-label> | ||||
|             <core-show-password name="password"> | ||||
|                 <ion-input | ||||
|                     class="ion-text-wrap core-ioninput-password" | ||||
| @ -27,7 +27,6 @@ | ||||
|                     [clearOnEdit]="false"> | ||||
|                 </ion-input> | ||||
|             </core-show-password> | ||||
|             </ion-label> | ||||
|         </ion-item> | ||||
|         <div class="ion-padding"> | ||||
|             <ion-button expand="block" [disabled]="!password" type="submit">{{ 'core.courses.enrolme' | translate }}</ion-button> | ||||
|  | ||||
| @ -34,6 +34,7 @@ import { CoreFileUploaderDelegate } from './fileuploader-delegate'; | ||||
| import { CoreCaptureError } from '@classes/errors/captureerror'; | ||||
| import { CoreIonLoadingElement } from '@classes/ion-loading'; | ||||
| import { CoreWSUploadFileResult } from '@services/ws'; | ||||
| import { CoreSites } from '@services/sites'; | ||||
| 
 | ||||
| /** | ||||
|  * Helper service to upload files. | ||||
| @ -738,6 +739,14 @@ export class CoreFileUploaderHelperProvider { | ||||
|         allowOffline?: boolean, | ||||
|         name?: string, | ||||
|     ): Promise<CoreWSUploadFileResult | FileEntry> { | ||||
|         if (maxSize === 0) { | ||||
|             const siteInfo = CoreSites.instance.getCurrentSite()?.getInfo(); | ||||
| 
 | ||||
|             if (siteInfo && siteInfo.usermaxuploadfilesize) { | ||||
|                 maxSize = siteInfo.usermaxuploadfilesize; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         if (maxSize !== undefined && maxSize != -1 && file.size > maxSize) { | ||||
|             throw this.createMaxBytesError(maxSize, file.name); | ||||
|         } | ||||
|  | ||||
| @ -38,14 +38,13 @@ | ||||
|                 </ion-label> | ||||
|             </ion-item> | ||||
|             <ion-item *ngIf="siteChecked && !isBrowserSSO" class="ion-margin-bottom"> | ||||
|                 <ion-label> | ||||
|                 <ion-label></ion-label> | ||||
|                 <core-show-password name="password"> | ||||
|                     <ion-input name="password" type="password" placeholder="{{ 'core.login.password' | translate }}" | ||||
|                         formControlName="password" [clearOnEdit]="false" autocomplete="current-password" enterkeyhint="go" | ||||
|                         required="true"> | ||||
|                     </ion-input> | ||||
|                 </core-show-password> | ||||
|                 </ion-label> | ||||
|             </ion-item> | ||||
|             <ion-button expand="block" type="submit" [disabled]="siteChecked && !isBrowserSSO && !credForm.valid" | ||||
|                 class="ion-margin core-login-login-button"> | ||||
|  | ||||
| @ -39,14 +39,13 @@ | ||||
|             </ion-label> | ||||
|         </ion-item> | ||||
|         <ion-item class="ion-margin-bottom"> | ||||
|             <ion-label> | ||||
|             <ion-label></ion-label> | ||||
|             <core-show-password name="password"> | ||||
|                 <ion-input class="core-ioninput-password" name="password" type="password" | ||||
|                     placeholder="{{ 'core.login.password' | translate }}" formControlName="password" [clearOnEdit]="false" | ||||
|                     autocomplete="current-password" enterkeyhint="go" required="true"> | ||||
|                 </ion-input> | ||||
|             </core-show-password> | ||||
|             </ion-label> | ||||
|         </ion-item> | ||||
|         <ion-grid class="ion-padding"> | ||||
|             <ion-row> | ||||
|  | ||||
| @ -53,7 +53,7 @@ | ||||
|                         <ion-icon name="fas-pen"></ion-icon> | ||||
|                     </ion-thumbnail> | ||||
|                     <ion-label> | ||||
|                         <h2 text-wrap>{{ 'core.login.yourenteredsite' | translate }}</h2> | ||||
|                         <h2 class="ion-text-wrap">{{ 'core.login.yourenteredsite' | translate }}</h2> | ||||
|                         <p>{{enteredSiteUrl.noProtocolUrl}}</p> | ||||
|                     </ion-label> | ||||
|                 </ion-item> | ||||
| @ -75,7 +75,7 @@ | ||||
| 
 | ||||
|         <ion-item *ngIf="siteSelector == 'url'" lines="none"> | ||||
|             <ion-label> | ||||
|                 <ion-button expand="block" [disabled]="!siteForm.valid" text-wrap> | ||||
|                 <ion-button expand="block" [disabled]="!siteForm.valid" class="ion-text-wrap"> | ||||
|                     {{ 'core.login.connect' | translate }} | ||||
|                 </ion-button> | ||||
|             </ion-label> | ||||
| @ -123,7 +123,7 @@ | ||||
|             <img src="assets/icon/icon.png" *ngIf="!site.imageurl" class="core-login-default-icon"> | ||||
|         </ion-thumbnail> | ||||
|         <ion-label> | ||||
|             <h2 *ngIf="site.title" text-wrap>{{site.title}}</h2> | ||||
|             <h2 *ngIf="site.title" class="ion-text-wrap">{{site.title}}</h2> | ||||
|             <p *ngIf="site.noProtocolUrl">{{site.noProtocolUrl}}</p> | ||||
|             <p *ngIf="site.location">{{site.location}}</p> | ||||
|         </ion-label> | ||||
|  | ||||
| @ -143,6 +143,7 @@ | ||||
|     "loadmore": "Load more", | ||||
|     "location": "Location", | ||||
|     "lostconnection": "Your authentication token is invalid or has expired. You will have to reconnect to the site.", | ||||
|     "maxfilesize": "Maximum size for new files: {{$a}}", | ||||
|     "maxsizeandattachments": "Maximum file size: {{$a.size}}, maximum number of files: {{$a.attachments}}", | ||||
|     "min": "min", | ||||
|     "mins": "mins", | ||||
|  | ||||
| @ -2838,10 +2838,9 @@ export class CoreFilepoolProvider { | ||||
|         let previousStatus: string | undefined; | ||||
|         // Search current status to set it as previous status.
 | ||||
|         try { | ||||
|             const entry = <CoreFilepoolPackageEntry> site.getDb().getRecord(PACKAGES_TABLE_NAME, { id: packageId }); | ||||
|             if (typeof extra == 'undefined' || extra === null) { | ||||
|                 extra = entry.extra; | ||||
|             } | ||||
|             const entry = await site.getDb().getRecord<CoreFilepoolPackageEntry>(PACKAGES_TABLE_NAME, { id: packageId }); | ||||
| 
 | ||||
|             extra = extra ?? entry.extra; | ||||
|             if (typeof downloadTime == 'undefined') { | ||||
|                 // Keep previous download time.
 | ||||
|                 downloadTime = entry.downloadTime; | ||||
|  | ||||
| @ -141,6 +141,8 @@ $colors-dark:  ( | ||||
| 
 | ||||
| $core-course-image-background: #81ecec, #74b9ff, #a29bfe, #dfe6e9, #00b894, #0984e3, #b2bec3, #fdcb6e, #fd79a8, #6c5ce7 !default; | ||||
| 
 | ||||
| $core-dd-question-colors: $white, $blue-light, #DCDCDC, #D8BFD8, #87CEFA, #DAA520, #FFD700, #F0E68C !default; | ||||
| 
 | ||||
| /* | ||||
|  * Layout Breakpoints | ||||
|  * | ||||
|  | ||||
| @ -432,3 +432,8 @@ ion-button.core-button-select { | ||||
|     font-weight: normal; | ||||
|     font-size: 1em; | ||||
| } | ||||
| 
 | ||||
| // Monospaced font. | ||||
| .core-monospaced { | ||||
|     font-family: Andale Mono,Monaco,Courier New,DejaVu Sans Mono,monospace; | ||||
| } | ||||
|  | ||||
| @ -38,7 +38,6 @@ | ||||
|     --yellow:          #{$yellow}; | ||||
|     --yellow-light:    #{$yellow-light}; | ||||
|     --yellow-dark:     #{$yellow-dark}; | ||||
| 
 | ||||
|     --purple:          #{$purple}; | ||||
| 
 | ||||
|     --core-color:      #{$core-color}; | ||||
| @ -188,4 +187,23 @@ | ||||
| 
 | ||||
|     --core-menu-box-shadow-end: var(--custom-menu-box-shadow-end, -4px 0px 16px rgba(0, 0, 0, 0.18)); | ||||
|     --core-menu-box-shadow-start: var(--custom-menu-box-shadow-start, 4px 0px 16px rgba(0, 0, 0, 0.18)); | ||||
| 
 | ||||
|     --core-question-correct-color: var(--green-dark); | ||||
|     --core-question-correct-color-bg: var(--green-light); | ||||
|     --core-question-incorrect-color: var(--red); | ||||
|     --core-question-incorrect-color-bg: var(--red-light); | ||||
|     --core-question-feedback-color: var(--yellow-dark); | ||||
|     --core-question-feedback-color-bg: var(--yellow-light); | ||||
|     --core-question-warning-color: var(--red); | ||||
|     --core-question-saved-color-bg: var(--gray-light); | ||||
| 
 | ||||
|     --core-question-state-correct-color: var(--green-light); | ||||
|     --core-question-state-partial-color: var(--yellow-light); | ||||
|     --core-question-state-partial-text: var(--yellow); | ||||
|     --core-question-state-incorrect-color: var(--red-light); | ||||
| 
 | ||||
|     --core-question-feedback-color: var(--yellow-dark); | ||||
|     --core-question-feedback-background-color: var(--yellow-light); | ||||
| 
 | ||||
|     --core-dd-question-selected-shadow: 2px 2px 4px var(--gray-dark); | ||||
| } | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user