diff --git a/src/addon/mod/quiz/components/components.module.ts b/src/addon/mod/quiz/components/components.module.ts new file mode 100644 index 000000000..33fbb3631 --- /dev/null +++ b/src/addon/mod/quiz/components/components.module.ts @@ -0,0 +1,45 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreCourseComponentsModule } from '@core/course/components/components.module'; +import { AddonModQuizIndexComponent } from './index/index'; + +@NgModule({ + declarations: [ + AddonModQuizIndexComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CoreCourseComponentsModule + ], + providers: [ + ], + exports: [ + AddonModQuizIndexComponent + ], + entryComponents: [ + AddonModQuizIndexComponent + ] +}) +export class AddonModQuizComponentsModule {} diff --git a/src/addon/mod/quiz/components/index/index.html b/src/addon/mod/quiz/components/index/index.html new file mode 100644 index 000000000..28a34ae12 --- /dev/null +++ b/src/addon/mod/quiz/components/index/index.html @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + +

{{ rule }}

+
+ +

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

+

{{ quiz.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} }} +
+ + + + + + + + + + {{ 'core.openinbrowser' | translate }} + + + + + + + + +
+
+
diff --git a/src/addon/mod/quiz/components/index/index.scss b/src/addon/mod/quiz/components/index/index.scss new file mode 100644 index 000000000..6d73d067f --- /dev/null +++ b/src/addon/mod/quiz/components/index/index.scss @@ -0,0 +1,29 @@ +addon-mod-quiz-index { + + .addon-mod_quiz-table { + .addon-mod_quiz-table-header .item-inner { + background-image: none; + } + + .item { + padding-left: 0; + } + + .label { + margin-top: 0; + margin-bottom: 0; + } + + .item:nth-child(even) { + background-color: $gray-lighter; + } + + .addon-mod_quiz-highlighted, + .item.addon-mod_quiz-highlighted, + .addon-mod_quiz-highlighted p, + .item.addon-mod_quiz-highlighted p { + background-color: $blue-light; + color: $blue-dark; + } + } +} diff --git a/src/addon/mod/quiz/components/index/index.ts b/src/addon/mod/quiz/components/index/index.ts new file mode 100644 index 000000000..6b8536296 --- /dev/null +++ b/src/addon/mod/quiz/components/index/index.ts @@ -0,0 +1,565 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Optional, Injector } from '@angular/core'; +import { Content, NavController } from 'ionic-angular'; +import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main-activity-component'; +import { CoreQuestionBehaviourDelegate } from '@core/question/providers/behaviour-delegate'; +import { AddonModQuizProvider } from '../../providers/quiz'; +import { AddonModQuizHelperProvider } from '../../providers/helper'; +import { AddonModQuizOfflineProvider } from '../../providers/quiz-offline'; +import { AddonModQuizSyncProvider } from '../../providers/quiz-sync'; +import { AddonModQuizPrefetchHandler } from '../../providers/prefetch-handler'; +import { CoreConstants } from '@core/constants'; + +/** + * Component that displays a quiz entry page. + */ +@Component({ + selector: 'addon-mod-quiz-index', + templateUrl: 'index.html', +}) +export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComponent { + component = AddonModQuizProvider.COMPONENT; + moduleName = 'quiz'; + + quiz: any; // The quiz. + now: number; // Current time. + syncTime: string; // Last synchronization time. + hasOffline: boolean; // Whether the quiz has offline data. + 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: boolean; // Whether the quiz behaviour is supported. + showResults: boolean; // Whether to show the result of the quiz (grade, etc.). + gradeOverridden: boolean; // 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. + + 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: any; // Data to auto-review an attempt. It's used to automatically open the review page after finishing. + protected quizAccessInfo: any; // Quiz access info. + protected attemptAccessInfo: any; // Last attempt access info. + protected attempts: any[]; // List of attempts the user has made. + protected moreAttempts: boolean; // Whether user can create/continue attempts. + protected options: any; // Combined review options. + protected bestGrade: any; // Best grade data. + protected gradebookData: {grade: number, feedback?: string}; // The gradebook grade and feedback. + protected overallStats: boolean; // Equivalent to overallstats in mod_quiz_view_object in Moodle. + protected finishedObserver: any; // It will observe attempt finished events. + + constructor(injector: Injector, protected quizProvider: AddonModQuizProvider, @Optional() protected content: Content, + protected quizHelper: AddonModQuizHelperProvider, protected quizOffline: AddonModQuizOfflineProvider, + protected quizSync: AddonModQuizSyncProvider, protected behaviourDelegate: CoreQuestionBehaviourDelegate, + protected prefetchHandler: AddonModQuizPrefetchHandler, protected navCtrl: NavController) { + super(injector); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + super.ngOnInit(); + + this.loadContent(false, true).then(() => { + if (!this.quizData) { + return; + } + + this.quizProvider.logViewQuiz(this.quizData.id).then(() => { + this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus); + }); + }); + + // Listen for attempt finished events. + this.finishedObserver = this.eventsProvider.on(AddonModQuizProvider.ATTEMPT_FINISHED_EVENT, (data) => { + // Go to review attempt if an attempt in this quiz was finished and synced. + if (this.quizData && data.quizId == this.quizData.id) { + this.autoReview = data; + } + }, this.siteId); + } + + /** + * Attempt the quiz. + */ + attemptQuiz(): void { + if (this.showStatusSpinner) { + // Quiz is being downloaded or synchronized, abort. + return; + } + + if (this.quizProvider.isQuizOffline(this.quizData)) { + // Quiz supports offline, check if it needs to be downloaded. + if (this.currentStatus != CoreConstants.DOWNLOADED) { + // Prefetch the quiz. + this.showStatusSpinner = true; + + this.prefetchHandler.prefetch(this.module, this.courseId, true).then(() => { + // Success downloading, open quiz. + this.openQuiz(); + }).catch((error) => { + if (this.hasOffline) { + // Error downloading but there is something offline, allow continuing it. + this.openQuiz(); + } else { + this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true); + } + + }).finally(() => { + this.showStatusSpinner = false; + }); + } else { + // Already downloaded, open it. + this.openQuiz(); + } + } else { + // Quiz isn't offline, just open it. + this.openQuiz(); + } + } + + /** + * Get the quiz data. + * + * @param {boolean} [refresh=false] If it's refreshing content. + * @param {boolean} [sync=false] If the refresh is needs syncing. + * @param {boolean} [showErrors=false] If show errors to the user of hide them. + * @return {Promise} Promise resolved when done. + */ + protected fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { + + // First get the quiz instance. + return this.quizProvider.getQuiz(this.courseId, this.module.id).then((quizData) => { + this.quizData = quizData; + this.quizData.gradeMethodReadable = this.quizProvider.getQuizGradeMethod(this.quizData.grademethod); + + this.now = new Date().getTime(); + this.dataRetrieved.emit(this.quizData); + this.description = this.quizData.intro || this.description; + + // Try to get warnings from automatic sync. + return this.quizSync.getSyncWarnings(this.quizData.id).then((warnings) => { + if (warnings && warnings.length) { + // Show warnings and delete them so they aren't shown again. + this.domUtils.showErrorModal(this.textUtils.buildMessage(warnings)); + + return this.quizSync.setSyncWarnings(this.quizData.id, []); + } + }); + }).then(() => { + if (this.quizProvider.isQuizOffline(this.quizData)) { + // Try to sync the quiz. + return this.syncActivity(showErrors).catch(() => { + // Ignore errors, keep getting data even if sync fails. + this.autoReview = undefined; + }); + } else { + this.autoReview = undefined; + this.showStatusSpinner = false; + } + }).then(() => { + + if (this.quizProvider.isQuizOffline(this.quizData)) { + // Handle status. + this.setStatusListener(); + + // Get last synchronization time and check if sync button should be seen. + // No need to return these promises, they should be faster than the rest. + this.quizSync.getReadableSyncTime(this.quizData.id).then((syncTime) => { + this.syncTime = syncTime; + }); + + this.quizSync.hasDataToSync(this.quizData.id).then((hasOffline) => { + this.hasOffline = hasOffline; + }); + } + + // Get quiz access info. + return this.quizProvider.getQuizAccessInformation(this.quizData.id).then((info) => { + this.quizAccessInfo = info; + this.quizData.showReviewColumn = info.canreviewmyattempts; + this.accessRules = info.accessrules; + this.unsupportedRules = this.quizProvider.getUnsupportedRules(info.activerulenames); + + if (this.quizData.preferredbehaviour) { + this.behaviourSupported = this.behaviourDelegate.isBehaviourSupported(this.quizData.preferredbehaviour); + } + + // Get question types in the quiz. + return this.quizProvider.getQuizRequiredQtypes(this.quizData.id).then((types) => { + this.unsupportedQuestions = this.quizProvider.getUnsupportedQuestions(types); + + return this.getAttempts(); + }); + }); + + }).then(() => { + // All data obtained, now fill the context menu. + this.fillContextMenu(refresh); + + // Quiz is ready to be shown, move it to the variable that is displayed. + this.quiz = this.quizData; + }); + } + + /** + * Get the user attempts in the quiz and the result info. + * + * @return {Promise} Promise resolved when done. + */ + protected getAttempts(): Promise { + + // Get access information of last attempt (it also works if no attempts made). + return this.quizProvider.getAttemptAccessInformation(this.quizData.id, 0).then((info) => { + this.attemptAccessInfo = info; + + // Get attempts. + return this.quizProvider.getUserAttempts(this.quizData.id).then((atts) => { + + return this.treatAttempts(atts).then((atts) => { + this.attempts = atts; + + // Check if user can create/continue attempts. + if (this.attempts.length) { + const last = this.attempts[this.attempts.length - 1]; + this.moreAttempts = !this.quizProvider.isAttemptFinished(last.state) || !this.attemptAccessInfo.isfinished; + } else { + this.moreAttempts = !this.attemptAccessInfo.isfinished; + } + + this.getButtonText(); + + return this.getResultInfo(); + }); + }); + }); + } + + /** + * Get the text to show in the button. It also sets restriction messages if needed. + */ + protected getButtonText(): void { + this.buttonText = ''; + + if (this.quizData.hasquestions !== 0) { + if (this.attempts.length && !this.quizProvider.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) { + // 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.unsupportedQuestions.length || this.unsupportedRules.length || !this.behaviourSupported) { + this.buttonText = ''; + } + } + } + + /** + * Get result info to show. + * + * @return {Promise} Promise resolved when done. + */ + protected getResultInfo(): Promise { + + if (this.attempts.length && this.quizData.showGradeColumn && this.bestGrade.hasgrade && + typeof this.gradebookData.grade != 'undefined') { + + const formattedGradebookGrade = this.quizProvider.formatGrade(this.gradebookData.grade, this.quizData.decimalpoints), + formattedBestGrade = this.quizProvider.formatGrade(this.bestGrade.grade, this.quizData.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 == this.quizData.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 = this.translate.instant('addon.mod_quiz.gradesofar', {$a: { + method: this.quizData.gradeMethodReadable, + mygrade: gradeToShow, + quizgrade: this.quizData.gradeFormatted + }}); + } else { + const outOfShort = this.translate.instant('addon.mod_quiz.outofshort', {$a: { + grade: gradeToShow, + maxgrade: this.quizData.gradeFormatted + }}); + + this.gradeResult = this.translate.instant('addon.mod_quiz.yourfinalgradeis', {$a: outOfShort}); + } + } + + if (this.quizData.showFeedbackColumn) { + // Get the quiz overall feedback. + return this.quizProvider.getFeedbackForGrade(this.quizData.id, this.gradebookData.grade).then((response) => { + this.overallFeedback = response.feedbacktext; + }); + } + } else { + this.showResults = false; + } + + return Promise.resolve(); + } + + /** + * Go to review an attempt that has just been finished. + * + * @return {Promise} Promise resolved when done. + */ + protected goToAutoReview(): Promise { + // If we go to auto review it means an attempt was finished. Check completion status. + this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus); + + // Verify that user can see the review. + const attemptId = this.autoReview.attemptId; + + if (this.quizAccessInfo.canreviewmyattempts) { + return this.quizProvider.getAttemptReview(attemptId, -1).then(() => { + this.navCtrl.push('AddonModQuizReviewPage', {courseId: this.courseId, quizId: this.quizData.id, attemptId}); + }).catch(() => { + // Ignore errors. + }); + } + + return Promise.resolve(); + } + + /** + * Checks if sync has succeed from result sync data. + * + * @param {any} result Data returned on the sync function. + * @return {boolean} If suceed or not. + */ + protected hasSyncSucceed(result: any): boolean { + if (result.attemptFinished) { + // An attempt was finished, check completion status. + this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus); + } + + // If the sync call isn't rejected it means the sync was successful. + return result.answersSent; + } + + /** + * User entered the page that contains the component. + */ + ionViewDidEnter(): void { + super.ionViewDidEnter(); + + // @todo: Go to auto review if we're coming from player. + } + + /** + * User left the page that contains the component. + */ + ionViewDidLeave(): void { + super.ionViewDidLeave(); + this.autoReview = undefined; + } + + /** + * Perform the invalidate content function. + * + * @return {Promise} Resolved when done. + */ + protected invalidateContent(): Promise { + const promises = []; + + promises.push(this.quizProvider.invalidateQuizData(this.courseId)); + + if (this.quizData) { + promises.push(this.quizProvider.invalidateUserAttemptsForUser(this.quizData.id)); + promises.push(this.quizProvider.invalidateQuizAccessInformation(this.quizData.id)); + promises.push(this.quizProvider.invalidateQuizRequiredQtypes(this.quizData.id)); + promises.push(this.quizProvider.invalidateAttemptAccessInformation(this.quizData.id)); + promises.push(this.quizProvider.invalidateCombinedReviewOptionsForUser(this.quizData.id)); + promises.push(this.quizProvider.invalidateUserBestGradeForUser(this.quizData.id)); + promises.push(this.quizProvider.invalidateGradeFromGradebook(this.courseId)); + } + + return Promise.all(promises); + } + + /** + * Compares sync event data with current data to check if refresh content is needed. + * + * @param {any} syncEventData Data receiven on sync observer. + * @return {boolean} True if refresh is needed, false otherwise. + */ + protected isRefreshSyncNeeded(syncEventData: any): boolean { + if (syncEventData.attemptFinished) { + // An attempt was finished, check completion status. + this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus); + } + + if (this.quizData && syncEventData.quizId == this.quizData.id) { + this.content.scrollToTop(); + + return true; + } + + return false; + } + + /** + * Open a quiz to attempt it. + */ + protected openQuiz(): void { + this.navCtrl.push('AddonModQuizPlayerPage', {courseId: this.courseId, quizId: this.quiz.id, moduleUrl: this.module.url}); + } + + /** + * Displays some data based on the current status. + * + * @param {string} status The current status. + * @param {string} [previousStatus] The previous status. If not defined, there is no previous status. + */ + protected showStatus(status: string, previousStatus?: string): void { + this.showStatusSpinner = status == CoreConstants.DOWNLOADING; + } + + /** + * Performs the sync of the activity. + * + * @return {Promise} Promise resolved when done. + */ + protected sync(): Promise { + return this.quizSync.syncQuiz(this.quizData, true); + } + + /** + * Treat user attempts. + * + * @param {any} attempts The attempts to treat. + * @return {Promise} Promise resolved when done. + */ + protected treatAttempts(attempts: any): Promise { + if (!attempts || !attempts.length) { + // There are no attempts to treat. + return Promise.resolve(attempts); + } + + const lastFinished = this.quizProvider.getLastFinishedAttemptFromList(attempts), + promises = []; + + 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; + } + + // Load flag to show if attempts are finished but not synced. + promises.push(this.quizProvider.loadFinishedOfflineData(attempts)); + + // Get combined review options. + promises.push(this.quizProvider.getCombinedReviewOptions(this.quizData.id).then((result) => { + this.options = result; + })); + + // Get best grade. + promises.push(this.quizProvider.getUserBestGrade(this.quizData.id).then((best) => { + this.bestGrade = best; + + // Get gradebook grade. + return this.quizProvider.getGradeFromGradebook(this.courseId, this.module.id).then((data) => { + 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 + }; + }); + })); + + return Promise.all(promises).then(() => { + const grade: number = typeof this.gradebookData.grade != 'undefined' ? this.gradebookData.grade : this.bestGrade.grade, + quizGrade = this.quizProvider.formatGrade(grade, this.quizData.decimalpoints); + + // Calculate data to construct the header of the attempts table. + this.quizHelper.setQuizCalculatedData(this.quizData, this.options); + + this.overallStats = lastFinished && this.options.alloptions.marks >= AddonModQuizProvider.QUESTION_OPTIONS_MARK_AND_MAX; + + // Calculate data to show for each attempt. + attempts.forEach((attempt) => { + // Highlight the highest grade if appropriate. + const shouldHighlight = this.overallStats && this.quizData.grademethod == AddonModQuizProvider.GRADEHIGHEST && + attempts.length > 1; + + this.quizHelper.setAttemptCalculatedData(this.quizData, attempt, shouldHighlight, quizGrade); + }); + + return attempts; + }); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + super.ngOnDestroy(); + + this.finishedObserver && this.finishedObserver.off(); + } +} diff --git a/src/addon/mod/quiz/lang/en.json b/src/addon/mod/quiz/lang/en.json new file mode 100644 index 000000000..ecc2cde5b --- /dev/null +++ b/src/addon/mod/quiz/lang/en.json @@ -0,0 +1,78 @@ +{ + "attemptfirst": "First attempt", + "attemptlast": "Last attempt", + "attemptnumber": "Attempt", + "attemptquiznow": "Attempt quiz now", + "attemptstate": "State", + "cannotsubmitquizdueto": "This quiz attempt cannot be submitted for the following reasons:", + "comment": "Comment", + "completedon": "Completed on", + "confirmclose": "Once you submit, you will no longer be able to change your answers for this attempt.", + "confirmcontinueoffline": "This attempt has not been synchronised since {{$a}}. If you have continued this attempt in another device since then, you may lose data.", + "confirmleavequizonerror": "An error occurred while saving the answers. Are you sure you want to leave the quiz?", + "confirmstart": "The quiz has a time limit of {{$a}}. Time will count down from the moment you start your attempt and you must submit before it expires. Are you sure that you wish to start now?", + "confirmstartheader": "Timed quiz", + "connectionerror": "Network connection lost. (Autosave failed).\n\nMake a note of any responses entered on this page in the last few minutes, then try to re-connect.\n\nOnce connection has been re-established, your responses should be saved and this message will disappear.", + "continueattemptquiz": "Continue the last attempt", + "continuepreview": "Continue the last preview", + "errorbehaviournotsupported": "This quiz can't be attempted in the app because the question behaviour is not supported by the app:", + "errordownloading": "Error downloading required data.", + "errorgetattempt": "Error getting attempt data.", + "errorgetquestions": "Error getting questions.", + "errorgetquiz": "Error getting quiz data.", + "errorparsequestions": "An error occurred while reading the questions. Please attempt this quiz in a web browser.", + "errorquestionsnotsupported": "This quiz can't be attempted in the app because it contains questions not supported by the app:", + "errorrulesnotsupported": "This quiz can't be attempted in the app because it has access rules not supported by the app:", + "errorsaveattempt": "An error occurred while saving the attempt data.", + "feedback": "Feedback", + "finishattemptdots": "Finish attempt...", + "finishnotsynced": "Finished but not synchronised", + "grade": "Grade", + "gradeaverage": "Average grade", + "gradehighest": "Highest grade", + "grademethod": "Grading method", + "gradesofar": "{{$a.method}}: {{$a.mygrade}} / {{$a.quizgrade}}.", + "hasdatatosync": "This quiz has offline data to be synchronised.", + "marks": "Marks", + "mustbesubmittedby": "This attempt must be submitted by {{$a}}.", + "noquestions": "No questions have been added yet", + "noreviewattempt": "You are not allowed to review this attempt.", + "notyetgraded": "Not yet graded", + "opentoc": "Open navigation popover", + "outof": "{{$a.grade}} out of {{$a.maxgrade}}", + "outofpercent": "{{$a.grade}} out of {{$a.maxgrade}} ({{$a.percent}}%)", + "outofshort": "{{$a.grade}}/{{$a.maxgrade}}", + "overallfeedback": "Overall feedback", + "overdue": "Overdue", + "overduemustbesubmittedby": "This attempt is now overdue. It should already have been submitted. If you would like this quiz to be graded, you must submit it by {{$a}}. If you do not submit it by then, no marks from this attempt will be counted.", + "preview": "Preview", + "previewquiznow": "Preview quiz now", + "question": "Question", + "quizpassword": "Quiz password", + "reattemptquiz": "Re-attempt quiz", + "requirepasswordmessage": "To attempt this quiz you need to know the quiz password", + "returnattempt": "Return to attempt", + "review": "Review", + "reviewofattempt": "Review of attempt {{$a}}", + "reviewofpreview": "Review of preview", + "showall": "Show all questions on one page", + "showeachpage": "Show one page at a time", + "startattempt": "Start attempt", + "startedon": "Started on", + "stateabandoned": "Never submitted", + "statefinished": "Finished", + "statefinisheddetails": "Submitted {{$a}}", + "stateinprogress": "In progress", + "stateoverdue": "Overdue", + "stateoverduedetails": "Must be submitted by {{$a}}", + "status": "Status", + "submitallandfinish": "Submit all and finish", + "summaryofattempt": "Summary of attempt", + "summaryofattempts": "Summary of your previous attempts", + "timeleft": "Time left", + "timetaken": "Time taken", + "warningattemptfinished": "Offline attempt discarded as it was finished on the site or not found.", + "warningdatadiscarded": "Some offline answers were discarded because the questions were modified online.", + "warningdatadiscardedfromfinished": "Attempt unfinished because some offline answers were discarded. Please review your answers then resubmit the attempt.", + "yourfinalgradeis": "Your final grade for this quiz is {{$a}}." +} \ No newline at end of file diff --git a/src/addon/mod/quiz/pages/index/index.html b/src/addon/mod/quiz/pages/index/index.html new file mode 100644 index 000000000..e8cebbc02 --- /dev/null +++ b/src/addon/mod/quiz/pages/index/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/addon/mod/quiz/pages/index/index.module.ts b/src/addon/mod/quiz/pages/index/index.module.ts new file mode 100644 index 000000000..3ee1e482e --- /dev/null +++ b/src/addon/mod/quiz/pages/index/index.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModQuizComponentsModule } from '../../components/components.module'; +import { AddonModQuizIndexPage } from './index'; + +@NgModule({ + declarations: [ + AddonModQuizIndexPage, + ], + imports: [ + CoreDirectivesModule, + AddonModQuizComponentsModule, + IonicPageModule.forChild(AddonModQuizIndexPage), + TranslateModule.forChild() + ], +}) +export class AddonModQuizIndexPageModule {} diff --git a/src/addon/mod/quiz/pages/index/index.ts b/src/addon/mod/quiz/pages/index/index.ts new file mode 100644 index 000000000..be398552e --- /dev/null +++ b/src/addon/mod/quiz/pages/index/index.ts @@ -0,0 +1,62 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, ViewChild } from '@angular/core'; +import { IonicPage, NavParams } from 'ionic-angular'; +import { AddonModQuizIndexComponent } from '../../components/index/index'; + +/** + * Page that displays the quiz entry page. + */ +@IonicPage({ segment: 'addon-mod-quiz-index' }) +@Component({ + selector: 'page-addon-mod-quiz-index', + templateUrl: 'index.html', +}) +export class AddonModQuizIndexPage { + @ViewChild(AddonModQuizIndexComponent) quizComponent: AddonModQuizIndexComponent; + + title: string; + module: any; + courseId: number; + + constructor(navParams: NavParams) { + this.module = navParams.get('module') || {}; + this.courseId = navParams.get('courseId'); + this.title = this.module.name; + } + + /** + * Update some data based on the quiz instance. + * + * @param {any} quiz Quiz instance. + */ + updateData(quiz: any): 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/addon/mod/quiz/providers/module-handler.ts b/src/addon/mod/quiz/providers/module-handler.ts new file mode 100644 index 000000000..de7e2af28 --- /dev/null +++ b/src/addon/mod/quiz/providers/module-handler.ts @@ -0,0 +1,71 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 } from '@angular/core'; +import { NavController, NavOptions } from 'ionic-angular'; +import { AddonModQuizIndexComponent } from '../components/index/index'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@core/course/providers/module-delegate'; +import { CoreCourseProvider } from '@core/course/providers/course'; + +/** + * Handler to support quiz modules. + */ +@Injectable() +export class AddonModQuizModuleHandler implements CoreCourseModuleHandler { + name = 'AddonModQuiz'; + modName = 'quiz'; + + constructor(private courseProvider: CoreCourseProvider) { } + + /** + * Check if the handler is enabled on a site level. + * + * @return {boolean} Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean { + return true; + } + + /** + * Get the data required to display the module in the course contents view. + * + * @param {any} module The module object. + * @param {number} courseId The course ID. + * @param {number} sectionId The section ID. + * @return {CoreCourseModuleHandlerData} Data to render the module. + */ + getData(module: any, courseId: number, sectionId: number): CoreCourseModuleHandlerData { + return { + icon: this.courseProvider.getModuleIconSrc('quiz'), + title: module.name, + class: 'addon-mod_quiz-handler', + showDownloadButton: true, + action(event: Event, navCtrl: NavController, module: any, courseId: number, options: NavOptions): void { + navCtrl.push('AddonModQuizIndexPage', {module: module, courseId: courseId}, options); + } + }; + } + + /** + * Get the component to render the module. This is needed to support singleactivity course format. + * The component returned must implement CoreCourseModuleMainComponent. + * + * @param {any} course The course object. + * @param {any} module The module object. + * @return {any} The component to use, undefined if not found. + */ + getMainComponent(course: any, module: any): any { + return AddonModQuizIndexComponent; + } +} diff --git a/src/addon/mod/quiz/providers/quiz.ts b/src/addon/mod/quiz/providers/quiz.ts index db6bbd65a..859510c25 100644 --- a/src/addon/mod/quiz/providers/quiz.ts +++ b/src/addon/mod/quiz/providers/quiz.ts @@ -33,6 +33,7 @@ import * as moment from 'moment'; @Injectable() export class AddonModQuizProvider { static COMPONENT = 'mmaModQuiz'; + static ATTEMPT_FINISHED_EVENT = 'addon_mod_quiz_attempt_finished'; // Grade methods. static GRADEHIGHEST = 1; @@ -1026,7 +1027,7 @@ export class AddonModQuizProvider { * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). * @param {string} [siteId] Site ID. If not defined, current site. * @param {number} [userId] User ID. If not defined use site's current user. - * @return {Promise} Promise resolved with the attempts. + * @return {Promise} Promise resolved with the best grade data. */ getUserBestGrade(quizId: number, ignoreCache?: boolean, siteId?: string, userId?: number): Promise { return this.sitesProvider.getSite(siteId).then((site) => { diff --git a/src/addon/mod/quiz/quiz.module.ts b/src/addon/mod/quiz/quiz.module.ts index a4ea1920d..3c08d7b7c 100644 --- a/src/addon/mod/quiz/quiz.module.ts +++ b/src/addon/mod/quiz/quiz.module.ts @@ -14,19 +14,23 @@ import { NgModule } from '@angular/core'; import { CoreCronDelegate } from '@providers/cron'; +import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; import { AddonModQuizAccessRuleDelegate } from './providers/access-rules-delegate'; import { AddonModQuizProvider } from './providers/quiz'; import { AddonModQuizOfflineProvider } from './providers/quiz-offline'; import { AddonModQuizHelperProvider } from './providers/helper'; import { AddonModQuizSyncProvider } from './providers/quiz-sync'; +import { AddonModQuizModuleHandler } from './providers/module-handler'; import { AddonModQuizPrefetchHandler } from './providers/prefetch-handler'; import { AddonModQuizSyncCronHandler } from './providers/sync-cron-handler'; +import { AddonModQuizComponentsModule } from './components/components.module'; @NgModule({ declarations: [ ], imports: [ + AddonModQuizComponentsModule ], providers: [ AddonModQuizAccessRuleDelegate, @@ -34,14 +38,17 @@ import { AddonModQuizSyncCronHandler } from './providers/sync-cron-handler'; AddonModQuizOfflineProvider, AddonModQuizHelperProvider, AddonModQuizSyncProvider, + AddonModQuizModuleHandler, AddonModQuizPrefetchHandler, AddonModQuizSyncCronHandler ] }) export class AddonModQuizModule { - constructor(prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModQuizPrefetchHandler, + constructor(moduleDelegate: CoreCourseModuleDelegate, moduleHandler: AddonModQuizModuleHandler, + prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModQuizPrefetchHandler, cronDelegate: CoreCronDelegate, syncHandler: AddonModQuizSyncCronHandler) { + moduleDelegate.registerHandler(moduleHandler); prefetchDelegate.registerHandler(prefetchHandler); cronDelegate.register(syncHandler); } diff --git a/src/components/dynamic-component/dynamic-component.ts b/src/components/dynamic-component/dynamic-component.ts index c41afc529..1692b7663 100644 --- a/src/components/dynamic-component/dynamic-component.ts +++ b/src/components/dynamic-component/dynamic-component.ts @@ -112,7 +112,7 @@ export class CoreDynamicComponent implements OnInit, OnChanges, DoCheck { * @param {any[]} params List of params to send to the function. * @return {any} Result of the call. Undefined if no component instance or the function doesn't exist. */ - callComponentFunction(name: string, params: any[]): any { + callComponentFunction(name: string, params?: any[]): any { if (this.instance && typeof this.instance[name] == 'function') { return this.instance[name].apply(this.instance, params); } diff --git a/src/core/course/classes/main-activity-component.ts b/src/core/course/classes/main-activity-component.ts index 8af38dfbc..9f78057c2 100644 --- a/src/core/course/classes/main-activity-component.ts +++ b/src/core/course/classes/main-activity-component.ts @@ -15,6 +15,7 @@ import { Injector } from '@angular/core'; import { CoreSitesProvider } from '@providers/sites'; import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; import { CoreEventsProvider } from '@providers/events'; import { Network } from '@ionic-native/network'; import { CoreAppProvider } from '@providers/app'; @@ -33,8 +34,10 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR protected siteId: string; // Current Site ID. protected syncObserver: any; // It will observe the sync auto event. + protected statusObserver: any; // It will observe changes on the status of the activity. Only if setStatusListener is called. protected onlineObserver: any; // It will observe the status of the network connection. protected syncEventName: string; // Auto sync event name. + protected currentStatus: string; // The current status of the activity. Only if setStatusListener is called. // List of services that will be injected using injector. // It's done like this so subclasses don't have to send all the services to the parent in the constructor. @@ -42,6 +45,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR protected courseProvider: CoreCourseProvider; protected appProvider: CoreAppProvider; protected eventsProvider: CoreEventsProvider; + protected modulePrefetchProvider: CoreCourseModulePrefetchDelegate; constructor(injector: Injector) { super(injector); @@ -50,6 +54,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR this.courseProvider = injector.get(CoreCourseProvider); this.appProvider = injector.get(CoreAppProvider); this.eventsProvider = injector.get(CoreEventsProvider); + this.modulePrefetchProvider = injector.get(CoreCourseModulePrefetchDelegate); const network = injector.get(Network); @@ -75,6 +80,10 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR this.syncObserver = this.eventsProvider.on(this.syncEventName, (data) => { if (this.isRefreshSyncNeeded(data)) { // Refresh the data. + this.loaded = false; + this.refreshIcon = 'spinner'; + this.syncIcon = 'spinner'; + this.refreshContent(false); } }, this.siteId); @@ -168,6 +177,39 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR }); } + /** + * Displays some data based on the current status. + * + * @param {string} status The current status. + * @param {string} [previousStatus] The previous status. If not defined, there is no previous status. + */ + protected showStatus(status: string, previousStatus?: string): void { + // To be overridden. + } + + /** + * Watch for changes on the status. + */ + protected setStatusListener(): void { + if (typeof this.statusObserver == 'undefined') { + // Listen for changes on this module status. + this.statusObserver = this.eventsProvider.on(CoreEventsProvider.PACKAGE_STATUS_CHANGED, (data) => { + if (data.componentId === this.module.id && data.component === this.component) { + // The status has changed, update it. + const previousStatus = this.currentStatus; + this.currentStatus = data.status; + + this.showStatus(this.currentStatus, previousStatus); + } + }, this.siteId); + + // Also, get the current status. + this.modulePrefetchProvider.getModuleStatus(this.module, this.courseId).then((status) => { + this.showStatus(status); + }); + } + } + /** * Performs the sync of the activity. * @@ -217,5 +259,6 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR this.onlineObserver && this.onlineObserver.unsubscribe(); this.syncObserver && this.syncObserver.off(); + this.statusObserver && this.statusObserver.off(); } } diff --git a/src/core/course/classes/main-resource-component.ts b/src/core/course/classes/main-resource-component.ts index be9c687e7..48a093320 100644 --- a/src/core/course/classes/main-resource-component.ts +++ b/src/core/course/classes/main-resource-component.ts @@ -42,6 +42,7 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy, protected isDestroyed; // Whether the component is destroyed, used when calling fillContextMenu. protected statusObserver; // Observer of package status changed, used when calling fillContextMenu. protected fetchContentDefaultError = 'core.course.errorgetmodule'; // Default error to show when loading contents. + protected isCurrentView: boolean; // Whether the component is in the current view. // List of services that will be injected using injector. // It's done like this so subclasses don't have to send all the services to the parent in the constructor. @@ -174,4 +175,18 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy, this.isDestroyed = true; this.statusObserver && this.statusObserver.off(); } + + /** + * User entered the page that contains the component. This function should be called by the page that contains this component. + */ + ionViewDidEnter(): void { + this.isCurrentView = true; + } + + /** + * User left the page that contains the component. This function should be called by the page that contains this component. + */ + ionViewDidLeave(): void { + this.isCurrentView = false; + } } diff --git a/src/core/course/components/format/format.ts b/src/core/course/components/format/format.ts index 504f5d0a3..d65a8d674 100644 --- a/src/core/course/components/format/format.ts +++ b/src/core/course/components/format/format.ts @@ -326,4 +326,22 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { this.sectionStatusObserver.off(); } } + + /** + * User entered the page that contains the component. + */ + ionViewDidEnter(): void { + this.dynamicComponents.forEach((component) => { + component.callComponentFunction('ionViewDidEnter'); + }); + } + + /** + * User left the page that contains the component. + */ + ionViewDidLeave(): void { + this.dynamicComponents.forEach((component) => { + component.callComponentFunction('ionViewDidLeave'); + }); + } } diff --git a/src/core/course/formats/singleactivity/components/singleactivity.ts b/src/core/course/formats/singleactivity/components/singleactivity.ts index a68a60fda..4cfa91918 100644 --- a/src/core/course/formats/singleactivity/components/singleactivity.ts +++ b/src/core/course/formats/singleactivity/components/singleactivity.ts @@ -67,4 +67,18 @@ export class CoreCourseFormatSingleActivityComponent implements OnChanges { doRefresh(refresher?: any, done?: () => void): Promise { return Promise.resolve(this.dynamicComponent.callComponentFunction('doRefresh', [refresher, done])); } + + /** + * User entered the page that contains the component. + */ + ionViewDidEnter(): void { + this.dynamicComponent.callComponentFunction('ionViewDidEnter'); + } + + /** + * User left the page that contains the component. + */ + ionViewDidLeave(): void { + this.dynamicComponent.callComponentFunction('ionViewDidLeave'); + } } diff --git a/src/core/course/pages/section/section.ts b/src/core/course/pages/section/section.ts index b5d5806ca..d0325a3ca 100644 --- a/src/core/course/pages/section/section.ts +++ b/src/core/course/pages/section/section.ts @@ -314,4 +314,18 @@ export class CoreCourseSectionPage implements OnDestroy { this.completionObserver.off(); } } + + /** + * User entered the page. + */ + ionViewDidEnter(): void { + this.formatComponent.ionViewDidEnter(); + } + + /** + * User left the page. + */ + ionViewDidLeave(): void { + this.formatComponent.ionViewDidLeave(); + } }