diff --git a/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html b/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html index 4f4f7b741..ab10934b6 100644 --- a/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html +++ b/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html @@ -64,7 +64,7 @@ {{ 'addon.mod_lesson.continue' | translate }} - + diff --git a/src/addons/mod/lesson/components/menu-modal/menu-modal.html b/src/addons/mod/lesson/components/menu-modal/menu-modal.html index b442fb3b3..602349fd7 100644 --- a/src/addons/mod/lesson/components/menu-modal/menu-modal.html +++ b/src/addons/mod/lesson/components/menu-modal/menu-modal.html @@ -4,7 +4,7 @@ - + diff --git a/src/addons/mod/lesson/components/password-modal/password-modal.html b/src/addons/mod/lesson/components/password-modal/password-modal.html index 9b9ff4658..c09d93d0f 100644 --- a/src/addons/mod/lesson/components/password-modal/password-modal.html +++ b/src/addons/mod/lesson/components/password-modal/password-modal.html @@ -4,7 +4,7 @@ - + @@ -20,7 +20,7 @@ {{ 'addon.mod_lesson.continue' | translate }} - + diff --git a/src/addons/mod/mod.module.ts b/src/addons/mod/mod.module.ts index 9a8f91414..92f8e574f 100644 --- a/src/addons/mod/mod.module.ts +++ b/src/addons/mod/mod.module.ts @@ -18,6 +18,7 @@ import { AddonModAssignModule } from './assign/assign.module'; import { AddonModBookModule } from './book/book.module'; import { AddonModLessonModule } from './lesson/lesson.module'; import { AddonModPageModule } from './page/page.module'; +import { AddonModQuizModule } from './quiz/quiz.module'; @NgModule({ declarations: [], @@ -26,6 +27,7 @@ import { AddonModPageModule } from './page/page.module'; AddonModBookModule, AddonModLessonModule, AddonModPageModule, + AddonModQuizModule, ], providers: [], exports: [], diff --git a/src/addons/mod/quiz/components/components.module.ts b/src/addons/mod/quiz/components/components.module.ts new file mode 100644 index 000000000..ef510db4b --- /dev/null +++ b/src/addons/mod/quiz/components/components.module.ts @@ -0,0 +1,38 @@ +// (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 { NgModule } from '@angular/core'; + +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'; + +@NgModule({ + declarations: [ + AddonModQuizIndexComponent, + AddonModQuizConnectionErrorComponent, + ], + imports: [ + CoreSharedModule, + CoreCourseComponentsModule, + ], + providers: [ + ], + exports: [ + AddonModQuizIndexComponent, + AddonModQuizConnectionErrorComponent, + ], +}) +export class AddonModQuizComponentsModule {} diff --git a/src/addons/mod/quiz/components/index/addon-mod-quiz-index.html b/src/addons/mod/quiz/components/index/addon-mod-quiz-index.html new file mode 100644 index 000000000..52c209fa2 --- /dev/null +++ b/src/addons/mod/quiz/components/index/addon-mod-quiz-index.html @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

{{ rule }}

+
+ + +

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

+

{{ gradeMethodReadable }}

+
+
+ + +

{{ 'core.lastsync' | translate }}

+

{{ syncTime }}

+
+
+
+
+ + + + + + {{ 'addon.mod_quiz.summaryofattempts' | translate }} + + + + + + + + + {{ 'addon.mod_quiz.attemptnumber' | translate }} + + # + {{ 'addon.mod_quiz.attemptstate' | translate }} + + {{ 'addon.mod_quiz.marks' | translate }} / {{ quiz.sumGradesFormatted }} + + + {{ 'addon.mod_quiz.grade' | translate }} / {{ quiz.gradeFormatted }} + + + + + + + + + + {{ 'addon.mod_quiz.preview' | translate }} + + + {{ attempt.attempt }} + + +

{{ sentence }}

+
+ +

{{ attempt.readableMark }}

+
+

{{ attempt.readableGrade }}

+
+
+
+
+
+ + + + + + {{ gradeResult }} + + + {{ 'core.course.overriddennotice' | translate }} + + + +

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

+

+

+
+
+ + +

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

+

+

+
+
+
+
+ + + + + + +

{{ message }}

+
+ +

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

+
+ + +

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

+

{{ type }}

+
+
+ + +

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

+

{{ name }}

+
+
+ + +

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

+

{{ quiz.preferredbehaviour }}

+
+
+ + + + + + {{ 'core.hasdatatosync' | translate: {$a: moduleName} }} + + + + + + +

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

+

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

+

{{ type }}

+
+
+ + + + {{ buttonText | translate }} + + + + + {{ 'core.openinbrowser' | translate }} + + + + + + + +
+
+
diff --git a/src/addons/mod/quiz/components/index/index.scss b/src/addons/mod/quiz/components/index/index.scss new file mode 100644 index 000000000..ead659221 --- /dev/null +++ b/src/addons/mod/quiz/components/index/index.scss @@ -0,0 +1,44 @@ +:host { + + .addon-mod_quiz-table { + .addon-mod_quiz-table-header { + --detail-icon-opacity: 0; + } + + ion-card-content { + padding-left: 0; + padding-right: 0; + } + + .item:nth-child(even) { + --background: var(--gray-lighter); + // @include darkmode() { + // background-color: $core-dark-item-divider-bg-color; + // } + } + + .addon-mod_quiz-highlighted, + .item.addon-mod_quiz-highlighted, + .addon-mod_quiz-highlighted p, + .item.addon-mod_quiz-highlighted p { + --background: var(--blue-light); + color: var(--blue-dark); + } + + // @include darkmode() { + // .addon-mod_quiz-highlighted, + // .item.addon-mod_quiz-highlighted, + // .addon-mod_quiz-highlighted p, + // .item.addon-mod_quiz-highlighted p { + // background-color: $blue-dark; + // color: $blue-light; + // } + + // .item.addon-mod_quiz-highlighted.activated, + // .item.addon-mod_quiz-highlighted.activated p { + // background-color: $blue; + // color: $blue-light; + // } + // } + } +} diff --git a/src/addons/mod/quiz/components/index/index.ts b/src/addons/mod/quiz/components/index/index.ts new file mode 100644 index 000000000..19912e9bb --- /dev/null +++ b/src/addons/mod/quiz/components/index/index.ts @@ -0,0 +1,658 @@ +// (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 { CoreConstants } from '@/core/constants'; +import { Component, OnDestroy, OnInit, Optional } from '@angular/core'; + +import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; +import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; +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 { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { Translate } from '@singletons'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { AddonModQuizPrefetchHandler } from '../../services/handlers/prefetch'; +import { + AddonModQuiz, + AddonModQuizAttemptFinishedData, + AddonModQuizAttemptWSData, + AddonModQuizCombinedReviewOptions, + AddonModQuizGetAttemptAccessInformationWSResponse, + AddonModQuizGetQuizAccessInformationWSResponse, + AddonModQuizGetUserBestGradeWSResponse, + AddonModQuizProvider, +} from '../../services/quiz'; +import { AddonModQuizAttempt, AddonModQuizHelper, AddonModQuizQuizData } from '../../services/quiz-helper'; +import { + AddonModQuizAutoSyncData, + AddonModQuizSync, + AddonModQuizSyncProvider, + AddonModQuizSyncResult, +} from '../../services/quiz-sync'; + +/** + * Component that displays a quiz entry page. + */ +@Component({ + selector: 'addon-mod-quiz-index', + templateUrl: 'addon-mod-quiz-index.html', + styleUrls: ['index.scss'], +}) +export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit, OnDestroy { + + component = AddonModQuizProvider.COMPONENT; + moduleName = 'quiz'; + quiz?: AddonModQuizQuizData; // The quiz. + now?: number; // Current time. + syncTime?: string; // Last synchronization time. + hasOffline = false; // Whether the quiz has offline data. + hasSupportedQuestions = false; // Whether the quiz has at least 1 supported question. + accessRules: string[] = []; // List of access rules of the quiz. + unsupportedRules: string[] = []; // List of unsupported access rules of the quiz. + unsupportedQuestions: string[] = []; // List of unsupported question types of the quiz. + behaviourSupported = false; // Whether the quiz behaviour is supported. + showResults = false; // Whether to show the result of the quiz (grade, etc.). + gradeOverridden = false; // Whether grade has been overridden. + gradebookFeedback?: string; // The feedback in the gradebook. + gradeResult?: string; // Message with the grade. + overallFeedback?: string; // The feedback for the grade. + buttonText?: string; // Text to display in the start/continue button. + preventMessages: string[] = []; // List of messages explaining why the quiz cannot be attempted. + showStatusSpinner = true; // Whether to show a spinner due to quiz status. + gradeMethodReadable?: string; // Grade method in a readable format. + showReviewColumn = false; // Whether to show the review column. + attempts: AddonModQuizAttempt[] = []; // List of attempts the user has made. + + protected fetchContentDefaultError = 'addon.mod_quiz.errorgetquiz'; // Default error to show when loading contents. + protected syncEventName = AddonModQuizSyncProvider.AUTO_SYNCED; + + // protected quizData: any; // Quiz instance. This variable will store the quiz instance until it's ready to be shown + protected autoReview?: AddonModQuizAttemptFinishedData; // Data to auto-review an attempt after finishing. + protected quizAccessInfo?: AddonModQuizGetQuizAccessInformationWSResponse; // Quiz access info. + protected attemptAccessInfo?: AddonModQuizGetAttemptAccessInformationWSResponse; // Last attempt access info. + protected moreAttempts = false; // Whether user can create/continue attempts. + protected options?: AddonModQuizCombinedReviewOptions; // Combined review options. + protected bestGrade?: AddonModQuizGetUserBestGradeWSResponse; // Best grade data. + protected gradebookData?: { grade?: number; feedback?: string }; // The gradebook grade and feedback. + protected overallStats = false; // Equivalent to overallstats in mod_quiz_view_object in Moodle. + protected finishedObserver?: CoreEventObserver; // It will observe attempt finished events. + protected hasPlayed = false; // Whether the user has gone to the quiz player (attempted). + protected candidateQuiz?: AddonModQuizQuizData; + + constructor( + protected content?: IonContent, + @Optional() courseContentsPage?: CoreCourseContentsPage, + ) { + super('AddonModQuizIndexComponent', content, courseContentsPage); + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + super.ngOnInit(); + + // Listen for attempt finished events. + this.finishedObserver = CoreEvents.on( + AddonModQuizProvider.ATTEMPT_FINISHED_EVENT, + (data) => { + // Go to review attempt if an attempt in this quiz was finished and synced. + if (this.quiz && data.quizId == this.quiz.id) { + this.autoReview = data; + } + }, + this.siteId, + ); + + await this.loadContent(false, true); + + if (!this.quiz) { + return; + } + + try { + await AddonModQuiz.instance.logViewQuiz(this.quiz.id, this.quiz.name); + + CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata); + } catch { + // Ignore errors. + } + } + + /** + * Attempt the quiz. + */ + async attemptQuiz(): Promise { + if (this.showStatusSpinner || !this.quiz) { + // Quiz is being downloaded or synchronized, abort. + return; + } + + if (!AddonModQuiz.instance.isQuizOffline(this.quiz)) { + // Quiz isn't offline, just open it. + return this.openQuiz(); + } + + // Quiz supports offline, check if it needs to be downloaded. + // If the site doesn't support check updates, always prefetch it because we cannot tell if there's something new. + const isDownloaded = this.currentStatus == CoreConstants.DOWNLOADED; + + if (isDownloaded && CoreCourseModulePrefetchDelegate.instance.canCheckUpdates()) { + // Already downloaded, open it. + return this.openQuiz(); + } + + // Prefetch the quiz. + this.showStatusSpinner = true; + + try { + await AddonModQuizPrefetchHandler.instance.prefetch(this.module!, this.courseId, true); + + // Success downloading, open quiz. + this.openQuiz(); + } catch (error) { + if (this.hasOffline || (isDownloaded && !CoreCourseModulePrefetchDelegate.instance.canCheckUpdates())) { + // Error downloading but there is something offline, allow continuing it. + // If the site doesn't support check updates, continue too because we cannot tell if there's something new. + this.openQuiz(); + } else { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.errordownloading', true); + } + } finally { + this.showStatusSpinner = false; + } + } + + /** + * Get the quiz data. + * + * @param refresh If it's refreshing content. + * @param sync If it should try to sync. + * @param showErrors If show errors to the user of hide them. + * @return Promise resolved when done. + */ + protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { + try { + // First get the quiz instance. + const quiz = await AddonModQuiz.instance.getQuiz(this.courseId!, this.module!.id); + + this.gradeMethodReadable = AddonModQuiz.instance.getQuizGradeMethod(quiz.grademethod); + this.now = Date.now(); + this.dataRetrieved.emit(quiz); + this.description = quiz.intro || this.description; + this.candidateQuiz = quiz; + + // Try to get warnings from automatic sync. + const warnings = await AddonModQuizSync.instance.getSyncWarnings(quiz.id); + + if (warnings?.length) { + // Show warnings and delete them so they aren't shown again. + CoreDomUtils.instance.showErrorModal(CoreTextUtils.instance.buildMessage(warnings)); + + await AddonModQuizSync.instance.setSyncWarnings(quiz.id, []); + } + + if (AddonModQuiz.instance.isQuizOffline(quiz) && sync) { + // Try to sync the quiz. + try { + await this.syncActivity(showErrors); + } catch { + // Ignore errors, keep getting data even if sync fails. + this.autoReview = undefined; + } + } else { + this.autoReview = undefined; + this.showStatusSpinner = false; + } + + if (AddonModQuiz.instance.isQuizOffline(quiz)) { + // Handle status. + this.setStatusListener(); + + // Get last synchronization time and check if sync button should be seen. + this.syncTime = await AddonModQuizSync.instance.getReadableSyncTime(quiz.id); + this.hasOffline = await AddonModQuizSync.instance.hasDataToSync(quiz.id); + } + + // Get quiz access info. + this.quizAccessInfo = await AddonModQuiz.instance.getQuizAccessInformation(quiz.id, { cmId: this.module!.id }); + + this.showReviewColumn = this.quizAccessInfo.canreviewmyattempts; + this.accessRules = this.quizAccessInfo.accessrules; + this.unsupportedRules = AddonModQuiz.instance.getUnsupportedRules(this.quizAccessInfo.activerulenames); + + if (quiz.preferredbehaviour) { + this.behaviourSupported = CoreQuestionBehaviourDelegate.instance.isBehaviourSupported(quiz.preferredbehaviour); + } + + // Get question types in the quiz. + const types = await AddonModQuiz.instance.getQuizRequiredQtypes(quiz.id, { cmId: this.module!.id }); + + this.unsupportedQuestions = AddonModQuiz.instance.getUnsupportedQuestions(types); + this.hasSupportedQuestions = !!types.find((type) => type != 'random' && this.unsupportedQuestions.indexOf(type) == -1); + + await this.getAttempts(quiz); + + // Quiz is ready to be shown, move it to the variable that is displayed. + this.quiz = quiz; + } finally { + this.fillContextMenu(refresh); + } + } + + /** + * Get the user attempts in the quiz and the result info. + * + * @param quiz Quiz instance. + * @return Promise resolved when done. + */ + protected async getAttempts(quiz: AddonModQuizQuizData): Promise { + + // Get access information of last attempt (it also works if no attempts made). + this.attemptAccessInfo = await AddonModQuiz.instance.getAttemptAccessInformation(quiz.id, 0, { cmId: this.module!.id }); + + // Get attempts. + const attempts = await AddonModQuiz.instance.getUserAttempts(quiz.id, { cmId: this.module!.id }); + + this.attempts = await this.treatAttempts(quiz, attempts); + + // Check if user can create/continue attempts. + if (this.attempts.length) { + const last = this.attempts[this.attempts.length - 1]; + this.moreAttempts = !AddonModQuiz.instance.isAttemptFinished(last.state) || !this.attemptAccessInfo.isfinished; + } else { + this.moreAttempts = !this.attemptAccessInfo.isfinished; + } + + this.getButtonText(quiz); + + await this.getResultInfo(quiz); + } + + /** + * Get the text to show in the button. It also sets restriction messages if needed. + * + * @param quiz Quiz. + */ + protected getButtonText(quiz: AddonModQuizQuizData): void { + this.buttonText = ''; + + if (quiz.hasquestions !== 0) { + if (this.attempts.length && !AddonModQuiz.instance.isAttemptFinished(this.attempts[this.attempts.length - 1].state)) { + // Last attempt is unfinished. + if (this.quizAccessInfo?.canattempt) { + this.buttonText = 'addon.mod_quiz.continueattemptquiz'; + } else if (this.quizAccessInfo?.canpreview) { + this.buttonText = 'addon.mod_quiz.continuepreview'; + } + + } else { + // Last attempt is finished or no attempts. + if (this.quizAccessInfo?.canattempt) { + this.preventMessages = this.attemptAccessInfo?.preventnewattemptreasons || []; + if (!this.preventMessages.length) { + if (!this.attempts.length) { + this.buttonText = 'addon.mod_quiz.attemptquiznow'; + } else { + this.buttonText = 'addon.mod_quiz.reattemptquiz'; + } + } + } else if (this.quizAccessInfo?.canpreview) { + this.buttonText = 'addon.mod_quiz.previewquiznow'; + } + } + } + + if (!this.buttonText) { + return; + } + + // So far we think a button should be printed, check if they will be allowed to access it. + this.preventMessages = this.quizAccessInfo?.preventaccessreasons || []; + + if (!this.moreAttempts) { + this.buttonText = ''; + } else if (this.quizAccessInfo?.canattempt && this.preventMessages.length) { + this.buttonText = ''; + } else if (!this.hasSupportedQuestions || this.unsupportedRules.length || !this.behaviourSupported) { + this.buttonText = ''; + } + } + + /** + * Get result info to show. + * + * @param quiz Quiz. + * @return Promise resolved when done. + */ + protected async getResultInfo(quiz: AddonModQuizQuizData): Promise { + + if (!this.attempts.length || !quiz.showGradeColumn || !this.bestGrade?.hasgrade || + this.gradebookData?.grade === undefined) { + this.showResults = false; + + return; + } + + const formattedGradebookGrade = AddonModQuiz.instance.formatGrade(this.gradebookData.grade, quiz.decimalpoints); + const formattedBestGrade = AddonModQuiz.instance.formatGrade(this.bestGrade.grade, quiz.decimalpoints); + let gradeToShow = formattedGradebookGrade; // By default we show the grade in the gradebook. + + this.showResults = true; + this.gradeOverridden = formattedGradebookGrade != formattedBestGrade; + this.gradebookFeedback = this.gradebookData.feedback; + + if (this.bestGrade.grade! > this.gradebookData.grade && this.gradebookData.grade == quiz.grade) { + // The best grade is higher than the max grade for the quiz. + // We'll do like Moodle web and show the best grade instead of the gradebook grade. + this.gradeOverridden = false; + gradeToShow = formattedBestGrade; + } + + if (this.overallStats) { + // Show the quiz grade. The message shown is different if the quiz is finished. + if (this.moreAttempts) { + this.gradeResult = Translate.instance.instant('addon.mod_quiz.gradesofar', { $a: { + method: this.gradeMethodReadable, + mygrade: gradeToShow, + quizgrade: quiz.gradeFormatted, + } }); + } else { + const outOfShort = Translate.instance.instant('addon.mod_quiz.outofshort', { $a: { + grade: gradeToShow, + maxgrade: quiz.gradeFormatted, + } }); + + this.gradeResult = Translate.instance.instant('addon.mod_quiz.yourfinalgradeis', { $a: outOfShort }); + } + } + + if (quiz.showFeedbackColumn) { + // Get the quiz overall feedback. + const response = await AddonModQuiz.instance.getFeedbackForGrade(quiz.id, this.gradebookData.grade, { + cmId: this.module!.id, + }); + + this.overallFeedback = response.feedbacktext; + } + } + + /** + * Go to review an attempt that has just been finished. + * + * @return Promise resolved when done. + */ + protected async goToAutoReview(): Promise { + if (!this.autoReview) { + return; + } + + // If we go to auto review it means an attempt was finished. Check completion status. + CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata); + + // Verify that user can see the review. + const attemptId = this.autoReview.attemptId; + + if (this.quizAccessInfo?.canreviewmyattempts) { + try { + await AddonModQuiz.instance.getAttemptReview(attemptId, { page: -1, cmId: this.module!.id }); + + // @todo this.navCtrl.push('AddonModQuizReviewPage', { courseId: this.courseId, quizId: quiz!.id, attemptId }); + } catch { + // Ignore errors. + } + } + } + + /** + * Checks if sync has succeed from result sync data. + * + * @param result Data returned on the sync function. + * @return If suceed or not. + */ + protected hasSyncSucceed(result: AddonModQuizSyncResult): boolean { + if (result.attemptFinished) { + // An attempt was finished, check completion status. + CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata); + } + + // If the sync call isn't rejected it means the sync was successful. + return result.updated; + } + + /** + * User entered the page that contains the component. + */ + async ionViewDidEnter(): Promise { + super.ionViewDidEnter(); + + if (!this.hasPlayed) { + this.autoReview = undefined; + + return; + } + + this.hasPlayed = false; + let promise = Promise.resolve(); + + // Update data when we come back from the player since the attempt status could have changed. + // Check if we need to go to review an attempt automatically. + if (this.autoReview && this.autoReview.synced) { + promise = this.goToAutoReview(); + this.autoReview = undefined; + } + + // Refresh data. + this.loaded = false; + this.refreshIcon = CoreConstants.ICON_LOADING; + this.syncIcon = CoreConstants.ICON_LOADING; + this.content?.scrollToTop(); + + await promise; + await CoreUtils.instance.ignoreErrors(this.refreshContent()); + + this.loaded = true; + this.refreshIcon = CoreConstants.ICON_REFRESH; + this.syncIcon = CoreConstants.ICON_SYNC; + } + + /** + * User left the page that contains the component. + */ + ionViewDidLeave(): void { + super.ionViewDidLeave(); + this.autoReview = undefined; + } + + /** + * Perform the invalidate content function. + * + * @return Resolved when done. + */ + protected async invalidateContent(): Promise { + const promises: Promise[] = []; + + promises.push(AddonModQuiz.instance.invalidateQuizData(this.courseId!)); + + if (this.quiz) { + promises.push(AddonModQuiz.instance.invalidateUserAttemptsForUser(this.quiz.id)); + promises.push(AddonModQuiz.instance.invalidateQuizAccessInformation(this.quiz.id)); + promises.push(AddonModQuiz.instance.invalidateQuizRequiredQtypes(this.quiz.id)); + promises.push(AddonModQuiz.instance.invalidateAttemptAccessInformation(this.quiz.id)); + promises.push(AddonModQuiz.instance.invalidateCombinedReviewOptionsForUser(this.quiz.id)); + promises.push(AddonModQuiz.instance.invalidateUserBestGradeForUser(this.quiz.id)); + promises.push(AddonModQuiz.instance.invalidateGradeFromGradebook(this.courseId!)); + } + + await Promise.all(promises); + } + + /** + * Compares sync event data with current data to check if refresh content is needed. + * + * @param syncEventData Data receiven on sync observer. + * @return True if refresh is needed, false otherwise. + */ + protected isRefreshSyncNeeded(syncEventData: AddonModQuizAutoSyncData): boolean { + if (!this.courseId || !this.module) { + return false; + } + + if (syncEventData.attemptFinished) { + // An attempt was finished, check completion status. + CoreCourse.instance.checkModuleCompletion(this.courseId, this.module.completiondata); + } + + if (this.quiz && syncEventData.quizId == this.quiz.id) { + this.content?.scrollToTop(); + + return true; + } + + return false; + } + + /** + * Open a quiz to attempt it. + */ + protected openQuiz(): void { + this.hasPlayed = true; + + // @todo this.navCtrl.push('player', {courseId: this.courseId, quizId: this.quiz.id, moduleUrl: this.module.url}); + } + + /** + * Displays some data based on the current status. + * + * @param status The current status. + * @param previousStatus The previous status. If not defined, there is no previous status. + */ + protected showStatus(status: string, previousStatus?: string): void { + this.showStatusSpinner = status == CoreConstants.DOWNLOADING; + + if (status == CoreConstants.DOWNLOADED && previousStatus == CoreConstants.DOWNLOADING) { + // Quiz downloaded now, maybe a new attempt was created. Load content again. + this.loaded = false; + this.loadContent(); + } + } + + /** + * Performs the sync of the activity. + * + * @return Promise resolved when done. + */ + protected sync(): Promise { + return AddonModQuizSync.instance.syncQuiz(this.candidateQuiz!, true); + } + + /** + * Treat user attempts. + * + * @param attempts The attempts to treat. + * @return Promise resolved when done. + */ + protected async treatAttempts( + quiz: AddonModQuizQuizData, + attempts: AddonModQuizAttemptWSData[], + ): Promise { + if (!attempts || !attempts.length) { + // There are no attempts to treat. + return []; + } + + const lastFinished = AddonModQuiz.instance.getLastFinishedAttemptFromList(attempts); + const promises: Promise[] = []; + + if (this.autoReview && lastFinished && lastFinished.id >= this.autoReview.attemptId) { + // User just finished an attempt in offline and it seems it's been synced, since it's finished in online. + // Go to the review of this attempt if the user hasn't left this view. + if (!this.isDestroyed && this.isCurrentView) { + promises.push(this.goToAutoReview()); + } + this.autoReview = undefined; + } + + // Get combined review options. + promises.push(AddonModQuiz.instance.getCombinedReviewOptions(quiz.id, { cmId: this.module!.id }).then((options) => { + this.options = options; + + return; + })); + + // Get best grade. + promises.push(this.getQuizGrade(quiz)); + + await Promise.all(promises); + + const grade = typeof this.gradebookData?.grade != 'undefined' ? this.gradebookData.grade : this.bestGrade?.grade; + const quizGrade = AddonModQuiz.instance.formatGrade(grade, quiz.decimalpoints); + + // Calculate data to construct the header of the attempts table. + AddonModQuizHelper.instance.setQuizCalculatedData(quiz, this.options!); + + this.overallStats = !!lastFinished && this.options!.alloptions.marks >= AddonModQuizProvider.QUESTION_OPTIONS_MARK_AND_MAX; + + // Calculate data to show for each attempt. + const formattedAttempts = await Promise.all(attempts.map((attempt, index) => { + // Highlight the highest grade if appropriate. + const shouldHighlight = this.overallStats && quiz.grademethod == AddonModQuizProvider.GRADEHIGHEST && + attempts.length > 1; + const isLast = index == attempts.length - 1; + + return AddonModQuizHelper.instance.setAttemptCalculatedData(quiz, attempt, shouldHighlight, quizGrade, isLast); + })); + + return formattedAttempts; + } + + /** + * Get quiz grade data. + * + * @param quiz Quiz. + * @return Promise resolved when done. + */ + protected async getQuizGrade(quiz: AddonModQuizQuizData): Promise { + this.bestGrade = await AddonModQuiz.instance.getUserBestGrade(quiz.id, { cmId: this.module!.id }); + + try { + // Get gradebook grade. + const data = await AddonModQuiz.instance.getGradeFromGradebook(this.courseId!, this.module!.id); + + this.gradebookData = { + grade: data.graderaw, + feedback: data.feedback, + }; + } catch { + // Fallback to quiz best grade if failure or not found. + this.gradebookData = { + grade: this.bestGrade.grade, + }; + } + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + super.ngOnDestroy(); + + this.finishedObserver?.off(); + } + +} diff --git a/src/addons/mod/quiz/pages/index/index.html b/src/addons/mod/quiz/pages/index/index.html new file mode 100644 index 000000000..51d75b733 --- /dev/null +++ b/src/addons/mod/quiz/pages/index/index.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/addons/mod/quiz/pages/index/index.module.ts b/src/addons/mod/quiz/pages/index/index.module.ts new file mode 100644 index 000000000..6cf37c942 --- /dev/null +++ b/src/addons/mod/quiz/pages/index/index.module.ts @@ -0,0 +1,40 @@ +// (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 { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonModQuizComponentsModule } from '../../components/components.module'; +import { AddonModQuizIndexPage } from './index'; + +const routes: Routes = [ + { + path: '', + component: AddonModQuizIndexPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CoreSharedModule, + AddonModQuizComponentsModule, + ], + declarations: [ + AddonModQuizIndexPage, + ], + exports: [RouterModule], +}) +export class AddonModQuizIndexPageModule {} diff --git a/src/addons/mod/quiz/pages/index/index.ts b/src/addons/mod/quiz/pages/index/index.ts new file mode 100644 index 000000000..7bfccfd4b --- /dev/null +++ b/src/addons/mod/quiz/pages/index/index.ts @@ -0,0 +1,69 @@ +// (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 } from '@angular/core'; + +import { CoreCourseWSModule } from '@features/course/services/course'; +import { CoreNavigator } from '@services/navigator'; +import { AddonModQuizIndexComponent } from '../../components/index'; +import { AddonModQuizQuizWSData } from '../../services/quiz'; + +/** + * Page that displays the quiz entry page. + */ +@Component({ + selector: 'page-addon-mod-quiz-index', + templateUrl: 'index.html', +}) +export class AddonModQuizIndexPage implements OnInit { + + @ViewChild(AddonModQuizIndexComponent) quizComponent?: AddonModQuizIndexComponent; + + title?: string; + module?: CoreCourseWSModule; + courseId?: number; + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.module = CoreNavigator.instance.getRouteParam('module'); + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId'); + this.title = this.module?.name; + } + + /** + * Update some data based on the quiz instance. + * + * @param quiz Quiz instance. + */ + updateData(quiz: AddonModQuizQuizWSData): void { + this.title = quiz.name || this.title; + } + + /** + * User entered the page. + */ + ionViewDidEnter(): void { + this.quizComponent?.ionViewDidEnter(); + } + + /** + * User left the page. + */ + ionViewDidLeave(): void { + this.quizComponent?.ionViewDidLeave(); + } + +} diff --git a/src/addons/mod/quiz/quiz-lazy.module.ts b/src/addons/mod/quiz/quiz-lazy.module.ts new file mode 100644 index 000000000..b167bbd5b --- /dev/null +++ b/src/addons/mod/quiz/quiz-lazy.module.ts @@ -0,0 +1,28 @@ +// (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 { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +const routes: Routes = [ + { + path: ':courseId/:cmdId', + loadChildren: () => import('./pages/index/index.module').then( m => m.AddonModQuizIndexPageModule), + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], +}) +export class AddonModQuizLazyModule {} diff --git a/src/addons/mod/quiz/quiz.module.ts b/src/addons/mod/quiz/quiz.module.ts new file mode 100644 index 000000000..601d4f3c3 --- /dev/null +++ b/src/addons/mod/quiz/quiz.module.ts @@ -0,0 +1,56 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; +import { Routes } from '@angular/router'; + +import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { AddonModQuizComponentsModule } from './components/components.module'; +import { SITE_SCHEMA } from './services/database/quiz'; +import { AddonModQuizModuleHandler, AddonModQuizModuleHandlerService } from './services/handlers/module'; +import { AddonModQuizPrefetchHandler } from './services/handlers/prefetch'; + +const routes: Routes = [ + { + path: AddonModQuizModuleHandlerService.PAGE_NAME, + loadChildren: () => import('./quiz-lazy.module').then(m => m.AddonModQuizLazyModule), + }, +]; + +@NgModule({ + imports: [ + CoreMainMenuTabRoutingModule.forChild(routes), + AddonModQuizComponentsModule, + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [SITE_SCHEMA], + multi: true, + }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreCourseModuleDelegate.instance.registerHandler(AddonModQuizModuleHandler.instance); + CoreCourseModulePrefetchDelegate.instance.registerHandler(AddonModQuizPrefetchHandler.instance); + }, + }, + ], +}) +export class AddonModQuizModule {} diff --git a/src/addons/mod/quiz/services/handlers/module.ts b/src/addons/mod/quiz/services/handlers/module.ts new file mode 100644 index 000000000..b37387a83 --- /dev/null +++ b/src/addons/mod/quiz/services/handlers/module.ts @@ -0,0 +1,98 @@ +// (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 { Injectable, Type } from '@angular/core'; + +import { CoreConstants } from '@/core/constants'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate'; +import { CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course'; +import { CoreCourseModule } from '@features/course/services/course-helper'; +import { CoreNavigationOptions, CoreNavigator } from '@services/navigator'; +import { AddonModQuizIndexComponent } from '../../components/index'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support quiz modules. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizModuleHandlerService implements CoreCourseModuleHandler { + + static readonly PAGE_NAME = 'mod_quiz'; + + name = 'AddonModQuiz'; + modName = 'quiz'; + + supportedFeatures = { + [CoreConstants.FEATURE_GROUPS]: true, + [CoreConstants.FEATURE_GROUPINGS]: true, + [CoreConstants.FEATURE_MOD_INTRO]: true, + [CoreConstants.FEATURE_COMPLETION_TRACKS_VIEWS]: true, + [CoreConstants.FEATURE_COMPLETION_HAS_RULES]: true, + [CoreConstants.FEATURE_GRADE_HAS_GRADE]: true, + [CoreConstants.FEATURE_GRADE_OUTCOMES]: true, + [CoreConstants.FEATURE_BACKUP_MOODLE2]: true, + [CoreConstants.FEATURE_SHOW_DESCRIPTION]: true, + [CoreConstants.FEATURE_CONTROLS_GRADE_VISIBILITY]: true, + [CoreConstants.FEATURE_USES_QUESTIONS]: true, + }; + + /** + * Check if the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Get the data required to display the module in the course contents view. + * + * @param module The module object. + * @param courseId The course ID. + * @param sectionId The section ID. + * @return Data to render the module. + */ + getData(module: CoreCourseAnyModuleData): CoreCourseModuleHandlerData { + return { + icon: CoreCourse.instance.getModuleIconSrc(this.modName, 'modicon' in module ? module.modicon : undefined), + title: module.name, + class: 'addon-mod_quiz-handler', + showDownloadButton: true, + action: (event: Event, module: CoreCourseModule, courseId: number, options?: CoreNavigationOptions) => { + options = options || {}; + options.params = options.params || {}; + Object.assign(options.params, { module }); + const routeParams = '/' + courseId + '/' + module.id; + + CoreNavigator.instance.navigateToSitePath(AddonModQuizModuleHandlerService.PAGE_NAME + routeParams, options); + }, + }; + } + + /** + * Get the component to render the module. This is needed to support singleactivity course format. + * The component returned must implement CoreCourseModuleMainComponent. + * + * @param course The course object. + * @param module The module object. + * @return The component to use, undefined if not found. + */ + async getMainComponent(): Promise> { + return AddonModQuizIndexComponent; + } + +} + +export class AddonModQuizModuleHandler extends makeSingleton(AddonModQuizModuleHandlerService) {}