From ff06a812d2b3ee43373720256152b63c46f04fdf Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 3 May 2018 13:25:55 +0200 Subject: [PATCH] MOBILE-2345 lesson: Implement player page --- src/addon/mod/lesson/lesson.module.ts | 2 + .../lesson/pages/menu-modal/menu-modal.html | 36 + .../pages/menu-modal/menu-modal.module.ts | 33 + .../lesson/pages/menu-modal/menu-modal.scss | 5 + .../mod/lesson/pages/menu-modal/menu-modal.ts | 58 ++ src/addon/mod/lesson/pages/player/player.html | 224 ++++++ .../mod/lesson/pages/player/player.module.ts | 33 + src/addon/mod/lesson/pages/player/player.scss | 11 + src/addon/mod/lesson/pages/player/player.ts | 635 ++++++++++++++++++ src/addon/mod/lesson/providers/helper.ts | 368 ++++++++++ 10 files changed, 1405 insertions(+) create mode 100644 src/addon/mod/lesson/pages/menu-modal/menu-modal.html create mode 100644 src/addon/mod/lesson/pages/menu-modal/menu-modal.module.ts create mode 100644 src/addon/mod/lesson/pages/menu-modal/menu-modal.scss create mode 100644 src/addon/mod/lesson/pages/menu-modal/menu-modal.ts create mode 100644 src/addon/mod/lesson/pages/player/player.html create mode 100644 src/addon/mod/lesson/pages/player/player.module.ts create mode 100644 src/addon/mod/lesson/pages/player/player.scss create mode 100644 src/addon/mod/lesson/pages/player/player.ts create mode 100644 src/addon/mod/lesson/providers/helper.ts diff --git a/src/addon/mod/lesson/lesson.module.ts b/src/addon/mod/lesson/lesson.module.ts index 27972cf62..7399c86a2 100644 --- a/src/addon/mod/lesson/lesson.module.ts +++ b/src/addon/mod/lesson/lesson.module.ts @@ -21,6 +21,7 @@ import { AddonModLessonComponentsModule } from './components/components.module'; import { AddonModLessonProvider } from './providers/lesson'; import { AddonModLessonOfflineProvider } from './providers/lesson-offline'; import { AddonModLessonSyncProvider } from './providers/lesson-sync'; +import { AddonModLessonHelperProvider } from './providers/helper'; import { AddonModLessonModuleHandler } from './providers/module-handler'; import { AddonModLessonPrefetchHandler } from './providers/prefetch-handler'; import { AddonModLessonSyncCronHandler } from './providers/sync-cron-handler'; @@ -38,6 +39,7 @@ import { AddonModLessonReportLinkHandler } from './providers/report-link-handler AddonModLessonProvider, AddonModLessonOfflineProvider, AddonModLessonSyncProvider, + AddonModLessonHelperProvider, AddonModLessonModuleHandler, AddonModLessonPrefetchHandler, AddonModLessonSyncCronHandler, diff --git a/src/addon/mod/lesson/pages/menu-modal/menu-modal.html b/src/addon/mod/lesson/pages/menu-modal/menu-modal.html new file mode 100644 index 000000000..1bb5c72ed --- /dev/null +++ b/src/addon/mod/lesson/pages/menu-modal/menu-modal.html @@ -0,0 +1,36 @@ + + + {{ pageInstance.lesson.name }} + + + + + + + + diff --git a/src/addon/mod/lesson/pages/menu-modal/menu-modal.module.ts b/src/addon/mod/lesson/pages/menu-modal/menu-modal.module.ts new file mode 100644 index 000000000..3147dc86e --- /dev/null +++ b/src/addon/mod/lesson/pages/menu-modal/menu-modal.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 { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModLessonMenuModalPage } from './menu-modal'; +import { TranslateModule } from '@ngx-translate/core'; + +@NgModule({ + declarations: [ + AddonModLessonMenuModalPage + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(AddonModLessonMenuModalPage), + TranslateModule.forChild() + ] +}) +export class AddonModLessonMenuModalPageModule {} diff --git a/src/addon/mod/lesson/pages/menu-modal/menu-modal.scss b/src/addon/mod/lesson/pages/menu-modal/menu-modal.scss new file mode 100644 index 000000000..8961fc65e --- /dev/null +++ b/src/addon/mod/lesson/pages/menu-modal/menu-modal.scss @@ -0,0 +1,5 @@ +page-addon-mod-lesson-menu-modal { + .addon-mod_lesson-selected, .item.addon-mod_lesson-selected { + background: $blue-light; + } +} diff --git a/src/addon/mod/lesson/pages/menu-modal/menu-modal.ts b/src/addon/mod/lesson/pages/menu-modal/menu-modal.ts new file mode 100644 index 000000000..a9406ceb4 --- /dev/null +++ b/src/addon/mod/lesson/pages/menu-modal/menu-modal.ts @@ -0,0 +1,58 @@ +// (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 } from '@angular/core'; +import { IonicPage, ViewController, NavParams } from 'ionic-angular'; + +/** + * Modal that renders the lesson menu and media file. + */ +@IonicPage({ segment: 'addon-mod-lesson-menu-modal' }) +@Component({ + selector: 'page-addon-mod-lesson-menu-modal', + templateUrl: 'menu-modal.html', +}) +export class AddonModLessonMenuModalPage { + + /** + * The instance of the page that opened the modal. We use the instance instead of the needed attributes for these reasons: + * - We want the user to be able to see the media file while the menu is being loaded, so we need to be able to update + * the menu dynamically based on the data retrieved by the page that opened the modal. + * - The onDidDismiss function takes a while to be called, making the app seem slow. This way we can directly call + * the functions we need without having to wait for the modal to be dismissed. + * @type {any} + */ + pageInstance: any; + + constructor(params: NavParams, protected viewCtrl: ViewController) { + this.pageInstance = params.get('page'); + } + + /** + * Close modal. + */ + closeModal(): void { + this.viewCtrl.dismiss(); + } + + /** + * Load a certain page. + * + * @param {number} pageId The page ID to load. + */ + loadPage(pageId: number): void { + this.pageInstance.changePage && this.pageInstance.changePage(pageId); + this.closeModal(); + } +} diff --git a/src/addon/mod/lesson/pages/player/player.html b/src/addon/mod/lesson/pages/player/player.html new file mode 100644 index 000000000..a1b5688f0 --- /dev/null +++ b/src/addon/mod/lesson/pages/player/player.html @@ -0,0 +1,224 @@ + + + + + + + + + + + + +
+ + +
+ +
+ + + + + +

{{ 'addon.mod_lesson.attempt' | translate:{$a: retake} }}

+
+ + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + +

{{ 'addon.mod_lesson.youranswer' | translate }}

+

+
+
+ + + + +
+ + + + + + +
+ + + + + + + + + + +
+ + + + + + + +

+
+ + + {{option.label}} + + +
+
+
+
+
+ + + + +
+
+ + + + + + + + {{ button.content }} + + + + + +

{{ 'addon.mod_lesson.progresscompleted' | translate:{$a: pageData.progress} }}

+ +
+
+ + {{ 'addon.mod_lesson.progressbarteacherwarning2' | translate }} +
+
+ + + +
+ + {{ 'addon.mod_lesson.finishretakeoffline' | translate }} +
+ +

{{ 'addon.mod_lesson.congratulations' | translate }}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +

{{ 'addon.mod_lesson.progresscompleted' | translate:{$a: eolData.progresscompleted.value} }}

+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +

+ +

+
+

{{ 'addon.mod_lesson.gotoendoflesson' | translate }}

+

{{ 'addon.mod_lesson.or' | translate }}

+

{{ 'addon.mod_lesson.continuetonextpage' | translate }}

+
+
+ + {{ 'addon.mod_lesson.finish' | translate }} + {{ button.label | translate }} + +
+
+
+
diff --git a/src/addon/mod/lesson/pages/player/player.module.ts b/src/addon/mod/lesson/pages/player/player.module.ts new file mode 100644 index 000000000..5569515c0 --- /dev/null +++ b/src/addon/mod/lesson/pages/player/player.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 { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModLessonPlayerPage } from './player'; + +@NgModule({ + declarations: [ + AddonModLessonPlayerPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(AddonModLessonPlayerPage), + TranslateModule.forChild() + ], +}) +export class AddonModLessonPlayerPageModule {} diff --git a/src/addon/mod/lesson/pages/player/player.scss b/src/addon/mod/lesson/pages/player/player.scss new file mode 100644 index 000000000..836d34398 --- /dev/null +++ b/src/addon/mod/lesson/pages/player/player.scss @@ -0,0 +1,11 @@ +page-addon-mod-lesson-player { + .addon-mod_lesson-slideshow { + max-width: 100%; + max-height: 100%; + } + + ion-input[padding-left] input[padding-left] { + // Applying padding-left to the ion-input applies it twice since it's replicated in the inner input. + padding-left: 0; + } +} diff --git a/src/addon/mod/lesson/pages/player/player.ts b/src/addon/mod/lesson/pages/player/player.ts new file mode 100644 index 000000000..4b823d33e --- /dev/null +++ b/src/addon/mod/lesson/pages/player/player.ts @@ -0,0 +1,635 @@ +// (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, OnInit, OnDestroy, ViewChild, ChangeDetectorRef } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { IonicPage, NavParams, Content, PopoverController, ModalController, Modal, NavController } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '@providers/app'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUrlUtilsProvider } from '@providers/utils/url'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { AddonModLessonProvider } from '../../providers/lesson'; +import { AddonModLessonOfflineProvider } from '../../providers/lesson-offline'; +import { AddonModLessonSyncProvider } from '../../providers/lesson-sync'; +import { AddonModLessonHelperProvider } from '../../providers/helper'; + +/** + * Page that allows attempting and reviewing a lesson. + */ +@IonicPage({ segment: 'addon-mod-lesson-player' }) +@Component({ + selector: 'page-addon-mod-lesson-player', + templateUrl: 'player.html', +}) +export class AddonModLessonPlayerPage implements OnInit, OnDestroy { + @ViewChild(Content) content: Content; + + component = AddonModLessonProvider.COMPONENT; + LESSON_EOL = AddonModLessonProvider.LESSON_EOL; + questionForm: FormGroup; // The FormGroup for question pages. + title: string; // The page title. + lesson: any; // The lesson object. + currentPage: number; // Current page being viewed. + review: boolean; // Whether the user is reviewing. + messages: any[]; // Messages to display to the user. + menuModal: Modal; // Modal to navigate through the pages. + canManage: boolean; // Whether the user can manage the lesson. + retake: number; // Current retake number. + showRetake: boolean; // Whether the retake number needs to be displayed. + lessonWidth: string; // Width of the lesson (if slideshow mode). + lessonHeight: string; // Height of the lesson (if slideshow mode). + endTime: number; // End time of the lesson if it's timed. + pageData: any; // Current page data. + pageContent: string; // Current page contents. + pageButtons: any[]; // List of buttons of the current page. + question: any; // Question of the current page (if it's a question page). + eolData: any; // Data for EOL page (if current page is EOL). + processData: any; // Data to display after processing a page. + loaded: boolean; // Whether data has been loaded. + displayMenu: boolean; // Whether the lesson menu should be displayed. + originalData: any; // Original question data. It is used to check if data has changed. + + protected courseId: number; // The course ID the lesson belongs to. + protected lessonId: number; // Lesson ID. + protected password: string; // Lesson password (if any). + protected forceLeave = false; // If true, don't perform any check when leaving the view. + protected offline: boolean; // Whether we are in offline mode. + protected accessInfo: any; // Lesson access info. + protected jumps: any; // All possible jumps. + protected mediaFile: any; // Media file of the lesson. + protected firstPageLoaded: boolean; // Whether the first page has been loaded. + protected loadingMenu: boolean; // Whether the lesson menu is being loaded. + protected lessonPages: any[]; // Lesson pages (for the lesson menu). + + constructor(protected navParams: NavParams, logger: CoreLoggerProvider, protected translate: TranslateService, + protected eventsProvider: CoreEventsProvider, protected sitesProvider: CoreSitesProvider, + protected syncProvider: CoreSyncProvider, protected domUtils: CoreDomUtilsProvider, popoverCtrl: PopoverController, + protected timeUtils: CoreTimeUtilsProvider, protected lessonProvider: AddonModLessonProvider, + protected lessonHelper: AddonModLessonHelperProvider, protected lessonSync: AddonModLessonSyncProvider, + protected lessonOfflineProvider: AddonModLessonOfflineProvider, protected cdr: ChangeDetectorRef, + modalCtrl: ModalController, protected navCtrl: NavController, protected appProvider: CoreAppProvider, + protected utils: CoreUtilsProvider, protected urlUtils: CoreUrlUtilsProvider, protected fb: FormBuilder) { + + this.lessonId = navParams.get('lessonId'); + this.courseId = navParams.get('courseId'); + this.password = navParams.get('password'); + this.review = !!navParams.get('review'); + this.currentPage = navParams.get('pageId'); + + // Block the lesson so it cannot be synced. + this.syncProvider.blockOperation(this.component, this.lessonId); + + // Create the navigation modal. + this.menuModal = modalCtrl.create('AddonModLessonMenuModalPage', { + page: this + }); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + // Fetch the Lesson data. + this.fetchLessonData().then((success) => { + if (success) { + // Review data loaded or new retake started, remove any retake being finished in sync. + this.lessonSync.deleteRetakeFinishedInSync(this.lessonId); + } + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + // Unblock the lesson so it can be synced. + this.syncProvider.unblockOperation(this.component, this.lessonId); + } + + /** + * Check if we can leave the page or not. + * + * @return {boolean|Promise} Resolved if we can leave it, rejected if not. + */ + ionViewCanLeave(): boolean | Promise { + if (this.forceLeave) { + return true; + } + + if (this.question && !this.eolData && !this.processData && this.originalData) { + // Question shown. Check if there is any change. + if (!this.utils.basicLeftCompare(this.questionForm.getRawValue(), this.originalData, 3)) { + return this.domUtils.showConfirm(this.translate.instant('core.confirmcanceledit')); + } + } + + return Promise.resolve(); + } + + /** + * A button was clicked. + * + * @param {any} data Button data. + */ + buttonClicked(data: any): void { + this.processPage(data); + } + + /** + * Call a function and go offline if allowed and the call fails. + * + * @param {Function} func Function to call. + * @param {any[]} args Arguments to pass to the function. + * @param {number} offlineParamPos Position of the offline parameter in the args. + * @param {number} [jumpsParamPos] Position of the jumps parameter in the args. + * @return {Promise} Promise resolved in success, rejected otherwise. + */ + protected callFunction(func: Function, args: any[], offlineParamPos: number, jumpsParamPos?: number): Promise { + return func.apply(func, args).catch((error) => { + if (!this.offline && !this.review && this.lessonProvider.isLessonOffline(this.lesson) && + !this.utils.isWebServiceError(error)) { + // If it fails, go offline. + this.offline = true; + + // Get the possible jumps now. + return this.lessonProvider.getPagesPossibleJumps(this.lesson.id, true).then((jumpList) => { + this.jumps = jumpList; + + // Call the function again with offline set to true and the new jumps. + args[offlineParamPos] = true; + if (typeof jumpsParamPos != 'undefined') { + args[jumpsParamPos] = this.jumps; + } + + return func.apply(func, args); + }); + } + + return Promise.reject(error); + }); + } + + /** + * Change the page from menu or when continuing from a feedback page. + * + * @param {number} pageId Page to load. + * @param {boolean} [ignoreCurrent] If true, allow loading current page. + */ + changePage(pageId: number, ignoreCurrent?: boolean): void { + if (!ignoreCurrent && !this.eolData && this.currentPage == pageId) { + // Page already loaded, stop. + return; + } + + this.loaded = true; + this.messages = []; + + this.loadPage(pageId).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error loading page'); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Get the lesson data and load the page. + * + * @return {Promise} Promise resolved with true if success, resolved with false otherwise. + */ + protected fetchLessonData(): Promise { + // Wait for any ongoing sync to finish. We won't sync a lesson while it's being played. + return this.lessonSync.waitForSync(this.lessonId).then(() => { + return this.lessonProvider.getLessonById(this.courseId, this.lessonId); + }).then((lessonData) => { + this.lesson = lessonData; + this.title = this.lesson.name; // Temporary title. + + // If lesson has offline data already, use offline mode. + return this.lessonOfflineProvider.hasOfflineData(this.lessonId); + }).then((offlineMode) => { + this.offline = offlineMode; + + if (!offlineMode && !this.appProvider.isOnline() && this.lessonProvider.isLessonOffline(this.lesson) && !this.review) { + // Lesson doesn't have offline data, but it allows offline and the device is offline. Use offline mode. + this.offline = true; + } + + return this.callFunction(this.lessonProvider.getAccessInformation.bind(this.lessonProvider), + [this.lesson.id, this.offline, true], 1); + }).then((info) => { + const promises = []; + + this.accessInfo = info; + this.canManage = info.canmanage; + this.retake = info.attemptscount; + this.showRetake = !this.currentPage && this.retake > 0; // Only show it in first page if it isn't the first retake. + + if (info.preventaccessreasons && info.preventaccessreasons.length) { + // If it's a password protected lesson and we have the password, allow playing it. + if (!this.password || info.preventaccessreasons.length > 1 || !this.lessonProvider.isPasswordProtected(info)) { + // Lesson cannot be played, show message and go back. + return Promise.reject(info.preventaccessreasons[0].message); + } + } + + if (this.review && this.navParams.get('retake') != info.attemptscount - 1) { + // Reviewing a retake that isn't the last one. Error. + return Promise.reject(this.translate.instant('addon.mod_lesson.errorreviewretakenotlast')); + } + + if (this.password) { + // Lesson uses password, get the whole lesson object. + promises.push(this.callFunction(this.lessonProvider.getLessonWithPassword.bind(this.lessonProvider), + [this.lesson.id, this.password, true, this.offline, true], 3).then((lesson) => { + this.lesson = lesson; + })); + } + + if (this.offline) { + // Offline mode, get the list of possible jumps to allow navigation. + promises.push(this.lessonProvider.getPagesPossibleJumps(this.lesson.id, true).then((jumpList) => { + this.jumps = jumpList; + })); + } + + return Promise.all(promises); + }).then(() => { + this.mediaFile = this.lesson.mediafiles && this.lesson.mediafiles[0]; + + this.lessonWidth = this.lesson.slideshow ? this.domUtils.formatPixelsSize(this.lesson.mediawidth) : ''; + this.lessonHeight = this.lesson.slideshow ? this.domUtils.formatPixelsSize(this.lesson.mediaheight) : ''; + + return this.launchRetake(this.currentPage); + }).then(() => { + return true; + }).catch((error) => { + // An error occurred. + let promise; + + if (this.review && this.navParams.get('retake') && this.utils.isWebServiceError(error)) { + // The user cannot review the retake. Unmark the retake as being finished in sync. + promise = this.lessonSync.deleteRetakeFinishedInSync(this.lessonId); + } else { + promise = Promise.resolve(); + } + + return promise.then(() => { + this.domUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); + this.forceLeave = true; + this.navCtrl.pop(); + + return false; + }); + }); + } + + /** + * Finish the retake. + * + * @param {boolean} [outOfTime] Whether the retake is finished because the user ran out of time. + * @return {Promise} Promise resolved when done. + */ + protected finishRetake(outOfTime?: boolean): Promise { + let promise; + + this.messages = []; + + if (this.offline && this.appProvider.isOnline()) { + // Offline mode but the app is online. Try to sync the data. + promise = this.lessonSync.syncLesson(this.lesson.id, true, true).then((result) => { + if (result.warnings && result.warnings.length) { + const error = result.warnings[0]; + + // Some data was deleted. Check if the retake has changed. + return this.lessonProvider.getAccessInformation(this.lesson.id).then((info) => { + if (info.attemptscount != this.accessInfo.attemptscount) { + // The retake has changed. Leave the view and show the error. + this.forceLeave = true; + this.navCtrl.pop(); + + return Promise.reject(error); + } + + // Retake hasn't changed, show the warning and finish the retake in offline. + this.offline = false; + this.domUtils.showErrorModal(error); + }); + } + + this.offline = false; + }, () => { + // Ignore errors. + }); + } else { + promise = Promise.resolve(); + } + + return promise.then(() => { + // Now finish the retake. + const args = [this.lesson, this.courseId, this.password, outOfTime, this.review, this.offline, this.accessInfo]; + + return this.callFunction(this.lessonProvider.finishRetake.bind(this.lessonProvider), args, 5); + }).then((data) => { + this.title = this.lesson.name; + this.eolData = data.data; + this.messages = this.messages.concat(data.messages); + this.processData = undefined; + + // Format activity link if present. + if (this.eolData && this.eolData.activitylink) { + this.eolData.activitylink.value = this.lessonHelper.formatActivityLink(this.eolData.activitylink.value); + } + + // Format review lesson if present. + if (this.eolData && this.eolData.reviewlesson) { + const params = this.urlUtils.extractUrlParams(this.eolData.reviewlesson.value); + + if (!params || !params.pageid) { + // No pageid in the URL, the user cannot review (probably didn't answer any question). + delete this.eolData.reviewlesson; + } else { + this.eolData.reviewlesson.pageid = params.pageid; + } + } + }); + } + + /** + * Jump to a certain page after performing an action. + * + * @param {number} pageId The page to load. + * @return {Promise} Promise resolved when done. + */ + protected jumpToPage(pageId: number): Promise { + if (pageId === 0) { + // Not a valid page, return to entry view. + // This happens, for example, when the user clicks to go to previous page and there is no previous page. + this.forceLeave = true; + this.navCtrl.pop(); + + return Promise.resolve(); + } else if (pageId == AddonModLessonProvider.LESSON_EOL) { + // End of lesson reached. + return this.finishRetake(); + } + + // Load new page. + this.messages = []; + + return this.loadPage(pageId); + } + + /** + * Start or continue a retake. + * + * @param {number} pageId The page to load. + * @return {Promise} Promise resolved when done. + */ + protected launchRetake(pageId: number): Promise { + let promise; + + if (this.review) { + // Review mode, no need to launch the retake. + promise = Promise.resolve({}); + } else if (!this.offline) { + // Not in offline mode, launch the retake. + promise = this.lessonProvider.launchRetake(this.lesson.id, this.password, pageId); + } else { + // Check if there is a finished offline retake. + promise = this.lessonOfflineProvider.hasFinishedRetake(this.lesson.id).then((finished) => { + if (finished) { + // Always show EOL page. + pageId = AddonModLessonProvider.LESSON_EOL; + } + + return {}; + }); + } + + return promise.then((data) => { + this.currentPage = pageId || this.accessInfo.firstpageid; + this.messages = data.messages || []; + + if (this.lesson.timelimit && !this.accessInfo.canmanage) { + // Get the last lesson timer. + return this.lessonProvider.getTimers(this.lesson.id, false, true).then((timers) => { + this.endTime = timers[timers.length - 1].starttime + this.lesson.timelimit; + }); + } + }).then(() => { + return this.loadPage(this.currentPage); + }); + } + + /** + * Load the lesson menu. + * + * @return {Promise} Promise resolved when done. + */ + protected loadMenu(): Promise { + if (this.loadingMenu) { + // Already loading. + return; + } + + this.loadingMenu = true; + + const args = [this.lessonId, this.password, this.offline, true]; + + return this.callFunction(this.lessonProvider.getPages.bind(this.lessonProvider), args, 2).then((pages) => { + this.lessonPages = pages.map((entry) => { + return entry.page; + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error loading menu.'); + }).finally(() => { + this.loadingMenu = false; + }); + } + + /** + * Load a certain page. + * + * @param {number} pageId The page to load. + * @return {Promise} Promise resolved when done. + */ + protected loadPage(pageId: number): Promise { + if (pageId == AddonModLessonProvider.LESSON_EOL) { + // End of lesson reached. + return this.finishRetake(); + } + + const args = [this.lesson, pageId, this.password, this.review, true, this.offline, true, this.accessInfo, this.jumps]; + + return this.callFunction(this.lessonProvider.getPageData.bind(this.lessonProvider), args, 5, 8).then((data) => { + if (data.newpageid == AddonModLessonProvider.LESSON_EOL) { + // End of lesson reached. + return this.finishRetake(); + } + + this.pageData = data; + this.title = data.page.title; + this.pageContent = this.lessonHelper.getPageContentsFromPageData(data); + this.loaded = true; + this.currentPage = pageId; + this.messages = this.messages.concat(data.messages); + + // Page loaded, hide EOL and feedback data if shown. + this.eolData = this.processData = undefined; + + if (this.lessonProvider.isQuestionPage(data.page.type)) { + // Create an empty FormGroup without controls, they will be added in getQuestionFromPageData. + this.questionForm = this.fb.group({}); + this.pageButtons = []; + this.question = this.lessonHelper.getQuestionFromPageData(this.questionForm, data); + this.originalData = this.questionForm.getRawValue(); // Use getRawValue to include disabled values. + } else { + this.pageButtons = this.lessonHelper.getPageButtonsFromHtml(data.pagecontent); + this.question = undefined; + this.originalData = undefined; + } + + if (data.displaymenu && !this.displayMenu) { + // Load the menu. + this.loadMenu(); + } + this.displayMenu = !!data.displaymenu; + + if (!this.firstPageLoaded) { + this.firstPageLoaded = true; + } else { + this.showRetake = false; + } + }); + } + + /** + * Process a page, sending some data. + * + * @param {any} data The data to send. + * @return {Promise} Promise resolved when done. + */ + protected processPage(data: any): Promise { + this.loaded = false; + + const args = [this.lesson, this.courseId, this.pageData, data, this.password, this.review, this.offline, this.accessInfo, + this.jumps]; + + return this.callFunction(this.lessonProvider.processPage.bind(this.lessonProvider), args, 6, 8).then((result) => { + if (!this.offline && !this.review && this.lessonProvider.isLessonOffline(this.lesson)) { + // Lesson allows offline and the user changed some data in server. Update cached data. + const retake = this.accessInfo.attemptscount; + + if (this.lessonProvider.isQuestionPage(this.pageData.page.type)) { + this.lessonProvider.getQuestionsAttemptsOnline(this.lessonId, retake, false, undefined, false, true); + } else { + this.lessonProvider.getContentPagesViewedOnline(this.lessonId, retake, false, true); + } + } + + if (result.nodefaultresponse || result.inmediatejump) { + // Don't display feedback or force a redirect to a new page. Load the new page. + return this.jumpToPage(result.newpageid); + } else { + + // Not inmediate jump, show the feedback. + result.feedback = this.lessonHelper.removeQuestionFromFeedback(result.feedback); + this.messages = result.messages; + this.processData = result; + this.processData.buttons = []; + + if (this.lesson.review && !result.correctanswer && !result.noanswer && !result.isessayquestion && + !result.maxattemptsreached && !result.reviewmode) { + // User can try again, show button to do so. + this.processData.buttons.push({ + label: 'addon.mod_lesson.reviewquestionback', + pageId: this.currentPage + }); + } + + // Button to continue. + if (this.lesson.review && !result.correctanswer && !result.noanswer && !result.isessayquestion && + !result.maxattemptsreached) { + this.processData.buttons.push({ + label: 'addon.mod_lesson.reviewquestioncontinue', + pageId: result.newpageid + }); + } else { + this.processData.buttons.push({ + label: 'addon.mod_lesson.continue', + pageId: result.newpageid + }); + } + } + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error processing page'); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Review the lesson. + * + * @param {number} pageId Page to load. + */ + reviewLesson(pageId: number): void { + this.loaded = false; + this.review = true; + this.offline = false; // Don't allow offline mode in review. + + this.loadPage(pageId).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error loading page'); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Submit a question. + */ + submitQuestion(): void { + this.loaded = false; + + // Use getRawValue to include disabled values. + this.lessonHelper.prepareQuestionData(this.question, this.questionForm.getRawValue()).then((data) => { + return this.processPage(data); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Time up. + */ + timeUp(): void { + // Time up called, hide the timer. + this.endTime = undefined; + this.loaded = false; + + this.finishRetake(true).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error finishing attempt'); + }).finally(() => { + this.loaded = true; + }); + } +} diff --git a/src/addon/mod/lesson/providers/helper.ts b/src/addon/mod/lesson/providers/helper.ts new file mode 100644 index 000000000..231ed5b3d --- /dev/null +++ b/src/addon/mod/lesson/providers/helper.ts @@ -0,0 +1,368 @@ +// (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 { FormBuilder, FormGroup } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { AddonModLessonProvider } from './lesson'; + +/** + * Helper service that provides some features for quiz. + */ +@Injectable() +export class AddonModLessonHelperProvider { + + protected div = document.createElement('div'); // A div element to search in HTML code. + + constructor(private domUtils: CoreDomUtilsProvider, private fb: FormBuilder, private translate: TranslateService, + private textUtils: CoreTextUtilsProvider) { } + + /** + * Given the HTML of next activity link, format it to extract the href and the text. + * + * @param {string} activityLink HTML of the activity link. + * @return {{formatted: boolean, label: string, href: string}} Formatted data. + */ + formatActivityLink(activityLink: string): {formatted: boolean, label: string, href: string} { + this.div.innerHTML = activityLink; + const anchor = this.div.querySelector('a'); + if (!anchor) { + // Anchor not found, return the original HTML. + return { + formatted: false, + label: activityLink, + href: '' + }; + } + + return { + formatted: true, + label: anchor.innerHTML, + href: anchor.href + }; + } + + /** + * Get the buttons to change pages. + * + * @param {string} html Page's HTML. + * @return {any[]} List of buttons. + */ + getPageButtonsFromHtml(html: string): any[] { + const buttons = []; + + // Get the container of the buttons if it exists. + this.div.innerHTML = html; + let buttonsContainer = this.div.querySelector('.branchbuttoncontainer'); + + if (!buttonsContainer) { + // Button container not found, might be a legacy lesson (from 1.9). + if (!this.div.querySelector('form input[type="submit"]')) { + // No buttons found. + return buttons; + } + buttonsContainer = this.div; + } + + const forms = Array.from(buttonsContainer.querySelectorAll('form')); + forms.forEach((form) => { + const buttonSelector = 'input[type="submit"], button[type="submit"]', + buttonEl = form.querySelector(buttonSelector), + inputs = Array.from(form.querySelectorAll('input')); + + if (!buttonEl || !inputs || !inputs.length) { + // Button not found or no inputs, ignore it. + return; + } + + const button = { + id: buttonEl.id, + title: buttonEl.title || buttonEl.value, + content: buttonEl.tagName == 'INPUT' ? buttonEl.value : buttonEl.innerHTML.trim(), + data: {} + }; + + inputs.forEach((input) => { + if (input.type != 'submit') { + button.data[input.name] = input.value; + } + }); + + buttons.push(button); + }); + + return buttons; + } + + /** + * Given a page data (result of getPageData), get the page contents. + * + * @param {any} data Page data. + * @return {string} Page contents. + */ + getPageContentsFromPageData(data: any): string { + // Search the page contents inside the whole page HTML. Use data.pagecontent because it's filtered. + this.div.innerHTML = data.pagecontent; + const contents = this.div.querySelector('.contents'); + + if (contents) { + return contents.innerHTML.trim(); + } + + // Cannot find contents element, return the page.contents (some elements like videos might not work). + return data.page.contents; + } + + /** + * Get a question and all the data required to render it from the page data (result of AddonModLessonProvider.getPageData). + * + * @param {FormGroup} questionForm The form group where to add the controls. + * @param {any} pageData Page data (result of $mmaModLesson#getPageData). + * @return {any} Question data. + */ + getQuestionFromPageData(questionForm: FormGroup, pageData: any): any { + const question: any = {}; + + // Get the container of the question answers if it exists. + this.div.innerHTML = pageData.pagecontent; + const fieldContainer = this.div.querySelector('.fcontainer'); + + // Get hidden inputs and add their data to the form group. + const hiddenInputs = Array.from(this.div.querySelectorAll('input[type="hidden"]')); + hiddenInputs.forEach((input) => { + questionForm.addControl(input.name, this.fb.control(input.value)); + }); + + // Get the submit button and extract its value. + const submitButton = this.div.querySelector('input[type="submit"]'); + question.submitLabel = submitButton ? submitButton.value : this.translate.instant('addon.mod_lesson.submit'); + + if (!fieldContainer) { + // Element not found, return. + return question; + } + + let type; + + switch (pageData.page.qtype) { + case AddonModLessonProvider.LESSON_PAGE_TRUEFALSE: + case AddonModLessonProvider.LESSON_PAGE_MULTICHOICE: + question.template = 'multichoice'; + question.options = []; + + // Get all the inputs. Search radio first. + let inputs = Array.from(fieldContainer.querySelectorAll('input[type="radio"]')); + if (!inputs || !inputs.length) { + // Radio buttons not found, it might be a multi answer. Search for checkbox. + question.multi = true; + inputs = Array.from(fieldContainer.querySelectorAll('input[type="checkbox"]')); + + if (!inputs || !inputs.length) { + // No checkbox found either. Stop. + return question; + } + } + + let controlAdded = false; + inputs.forEach((input) => { + const option: any = { + id: input.id, + name: input.name, + value: input.value, + checked: !!input.checked, + disabled: !!input.disabled + }, + parent = input.parentElement; + + if (option.checked || question.multi) { + // Add the control. + const value = question.multi ? {value: option.checked, disabled: option.disabled} : option.value; + questionForm.addControl(option.name, this.fb.control(value)); + controlAdded = true; + } + + // Remove the input and use the rest of the parent contents as the label. + input.remove(); + option.text = parent.innerHTML.trim(); + + question.options.push(option); + }); + + if (!question.multi) { + question.controlName = inputs[0].name; // All option have the same name in single choice. + + if (!controlAdded) { + // No checked option for single choice, add the control with an empty value. + questionForm.addControl(question.controlName, this.fb.control('')); + } + } + + break; + + case AddonModLessonProvider.LESSON_PAGE_NUMERICAL: + type = 'number'; + case AddonModLessonProvider.LESSON_PAGE_SHORTANSWER: + question.template = 'shortanswer'; + + // Get the input. + const input = fieldContainer.querySelector('input[type="text"], input[type="number"]'); + if (!input) { + return question; + } + + question.input = { + id: input.id, + name: input.name, + maxlength: input.maxLength, + type: type || 'text' + }; + + // Init the control. + questionForm.addControl(input.name, this.fb.control({value: input.value, disabled: input.readOnly})); + break; + + case AddonModLessonProvider.LESSON_PAGE_ESSAY: + question.template = 'essay'; + + // Get the textarea. + const textarea = fieldContainer.querySelector('textarea'); + + if (!textarea) { + // Textarea not found, probably review mode. + const answerEl = fieldContainer.querySelector('.reviewessay'); + if (!answerEl) { + // Answer not found, stop. + return question; + } + question.useranswer = answerEl.innerHTML; + + } else { + question.textarea = { + id: textarea.id, + name: textarea.name || 'answer[text]' + }; + + // Init the control. + question.control = this.fb.control(''); + questionForm.addControl(question.textarea.name, question.control); + } + + break; + + case AddonModLessonProvider.LESSON_PAGE_MATCHING: + question.template = 'matching'; + + const rows = Array.from(fieldContainer.querySelectorAll('.answeroption')); + question.rows = []; + + rows.forEach((row) => { + const label = row.querySelector('label'), + select = row.querySelector('select'), + options = Array.from(row.querySelectorAll('option')), + rowData: any = {}; + + if (!label || !select || !options || !options.length) { + return; + } + + // Get the row's text (label). + rowData.text = label.innerHTML.trim(); + rowData.id = select.id; + rowData.name = select.name; + rowData.options = []; + + // Treat each option. + let controlAdded = false; + options.forEach((option) => { + if (typeof option.value == 'undefined') { + // Option not valid, ignore it. + return; + } + + const opt = { + value: option.value, + label: option.innerHTML.trim(), + selected: option.selected + }; + + if (opt.selected) { + controlAdded = true; + questionForm.addControl(rowData.name, this.fb.control({value: opt.value, disabled: !!select.disabled})); + } + + rowData.options.push(opt); + }); + + if (!controlAdded) { + // No selected option, add the control with an empty value. + questionForm.addControl(rowData.name, this.fb.control({value: '', disabled: !!select.disabled})); + } + + question.rows.push(rowData); + }); + break; + default: + // Nothing to do. + } + + return question; + } + + /** + * Prepare the question data to be sent to server. + * + * @param {any} question Question to prepare. + * @param {any} data Data to prepare. + * @return {Promise} Promise resolved with the data to send when done. + */ + prepareQuestionData(question: any, data: any): Promise { + if (question.template == 'essay' && question.textarea) { + // The answer might need formatting. Check if rich text editor is enabled or not. + return this.domUtils.isRichTextEditorEnabled().then((enabled) => { + if (!enabled) { + // Rich text editor not enabled, add some HTML to the answer if needed. + data[question.textarea.property] = this.textUtils.formatHtmlLines(data[question.textarea.property]); + } + + return data; + }); + } else if (question.template == 'multichoice' && question.multi) { + // Only send the options with value set to true. + for (const name in data) { + if (name.match(/answer\[\d+\]/) && data[name] == false) { + delete data[name]; + } + } + } + + return Promise.resolve(data); + } + + /** + * Given the feedback of a process page in HTML, remove the question text. + * + * @param {string} html Feedback's HTML. + * @return {string} Feedback without the question text. + */ + removeQuestionFromFeedback(html: string): string { + this.div.innerHTML = html; + + // Remove the question text. + this.domUtils.removeElement(this.div, '.generalbox:not(.feedback):not(.correctanswer)'); + + return this.div.innerHTML.trim(); + } +}