// (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; }); } }