From 2ff5883026fdffac4a4ad191df2ecfdc84c791db Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 27 Jan 2021 12:11:02 +0100 Subject: [PATCH 01/10] MOBILE-3648 lesson: Implement base services --- src/addons/addons.module.ts | 2 + src/addons/mod/lesson/lang.json | 86 + src/addons/mod/lesson/lesson.module.ts | 31 + .../mod/lesson/services/database/lesson.ts | 185 + .../mod/lesson/services/lesson-helper.ts | 723 +++ .../mod/lesson/services/lesson-offline.ts | 565 +++ src/addons/mod/lesson/services/lesson.ts | 4231 +++++++++++++++++ src/addons/mod/mod.module.ts | 27 + 8 files changed, 5850 insertions(+) create mode 100644 src/addons/mod/lesson/lang.json create mode 100644 src/addons/mod/lesson/lesson.module.ts create mode 100644 src/addons/mod/lesson/services/database/lesson.ts create mode 100644 src/addons/mod/lesson/services/lesson-helper.ts create mode 100644 src/addons/mod/lesson/services/lesson-offline.ts create mode 100644 src/addons/mod/lesson/services/lesson.ts create mode 100644 src/addons/mod/mod.module.ts diff --git a/src/addons/addons.module.ts b/src/addons/addons.module.ts index fd704ef20..7b2688efe 100644 --- a/src/addons/addons.module.ts +++ b/src/addons/addons.module.ts @@ -23,6 +23,7 @@ import { AddonCalendarModule } from './calendar/calendar.module'; import { AddonNotificationsModule } from './notifications/notifications.module'; import { AddonMessageOutputModule } from './messageoutput/messageoutput.module'; import { AddonMessagesModule } from './messages/messages.module'; +import { AddonModModule } from './mod/mod.module'; @NgModule({ imports: [ @@ -35,6 +36,7 @@ import { AddonMessagesModule } from './messages/messages.module'; AddonUserProfileFieldModule, AddonNotificationsModule, AddonMessageOutputModule, + AddonModModule, ], }) export class AddonsModule {} diff --git a/src/addons/mod/lesson/lang.json b/src/addons/mod/lesson/lang.json new file mode 100644 index 000000000..3c310fca4 --- /dev/null +++ b/src/addons/mod/lesson/lang.json @@ -0,0 +1,86 @@ +{ + "answer": "Answer", + "attempt": "Attempt: {{$a}}", + "attemptheader": "Attempt", + "attemptsremaining": "You have {{$a}} attempt(s) remaining", + "averagescore": "Average score", + "averagetime": "Average time", + "branchtable": "Content", + "cannotfindattempt": "Error: could not find attempt", + "cannotfinduser": "Error: could not find users", + "clusterjump": "Unseen question within a cluster", + "completed": "Completed", + "congratulations": "Congratulations - end of lesson reached", + "continue": "Continue", + "continuetonextpage": "Continue to next page.", + "defaultessayresponse": "Your essay will be graded by your teacher.", + "detailedstats": "Detailed statistics", + "didnotanswerquestion": "Did not answer this question.", + "displayofgrade": "Display of grade (for students only)", + "displayscorewithessays": "

You earned {{$a.score}} out of {{$a.tempmaxgrade}} for the automatically graded questions.

\n

Your {{$a.essayquestions}} essay question(s) will be graded and added into your final score at a later date.

\n

Your current grade without the essay question(s) is {{$a.score}} out of {{$a.grade}}.

", + "displayscorewithoutessays": "Your score is {{$a.score}} (out of {{$a.grade}}).", + "emptypassword": "Password cannot be empty", + "enterpassword": "Please enter the password:", + "eolstudentoutoftimenoanswers": "You did not answer any questions. You have received a 0 for this lesson.", + "errorprefetchrandombranch": "This lesson contains a jump to a random content page. It can't be attempted in the app until it has been started in a web browser.", + "errorreviewretakenotlast": "This attempt can no longer be reviewed because another attempt has been finished.", + "finish": "Finish", + "finishretakeoffline": "This attempt was finished offline.", + "firstwrong": "You have answered incorrectly. Would you like to attempt the question again? (If you now answer the question correctly, it will not count towards your final score.)", + "gotoendoflesson": "Go to the end of the lesson", + "grade": "Grade", + "highscore": "High score", + "hightime": "High time", + "leftduringtimed": "You have left during a timed lesson.
Please click on Continue to restart the lesson.", + "leftduringtimednoretake": "You have left during a timed lesson and you are
not allowed to retake or continue the lesson.", + "lessonmenu": "Lesson menu", + "lessonstats": "Lesson statistics", + "linkedmedia": "Linked media", + "loginfail": "Login failed, please try again...", + "lowscore": "Low score", + "lowtime": "Low time", + "maximumnumberofattemptsreached": "Maximum number of attempts reached - Moving to next page", + "modattemptsnoteacher": "Student review only works for students.", + "modulenameplural": "Lessons", + "noanswer": "One or more questions have no answer given. Please go back and submit an answer.", + "nolessonattempts": "No attempts have been made on this lesson.", + "nolessonattemptsgroup": "No attempts have been made by {{$a}} group members on this lesson.", + "notcompleted": "Not completed", + "numberofcorrectanswers": "Number of correct answers: {{$a}}", + "numberofpagesviewed": "Number of questions answered: {{$a}}", + "numberofpagesviewednotice": "Number of questions answered: {{$a.nquestions}} (You should answer at least {{$a.minquestions}})", + "ongoingcustom": "You have earned {{$a.score}} point(s) out of {{$a.currenthigh}} point(s) thus far.", + "ongoingnormal": "You have answered {{$a.correct}} correctly out of {{$a.viewed}} attempts.", + "or": "OR", + "overview": "Overview", + "preview": "Preview", + "progressbarteacherwarning2": "You will not see the progress bar because you can edit this lesson", + "progresscompleted": "You have completed {{$a}}% of the lesson", + "question": "Question", + "rawgrade": "Raw grade", + "reports": "Reports", + "response": "Response", + "retakefinishedinsync": "An offline attempt was synchronised. Do you want to review it?", + "retakelabelfull": "{{retake}}: {{grade}} {{timestart}} ({{duration}})", + "retakelabelshort": "{{retake}}: {{grade}} {{timestart}}", + "review": "Review", + "reviewlesson": "Review lesson", + "reviewquestionback": "Yes, I'd like to try again", + "reviewquestioncontinue": "No, I just want to go on to the next question", + "secondpluswrong": "Not quite. Would you like to try again?", + "submit": "Submit", + "teacherjumpwarning": "A {{$a.cluster}} jump or an {{$a.unseen}} jump is being used in this lesson. The next page jump will be used instead. Log in as a student to test these jumps.", + "teacherongoingwarning": "The ongoing score is only displayed for the student. Log in as a student to test the ongoing score.", + "teachertimerwarning": "Timer only works for students. Test the timer by logging in as a student.", + "thatsthecorrectanswer": "That's the correct answer", + "thatsthewronganswer": "That's the wrong answer", + "timeremaining": "Time remaining", + "timetaken": "Time taken", + "unseenpageinbranch": "Unseen question within a content page", + "warningretakefinished": "The attempt was finished on the site.", + "welldone": "Well done!", + "youhaveseen": "You have seen more than one page of this lesson already.
Do you want to start at the last page you saw?", + "youranswer": "Your answer", + "yourcurrentgradeisoutof": "Your current grade is {{$a.grade}} out of {{$a.total}}", + "youshouldview": "You should answer at least: {{$a}}" +} \ No newline at end of file diff --git a/src/addons/mod/lesson/lesson.module.ts b/src/addons/mod/lesson/lesson.module.ts new file mode 100644 index 000000000..83df48fce --- /dev/null +++ b/src/addons/mod/lesson/lesson.module.ts @@ -0,0 +1,31 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; + +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { SITE_SCHEMA, OFFLINE_SITE_SCHEMA } from './services/database/lesson'; + +@NgModule({ + imports: [ + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [SITE_SCHEMA, OFFLINE_SITE_SCHEMA], + multi: true, + }, + ], +}) +export class AddonModLessonModule {} diff --git a/src/addons/mod/lesson/services/database/lesson.ts b/src/addons/mod/lesson/services/database/lesson.ts new file mode 100644 index 000000000..ec4b6ec2b --- /dev/null +++ b/src/addons/mod/lesson/services/database/lesson.ts @@ -0,0 +1,185 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for AddonModLessonOfflineProvider. + */ +export const PASSWORD_TABLE_NAME = 'addon_mod_lesson_password'; +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'AddonModLessonProvider', + version: 1, + tables: [ + { + name: PASSWORD_TABLE_NAME, + columns: [ + { + name: 'lessonid', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'password', + type: 'TEXT', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + ], + }, + ], +}; + +/** + * Database variables for AddonModLessonOfflineProvider. + */ +export const RETAKES_TABLE_NAME = 'addon_mod_lesson_retakes'; +export const PAGE_ATTEMPTS_TABLE_NAME = 'addon_mod_lesson_page_attempts'; +export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = { + name: 'AddonModLessonOfflineProvider', + version: 1, + tables: [ + { + name: RETAKES_TABLE_NAME, + columns: [ + { + name: 'lessonid', + type: 'INTEGER', + primaryKey: true, // Only 1 offline retake per lesson. + }, + { + name: 'retake', // Retake number. + type: 'INTEGER', + notNull: true, + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'finished', + type: 'INTEGER', + }, + { + name: 'outoftime', + type: 'INTEGER', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + { + name: 'lastquestionpage', + type: 'INTEGER', + }, + ], + }, + { + name: PAGE_ATTEMPTS_TABLE_NAME, + columns: [ + { + name: 'lessonid', + type: 'INTEGER', + notNull: true, + }, + { + name: 'retake', // Retake number. + type: 'INTEGER', + notNull: true, + }, + { + name: 'pageid', + type: 'INTEGER', + notNull: true, + }, + { + name: 'timemodified', + type: 'INTEGER', + notNull: true, + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'data', + type: 'TEXT', + }, + { + name: 'type', + type: 'INTEGER', + }, + { + name: 'newpageid', + type: 'INTEGER', + }, + { + name: 'correct', + type: 'INTEGER', + }, + { + name: 'answerid', + type: 'INTEGER', + }, + { + name: 'useranswer', + type: 'TEXT', + }, + ], + // A user can attempt several times per page and retake. + primaryKeys: ['lessonid', 'retake', 'pageid', 'timemodified'], + }, + ], +}; + +/** + * Lesson retake data. + */ +export type AddonModLessonPasswordDBRecord = { + lessonid: number; + password: string; + timemodified: number; +}; + +/** + * Lesson retake data. + */ +export type AddonModLessonRetakeDBRecord = { + lessonid: number; + retake: number; + courseid: number; + finished: number; + outoftime?: number | null; + timemodified?: number | null; + lastquestionpage?: number | null; +}; + +/** + * Lesson page attempts data. + */ +export type AddonModLessonPageAttemptDBRecord = { + lessonid: number; + retake: number; + pageid: number; + timemodified: number; + courseid: number; + data: string | null; + type: number; + newpageid: number; + correct: number; + answerid: number | null; + useranswer: string | null; +}; diff --git a/src/addons/mod/lesson/services/lesson-helper.ts b/src/addons/mod/lesson/services/lesson-helper.ts new file mode 100644 index 000000000..738ff63d5 --- /dev/null +++ b/src/addons/mod/lesson/services/lesson-helper.ts @@ -0,0 +1,723 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; + +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { makeSingleton, Translate } from '@singletons'; +import { + AddonModLesson, + AddonModLessonAttemptsOverviewsAttemptWSData, + AddonModLessonGetPageDataWSResponse, + AddonModLessonProvider, +} from './lesson'; + +/** + * Helper service that provides some features for quiz. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModLessonHelperProvider { + + constructor( + protected formBuilder: FormBuilder, + ) {} + + /** + * Given the HTML of next activity link, format it to extract the href and the text. + * + * @param activityLink HTML of the activity link. + * @return Formatted data. + */ + formatActivityLink(activityLink: string): {formatted: boolean; label: string; href: string} { + const element = CoreDomUtils.instance.convertToElement(activityLink); + const anchor = element.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, + }; + } + + /** + * Given the HTML of an answer from a content page, extract the data to render the answer. + * + * @param html Answer's HTML. + * @return Data to render the answer. + */ + getContentPageAnswerDataFromHtml(html: string): {buttonText: string; content: string} { + const data = { + buttonText: '', + content: '', + }; + const element = CoreDomUtils.instance.convertToElement(html); + + // Search the input button. + const button = element.querySelector('input[type="button"]'); + + if (button) { + // Extract the button content and remove it from the HTML. + data.buttonText = button.value; + button.remove(); + } + + data.content = element.innerHTML.trim(); + + return data; + } + + /** + * Get the buttons to change pages. + * + * @param html Page's HTML. + * @return List of buttons. + */ + getPageButtonsFromHtml(html: string): AddonModLessonPageButton[] { + const buttons: AddonModLessonPageButton[] = []; + const element = CoreDomUtils.instance.convertToElement(html); + + // Get the container of the buttons if it exists. + let buttonsContainer = element.querySelector('.branchbuttoncontainer'); + + if (!buttonsContainer) { + // Button container not found, might be a legacy lesson (from 1.9). + if (!element.querySelector('form input[type="submit"]')) { + // No buttons found. + return buttons; + } + buttonsContainer = element; + } + + const forms = Array.from(buttonsContainer.querySelectorAll('form')); + forms.forEach((form) => { + const buttonSelector = 'input[type="submit"], button[type="submit"]'; + const buttonEl = form.querySelector(buttonSelector); + const inputs = Array.from(form.querySelectorAll('input')); + + if (!buttonEl || !inputs || !inputs.length) { + // Button not found or no inputs, ignore it. + return; + } + + const button: AddonModLessonPageButton = { + 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, get the page contents. + * + * @param data Page data. + * @return Page contents. + */ + getPageContentsFromPageData(data: AddonModLessonGetPageDataWSResponse): string { + // Search the page contents inside the whole page HTML. Use data.pagecontent because it's filtered. + const element = CoreDomUtils.instance.convertToElement(data.pagecontent || ''); + const contents = element.querySelector('.contents'); + + if (contents) { + return contents.innerHTML.trim(); + } + + // Cannot find contents element. + if (AddonModLesson.instance.isQuestionPage(data.page?.type || -1) || + data.page?.qtype == AddonModLessonProvider.LESSON_PAGE_BRANCHTABLE) { + // Return page.contents to prevent having duplicated elements (some elements like videos might not work). + return data.page?.contents || ''; + } else { + // It's an end of cluster, end of branch, etc. Return the whole pagecontent to match what's displayed in web. + return data.pagecontent || ''; + } + } + + /** + * Get a question and all the data required to render it from the page data. + * + * @param questionForm The form group where to add the controls. + * @param pageData Page data. + * @return Question data. + */ + getQuestionFromPageData(questionForm: FormGroup, pageData: AddonModLessonGetPageDataWSResponse): AddonModLessonQuestion { + const element = CoreDomUtils.instance.convertToElement(pageData.pagecontent || ''); + + // Get the container of the question answers if it exists. + const fieldContainer = element.querySelector('.fcontainer'); + + // Get hidden inputs and add their data to the form group. + const hiddenInputs = Array.from(element.querySelectorAll('input[type="hidden"]')); + hiddenInputs.forEach((input) => { + questionForm.addControl(input.name, this.formBuilder.control(input.value)); + }); + + // Get the submit button and extract its value. + const submitButton = element.querySelector('input[type="submit"]'); + const question: AddonModLessonQuestion = { + template: '', + submitLabel: submitButton ? submitButton.value : Translate.instance.instant('addon.mod_lesson.submit'), + }; + + if (!fieldContainer) { + // Element not found, return. + return question; + } + + let type = 'text'; + + switch (pageData.page?.qtype) { + case AddonModLessonProvider.LESSON_PAGE_TRUEFALSE: + case AddonModLessonProvider.LESSON_PAGE_MULTICHOICE: + return this.getMultiChoiceQuestionData(questionForm, question, fieldContainer); + + case AddonModLessonProvider.LESSON_PAGE_NUMERICAL: + type = 'number'; + case AddonModLessonProvider.LESSON_PAGE_SHORTANSWER: + return this.getInputQuestionData(questionForm, question, fieldContainer, type); + + case AddonModLessonProvider.LESSON_PAGE_ESSAY: { + return this.getEssayQuestionData(questionForm, question, fieldContainer); + } + + case AddonModLessonProvider.LESSON_PAGE_MATCHING: { + return this.getMatchingQuestionData(questionForm, question, fieldContainer); + } + } + + return question; + } + + /** + * Get a multichoice question data. + * + * @param questionForm The form group where to add the controls. + * @param question Basic question data. + * @param fieldContainer HTMLElement containing the data. + * @return Question data. + */ + protected getMultiChoiceQuestionData( + questionForm: FormGroup, + question: AddonModLessonQuestion, + fieldContainer: HTMLElement, + ): AddonModLessonMultichoiceQuestion { + const multiChoiceQuestion = { + ...question, + template: 'multichoice', + options: [], + multi: false, + }; + + // 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. + multiChoiceQuestion.multi = true; + inputs = Array.from(fieldContainer.querySelectorAll('input[type="checkbox"]')); + + if (!inputs || !inputs.length) { + // No checkbox found either. Stop. + return multiChoiceQuestion; + } + } + + let controlAdded = false; + inputs.forEach((input) => { + const parent = input.parentElement; + const option: AddonModLessonMultichoiceOption = { + id: input.id, + name: input.name, + value: input.value, + checked: !!input.checked, + disabled: !!input.disabled, + text: parent?.innerHTML.trim() || '', + }; + + if (option.checked || multiChoiceQuestion.multi) { + // Add the control. + const value = multiChoiceQuestion.multi ? + { value: option.checked, disabled: option.disabled } : option.value; + questionForm.addControl(option.name, this.formBuilder.control(value)); + controlAdded = true; + } + + // Remove the input and use the rest of the parent contents as the label. + input.remove(); + multiChoiceQuestion.options!.push(option); + }); + + if (!multiChoiceQuestion.multi) { + multiChoiceQuestion.controlName = inputs[0].name; + + if (!controlAdded) { + // No checked option for single choice, add the control with an empty value. + questionForm.addControl(multiChoiceQuestion.controlName, this.formBuilder.control('')); + } + } + + return multiChoiceQuestion; + } + + /** + * Get an input question data. + * + * @param questionForm The form group where to add the controls. + * @param question Basic question data. + * @param fieldContainer HTMLElement containing the data. + * @param type Type of the input. + * @return Question data. + */ + protected getInputQuestionData( + questionForm: FormGroup, + question: AddonModLessonQuestion, + fieldContainer: HTMLElement, + type: string, + ): AddonModLessonInputQuestion { + + const inputQuestion = question; + inputQuestion.template = 'shortanswer'; + + // Get the input. + const input = fieldContainer.querySelector('input[type="text"], input[type="number"]'); + if (!input) { + return inputQuestion; + } + + inputQuestion.input = { + id: input.id, + name: input.name, + maxlength: input.maxLength, + type, + }; + + // Init the control. + questionForm.addControl(input.name, this.formBuilder.control({ value: input.value, disabled: input.readOnly })); + + return inputQuestion; + } + + /** + * Get an essay question data. + * + * @param questionForm The form group where to add the controls. + * @param question Basic question data. + * @param fieldContainer HTMLElement containing the data. + * @return Question data. + */ + protected getEssayQuestionData( + questionForm: FormGroup, + question: AddonModLessonQuestion, + fieldContainer: HTMLElement, + ): AddonModLessonEssayQuestion { + const essayQuestion = question; + essayQuestion.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 essayQuestion; + } + essayQuestion.useranswer = answerEl.innerHTML; + + } else { + essayQuestion.textarea = { + id: textarea.id, + name: textarea.name || 'answer[text]', + }; + + // Init the control. + essayQuestion.control = this.formBuilder.control(''); + questionForm.addControl(essayQuestion.textarea.name, essayQuestion.control); + } + + return essayQuestion; + } + + /** + * Get a matching question data. + * + * @param questionForm The form group where to add the controls. + * @param question Basic question data. + * @param fieldContainer HTMLElement containing the data. + * @return Question data. + */ + protected getMatchingQuestionData( + questionForm: FormGroup, + question: AddonModLessonQuestion, + fieldContainer: HTMLElement, + ): AddonModLessonMatchingQuestion { + + const matchingQuestion = { + ...question, + template: 'matching', + rows: [], + }; + + const rows = Array.from(fieldContainer.querySelectorAll('.answeroption')); + + rows.forEach((row) => { + const label = row.querySelector('label'); + const select = row.querySelector('select'); + const options = Array.from(row.querySelectorAll('option')); + + if (!label || !select || !options || !options.length) { + return; + } + + // Get the row's text (label). + const rowData: AddonModLessonMatchingRow = { + text: label.innerHTML.trim(), + id: select.id, + name: select.name, + options: [], + }; + + // Treat each option. + let controlAdded = false; + options.forEach((option) => { + if (typeof option.value == 'undefined') { + // Option not valid, ignore it. + return; + } + + const optionData: AddonModLessonMatchingRowOption = { + value: option.value, + label: option.innerHTML.trim(), + selected: option.selected, + }; + + if (optionData.selected) { + controlAdded = true; + questionForm.addControl( + rowData.name, + this.formBuilder.control({ value: optionData.value, disabled: !!select.disabled }), + ); + } + + rowData.options.push(optionData); + }); + + if (!controlAdded) { + // No selected option, add the control with an empty value. + questionForm.addControl(rowData.name, this.formBuilder.control({ value: '', disabled: !!select.disabled })); + } + + matchingQuestion.rows.push(rowData); + }); + + return matchingQuestion; + } + + /** + * Given the HTML of an answer from a question page, extract the data to render the answer. + * + * @param html Answer's HTML. + * @return Object with the data to render the answer. If the answer doesn't require any parsing, return a string with the HTML. + */ + getQuestionPageAnswerDataFromHtml(html: string): AddonModLessonAnswerData { + const element = CoreDomUtils.instance.convertToElement(html); + + // Check if it has a checkbox. + let input = element.querySelector('input[type="checkbox"][name*="answer"]'); + if (input) { + // Truefalse or multichoice. + const data: AddonModLessonCheckboxAnswerData = { + isCheckbox: true, + checked: !!input.checked, + name: input.name, + highlight: !!element.querySelector('.highlight'), + content: '', + }; + + input.remove(); + data.content = element.innerHTML.trim(); + + return data; + } + + // Check if it has an input text or number. + input = element.querySelector('input[type="number"],input[type="text"]'); + if (input) { + // Short answer or numeric. + return { + isText: true, + value: input.value, + }; + } + + // Check if it has a select. + const select = element.querySelector('select'); + if (select?.options) { + // Matching. + const selectedOption = select.options[select.selectedIndex]; + const data: AddonModLessonSelectAnswerData = { + isSelect: true, + id: select.id, + value: selectedOption ? selectedOption.value : '', + content: '', + }; + + select.remove(); + data.content = element.innerHTML.trim(); + + return data; + } + + // The answer doesn't need any parsing, return the HTML as it is. + return html; + } + + /** + * Get a label to identify a retake (lesson attempt). + * + * @param retake Retake object. + * @param includeDuration Whether to include the duration of the retake. + * @return Retake label. + */ + getRetakeLabel(retake: AddonModLessonAttemptsOverviewsAttemptWSData, includeDuration?: boolean): string { + const data = { + retake: retake.try + 1, + grade: '', + timestart: '', + duration: '', + }; + const hasGrade = retake.grade != null; + + if (hasGrade || retake.end) { + // Retake finished with or without grade (if the lesson only has content pages, it has no grade). + if (hasGrade) { + data.grade = Translate.instance.instant('core.percentagenumber', { $a: retake.grade }); + } + data.timestart = CoreTimeUtils.instance.userDate(retake.timestart * 1000); + if (includeDuration) { + data.duration = CoreTimeUtils.instance.formatTime(retake.timeend - retake.timestart); + } + } else { + // The user has not completed the retake. + data.grade = Translate.instance.instant('addon.mod_lesson.notcompleted'); + if (retake.timestart) { + data.timestart = CoreTimeUtils.instance.userDate(retake.timestart * 1000); + } + } + + return Translate.instance.instant('addon.mod_lesson.retakelabel' + (includeDuration ? 'full' : 'short'), data); + } + + /** + * Prepare the question data to be sent to server. + * + * @param question Question to prepare. + * @param data Data to prepare. + * @return Data to send. + */ + prepareQuestionData(question: AddonModLessonQuestion, data: Record): Record { + if (question.template == 'essay') { + const textarea = ( question).textarea; + + // Add some HTML to the answer if needed. + if (textarea) { + data[textarea.name] = CoreTextUtils.instance.formatHtmlLines( data[textarea.name]); + } + } 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 data; + } + + /** + * Given the feedback of a process page in HTML, remove the question text. + * + * @param html Feedback's HTML. + * @return Feedback without the question text. + */ + removeQuestionFromFeedback(html: string): string { + const element = CoreDomUtils.instance.convertToElement(html); + + // Remove the question text. + CoreDomUtils.instance.removeElement(element, '.generalbox:not(.feedback):not(.correctanswer)'); + + return element.innerHTML.trim(); + } + +} + +export class AddonModLessonHelper extends makeSingleton(AddonModLessonHelperProvider) {} + +/** + * Page button data. + */ +export type AddonModLessonPageButton = { + id: string; + title: string; + content: string; + data: Record; +}; + +/** + * Generic question data. + */ +export type AddonModLessonQuestion = { + template: string; // Name of the template to use. + submitLabel: string; // Text to display in submit. +}; + +/** + * Multichoice question data. + */ +export type AddonModLessonMultichoiceQuestion = AddonModLessonQuestion & { + multi: boolean; // Whether it allows multiple answers. + options: AddonModLessonMultichoiceOption[]; // Options for multichoice question. + controlName?: string; // Name of the form control, for single choice. +}; + +/** + * Short answer or numeric question data. + */ +export type AddonModLessonInputQuestion = AddonModLessonQuestion & { + input?: AddonModLessonQuestionInput; // Text input for text/number questions. +}; + +/** + * Essay question data. + */ +export type AddonModLessonEssayQuestion = AddonModLessonQuestion & { + useranswer?: string; // User answer, for reviewing. + textarea?: AddonModLessonTextareaData; // Data for the textarea. + control?: FormControl; // Form control. +}; + +/** + * Matching question data. + */ +export type AddonModLessonMatchingQuestion = AddonModLessonQuestion & { + rows: AddonModLessonMatchingRow[]; +}; + +/** + * Data for each option in a multichoice question. + */ +export type AddonModLessonMultichoiceOption = { + id: string; + name: string; + value: string; + checked: boolean; + disabled: boolean; + text: string; +}; + +/** + * Input data for text/number questions. + */ +export type AddonModLessonQuestionInput = { + id: string; + name: string; + maxlength: number; + type: string; +}; + +/** + * Textarea data for essay questions. + */ +export type AddonModLessonTextareaData = { + id: string; + name: string; +}; + +/** + * Data for each row in a matching question. + */ +export type AddonModLessonMatchingRow = { + id: string; + name: string; + text: string; + options: AddonModLessonMatchingRowOption[]; +}; + +/** + * Data for each option in a row in a matching question. + */ +export type AddonModLessonMatchingRowOption = { + value: string; + label: string; + selected: boolean; +}; + +/** + * Checkbox answer. + */ +export type AddonModLessonCheckboxAnswerData = { + isCheckbox: true; + checked: boolean; + name: string; + highlight: boolean; + content: string; +}; + +/** + * Text answer. + */ +export type AddonModLessonTextAnswerData = { + isText: true; + value: string; +}; + +/** + * Select answer. + */ +export type AddonModLessonSelectAnswerData = { + isSelect: true; + id: string; + value: string; + content: string; +}; + +/** + * Any possible answer data. + */ +export type AddonModLessonAnswerData = + AddonModLessonCheckboxAnswerData | AddonModLessonTextAnswerData | AddonModLessonSelectAnswerData | string; diff --git a/src/addons/mod/lesson/services/lesson-offline.ts b/src/addons/mod/lesson/services/lesson-offline.ts new file mode 100644 index 000000000..b557428f9 --- /dev/null +++ b/src/addons/mod/lesson/services/lesson-offline.ts @@ -0,0 +1,565 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { + AddonModLessonPageAttemptDBRecord, + AddonModLessonRetakeDBRecord, + PAGE_ATTEMPTS_TABLE_NAME, + RETAKES_TABLE_NAME, +} from './database/lesson'; + +import { AddonModLessonPageWSData, AddonModLessonProvider } from './lesson'; + +/** + * Service to handle offline lesson. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModLessonOfflineProvider { + + /** + * Delete an offline attempt. + * + * @param lessonId Lesson ID. + * @param retake Lesson retake number. + * @param pageId Page ID. + * @param timemodified The timemodified of the attempt. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteAttempt(lessonId: number, retake: number, pageId: number, timemodified: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().deleteRecords(PAGE_ATTEMPTS_TABLE_NAME, > { + lessonid: lessonId, + retake: retake, + pageid: pageId, + timemodified: timemodified, + }); + } + + /** + * Delete offline lesson retake. + * + * @param lessonId Lesson ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteRetake(lessonId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().deleteRecords(RETAKES_TABLE_NAME, > { lessonid: lessonId }); + } + + /** + * Delete offline attempts for a retake and page. + * + * @param lessonId Lesson ID. + * @param retake Lesson retake number. + * @param pageId Page ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteRetakeAttemptsForPage(lessonId: number, retake: number, pageId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().deleteRecords(PAGE_ATTEMPTS_TABLE_NAME, > { + lessonid: lessonId, + retake: retake, + pageid: pageId, + }); + } + + /** + * Mark a retake as finished. + * + * @param lessonId Lesson ID. + * @param courseId Course ID the lesson belongs to. + * @param retake Retake number. + * @param finished Whether retake is finished. + * @param outOfTime If the user ran out of time. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success, rejected otherwise. + */ + async finishRetake( + lessonId: number, + courseId: number, + retake: number, + finished?: boolean, + outOfTime?: boolean, + siteId?: string, + ): Promise { + + const site = await CoreSites.instance.getSite(siteId); + + // Get current stored retake (if any). If not found, it will create a new one. + const entry = await this.getRetakeWithFallback(lessonId, courseId, retake, site.id); + + entry.finished = finished ? 1 : 0; + entry.outoftime = outOfTime ? 1 : 0; + entry.timemodified = CoreTimeUtils.instance.timestamp(); + + await site.getDb().insertRecord(RETAKES_TABLE_NAME, entry); + } + + /** + * Get all the offline page attempts in a certain site. + * + * @param siteId Site ID. If not set, use current site. + * @return Promise resolved when the offline attempts are retrieved. + */ + async getAllAttempts(siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + const attempts = await db.getAllRecords(PAGE_ATTEMPTS_TABLE_NAME); + + return this.parsePageAttempts(attempts); + } + + /** + * Get all the lessons that have offline data in a certain site. + * + * @param siteId Site ID. If not set, use current site. + * @return Promise resolved with an object containing the lessons. + */ + async getAllLessonsWithData(siteId?: string): Promise { + const lessons: Record = {}; + + const [pageAttempts, retakes] = await Promise.all([ + CoreUtils.instance.ignoreErrors(this.getAllAttempts(siteId)), + CoreUtils.instance.ignoreErrors(this.getAllRetakes(siteId)), + ]); + + this.getLessonsFromEntries(lessons, pageAttempts || []); + this.getLessonsFromEntries(lessons, retakes || []); + + return CoreUtils.instance.objectToArray(lessons); + } + + /** + * Get all the offline retakes in a certain site. + * + * @param siteId Site ID. If not set, use current site. + * @return Promise resolved when the offline retakes are retrieved. + */ + async getAllRetakes(siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + return db.getAllRecords(RETAKES_TABLE_NAME); + } + + /** + * Retrieve the last offline attempt stored in a retake. + * + * @param lessonId Lesson ID. + * @param retake Retake number. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the attempt (undefined if no attempts). + */ + async getLastQuestionPageAttempt( + lessonId: number, + retake: number, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + try { + const retakeData = await this.getRetakeWithFallback(lessonId, 0, retake, siteId); + if (!retakeData.lastquestionpage) { + // No question page attempted. + return; + } + + const attempts = await this.getRetakeAttemptsForPage(lessonId, retake, retakeData.lastquestionpage, siteId); + + // Return the attempt with highest timemodified. + return attempts.reduce((a, b) => a.timemodified > b.timemodified ? a : b); + } catch { + // Error, return undefined. + } + } + + /** + * Retrieve all offline attempts for a lesson. + * + * @param lessonId Lesson ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the attempts. + */ + async getLessonAttempts(lessonId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const attempts = await site.getDb().getRecords( + PAGE_ATTEMPTS_TABLE_NAME, + { lessonid: lessonId }, + ); + + return this.parsePageAttempts(attempts); + } + + /** + * Given a list of DB entries (either retakes or page attempts), get the list of lessons. + * + * @param lessons Object where to store the lessons. + * @param entries List of DB entries. + */ + protected getLessonsFromEntries( + lessons: Record, + entries: (AddonModLessonPageAttemptRecord | AddonModLessonRetakeDBRecord)[], + ): void { + entries.forEach((entry) => { + if (!lessons[entry.lessonid]) { + lessons[entry.lessonid] = { + id: entry.lessonid, + courseId: entry.courseid, + }; + } + }); + } + + /** + * Get attempts for question pages and retake in a lesson. + * + * @param lessonId Lesson ID. + * @param retake Retake number. + * @param correct True to only fetch correct attempts, false to get them all. + * @param pageId If defined, only get attempts on this page. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the attempts. + */ + async getQuestionsAttempts( + lessonId: number, + retake: number, + correct?: boolean, + pageId?: number, + siteId?: string, + ): Promise { + const attempts = pageId ? + await this.getRetakeAttemptsForPage(lessonId, retake, pageId, siteId) : + await this.getRetakeAttemptsForType(lessonId, retake, AddonModLessonProvider.TYPE_QUESTION, siteId); + + if (correct) { + return attempts.filter((attempt) => !!attempt.correct); + } + + return attempts; + } + + /** + * Retrieve a retake from site DB. + * + * @param lessonId Lesson ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the retake. + */ + async getRetake(lessonId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.getDb().getRecord(RETAKES_TABLE_NAME, { lessonid: lessonId }); + } + + /** + * Retrieve all offline attempts for a retake. + * + * @param lessonId Lesson ID. + * @param retake Retake number. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the retake attempts. + */ + async getRetakeAttempts(lessonId: number, retake: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const attempts = await site.getDb().getRecords( + PAGE_ATTEMPTS_TABLE_NAME, + > { + lessonid: lessonId, + retake, + }, + ); + + return this.parsePageAttempts(attempts); + } + + /** + * Retrieve offline attempts for a retake and page. + * + * @param lessonId Lesson ID. + * @param retake Lesson retake number. + * @param pageId Page ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the retake attempts. + */ + async getRetakeAttemptsForPage( + lessonId: number, + retake: number, + pageId: number, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const attempts = await site.getDb().getRecords( + PAGE_ATTEMPTS_TABLE_NAME, + > { + lessonid: lessonId, + retake, + pageid: pageId, + }, + ); + + return this.parsePageAttempts(attempts); + } + + /** + * Retrieve offline attempts for certain pages for a retake. + * + * @param lessonId Lesson ID. + * @param retake Retake number. + * @param type Type of the pages to get: TYPE_QUESTION or TYPE_STRUCTURE. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the retake attempts. + */ + async getRetakeAttemptsForType( + lessonId: number, + retake: number, + type: number, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const attempts = await site.getDb().getRecords( + PAGE_ATTEMPTS_TABLE_NAME, + > { + lessonid: lessonId, + retake, + type, + }, + ); + + return this.parsePageAttempts(attempts); + } + + /** + * Get stored retake. If not found or doesn't match the retake number, return a new one. + * + * @param lessonId Lesson ID. + * @param courseId Course ID the lesson belongs to. + * @param retake Retake number. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the retake. + */ + protected async getRetakeWithFallback( + lessonId: number, + courseId: number, + retake: number, + siteId?: string, + ): Promise { + try { + // Get current stored retake. + const retakeData = await this.getRetake(lessonId, siteId); + + if (retakeData.retake == retake) { + return retakeData; + } + } catch { + // No retake, create a new one. + } + + // Create a new retake. + return { + lessonid: lessonId, + retake, + courseid: courseId, + finished: 0, + }; + } + + /** + * Check if there is a finished retake for a certain lesson. + * + * @param lessonId Lesson ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean. + */ + async hasFinishedRetake(lessonId: number, siteId?: string): Promise { + try { + const retake = await this.getRetake(lessonId, siteId); + + return !!retake.finished; + } catch { + return false; + } + } + + /** + * Check if a lesson has offline data. + * + * @param lessonId Lesson ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean. + */ + async hasOfflineData(lessonId: number, siteId?: string): Promise { + const [retake, attempts] = await Promise.all([ + CoreUtils.instance.ignoreErrors(this.getRetake(lessonId, siteId)), + CoreUtils.instance.ignoreErrors(this.getLessonAttempts(lessonId, siteId)), + ]); + + return !!retake || !!attempts?.length; + } + + /** + * Check if there are offline attempts for a retake. + * + * @param lessonId Lesson ID. + * @param retake Retake number. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with a boolean. + */ + async hasRetakeAttempts(lessonId: number, retake: number, siteId?: string): Promise { + try { + const list = await this.getRetakeAttempts(lessonId, retake, siteId); + + return !!list.length; + } catch { + return false; + } + } + + /** + * Parse some properties of a page attempt. + * + * @param attempt The attempt to treat. + * @return The treated attempt. + */ + protected parsePageAttempt(attempt: AddonModLessonPageAttemptDBRecord): AddonModLessonPageAttemptRecord { + return { + ...attempt, + data: attempt.data ? CoreTextUtils.instance.parseJSON(attempt.data) : null, + useranswer: attempt.useranswer ? CoreTextUtils.instance.parseJSON(attempt.useranswer) : null, + }; + } + + /** + * Parse some properties of some page attempts. + * + * @param attempts The attempts to treat. + * @return The treated attempts. + */ + protected parsePageAttempts(attempts: AddonModLessonPageAttemptDBRecord[]): AddonModLessonPageAttemptRecord[] { + return attempts.map((attempt) => this.parsePageAttempt(attempt)); + } + + /** + * Process a lesson page, saving its data. + * + * @param lessonId Lesson ID. + * @param courseId Course ID the lesson belongs to. + * @param retake Retake number. + * @param page Page. + * @param data Data to save. + * @param newPageId New page ID (calculated). + * @param answerId The answer ID that the user answered. + * @param correct If answer is correct. Only for question pages. + * @param userAnswer The user's answer (userresponse from checkAnswer). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success, rejected otherwise. + */ + async processPage( + lessonId: number, + courseId: number, + retake: number, + page: AddonModLessonPageWSData, + data: Record, + newPageId: number, + answerId?: number, + correct?: boolean, + userAnswer?: unknown, + siteId?: string, + ): Promise { + + const site = await CoreSites.instance.getSite(siteId); + + const entry: AddonModLessonPageAttemptDBRecord = { + lessonid: lessonId, + retake: retake, + pageid: page.id, + timemodified: CoreTimeUtils.instance.timestamp(), + courseid: courseId, + data: data ? JSON.stringify(data) : null, + type: page.type, + newpageid: newPageId, + correct: correct ? 1 : 0, + answerid: answerId || null, + useranswer: userAnswer ? JSON.stringify(userAnswer) : null, + }; + + await site.getDb().insertRecord(PAGE_ATTEMPTS_TABLE_NAME, entry); + + if (page.type == AddonModLessonProvider.TYPE_QUESTION) { + // It's a question page, set it as last question page attempted. + await this.setLastQuestionPageAttempted(lessonId, courseId, retake, page.id, siteId); + } + } + + /** + * Set the last question page attempted in a retake. + * + * @param lessonId Lesson ID. + * @param courseId Course ID the lesson belongs to. + * @param retake Retake number. + * @param lastPage ID of the last question page attempted. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success, rejected otherwise. + */ + async setLastQuestionPageAttempted( + lessonId: number, + courseId: number, + retake: number, + lastPage: number, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + // Get current stored retake (if any). If not found, it will create a new one. + const entry = await this.getRetakeWithFallback(lessonId, courseId, retake, site.id); + + entry.lastquestionpage = lastPage; + entry.timemodified = CoreTimeUtils.instance.timestamp(); + + await site.getDb().insertRecord(RETAKES_TABLE_NAME, entry); + } + +} + +export class AddonModLessonOffline extends makeSingleton(AddonModLessonOfflineProvider) {} + +/** + * Attempt DB record with parsed data. + */ +export type AddonModLessonPageAttemptRecord = Omit & { + data: Record | null; + useranswer: unknown | null; +}; + +/** + * Lesson data stored in DB. + */ +export type AddonModLessonLessonStoredData = { + id: number; + courseId: number; +}; diff --git a/src/addons/mod/lesson/services/lesson.ts b/src/addons/mod/lesson/services/lesson.ts new file mode 100644 index 000000000..2e1a8c2a4 --- /dev/null +++ b/src/addons/mod/lesson/services/lesson.ts @@ -0,0 +1,4231 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreError } from '@classes/errors/error'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreGradesProvider } from '@features/grades/services/grades'; +import { CoreSites, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreEvents, CoreEventSiteData } from '@singletons/events'; +import { AddonModLessonPasswordDBRecord, PASSWORD_TABLE_NAME } from './database/lesson'; +import { AddonModLessonOffline, AddonModLessonPageAttemptRecord } from './lesson-offline'; + +const ROOT_CACHE_KEY = 'mmaModLesson:'; + +/** + * Service that provides some features for lesson. + * + * Lesson terminology is a bit confusing and ambiguous in Moodle. For that reason, in the app it has been decided to use + * the following terminology: + * - Retake: An attempt in a lesson. In Moodle it's sometimes called "attempt", "try" or "retry". + * - Attempt: An attempt in a page inside a retake. In the app, this includes content pages. + * - Content page: A page with only content (no question). In Moodle it's sometimes called "branch table". + * - Page answers: List of possible answers for a page (configured by the teacher). NOT the student answer for the page. + * + * This terminology sometimes won't match with WebServices names, params or responses. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModLessonProvider { + + static readonly COMPONENT = 'mmaModLesson'; + static readonly DATA_SENT_EVENT = 'addon_mod_lesson_data_sent'; + + static readonly LESSON_THISPAGE = 0; // This page. + static readonly LESSON_UNSEENPAGE = 1; // Next page -> any page not seen before. + static readonly LESSON_UNANSWEREDPAGE = 2; // Next page -> any page not answered correctly. + static readonly LESSON_NEXTPAGE = -1; // Jump to Next Page. + static readonly LESSON_EOL = -9; // End of Lesson. + static readonly LESSON_UNSEENBRANCHPAGE = -50; // Jump to an unseen page within a branch and end of branch or end of lesson. + static readonly LESSON_RANDOMPAGE = -60; // Jump to a random page within a branch and end of branch or end of lesson. + static readonly LESSON_RANDOMBRANCH = -70; // Jump to a random Branch. + static readonly LESSON_CLUSTERJUMP = -80; // Cluster Jump. + + // Type of page: question or structure (content). + static readonly TYPE_QUESTION = 0; + static readonly TYPE_STRUCTURE = 1; + + // Type of question pages. + static readonly LESSON_PAGE_SHORTANSWER = 1; + static readonly LESSON_PAGE_TRUEFALSE = 2; + static readonly LESSON_PAGE_MULTICHOICE = 3; + static readonly LESSON_PAGE_MATCHING = 5; + static readonly LESSON_PAGE_NUMERICAL = 8; + static readonly LESSON_PAGE_ESSAY = 10; + static readonly LESSON_PAGE_BRANCHTABLE = 20; // Content page. + static readonly LESSON_PAGE_ENDOFBRANCH = 21; + static readonly LESSON_PAGE_CLUSTER = 30; + static readonly LESSON_PAGE_ENDOFCLUSTER = 31; + + static readonly MULTIANSWER_DELIMITER = '@^#|'; // Constant used as a delimiter when parsing multianswer questions. + static readonly LESSON_OTHER_ANSWERS = '@#wronganswer#@'; + + /** + * Add an answer and its response to a feedback string (HTML). + * + * @param feedback The current feedback. + * @param answer Student answer. + * @param answerFormat Answer format. + * @param response Response. + * @param className Class to add to the response. + * @return New feedback. + */ + protected addAnswerAndResponseToFeedback( + feedback: string, + answer: string, + answerFormat: number, + response: string, + className: string, + ): string { + // Add a table row containing the answer. + feedback += '' + (answerFormat ? answer : CoreTextUtils.instance.cleanTags(answer)) + + ''; + + // If the response exists, add a table row containing the response. If not, add en empty row. + if (response?.trim()) { + feedback += '' + + Translate.instance.instant('addon.mod_lesson.response') + ':
' + + response + ''; + } else { + feedback += ''; + } + + return feedback; + } + + /** + * Add a message to a list of messages, following the format of the messages returned by WS. + * + * @param messages List of messages where to add the message. + * @param stringId The ID of the message to be translated. E.g. 'addon.mod_lesson.numberofpagesviewednotice'. + * @param stringParams The params of the message (if any). + */ + protected addMessage(messages: AddonModLessonMessageWSData[], stringId: string, stringParams?: Record): void { + messages.push({ + message: Translate.instance.instant(stringId, stringParams), + type: '', + }); + } + + /** + * Add a property to the result of the "process EOL page" simulation in offline. + * + * @param result Result where to add the value. + * @param name Name of the property. + * @param value Value to add. + * @param addMessage Whether to add a message related to the value. + */ + protected addResultValueEolPage( + result: AddonModLessonFinishRetakeResponse, + name: string, + value: unknown, + addMessage?: boolean, + ): void { + let message = ''; + + if (addMessage) { + const params = typeof value != 'boolean' ? { $a: value } : undefined; + message = Translate.instance.instant('addon.mod_lesson.' + name, params); + } + + result.data[name] = { + name: name, + value: value, + message: message, + }; + } + + /** + * Check if an answer page (from getUserRetake) is a content page. + * + * @param page Answer page. + * @return Whether it's a content page. + */ + answerPageIsContent(page: AddonModLessonUserAttemptAnswerPageWSData): boolean { + // The page doesn't have any reliable field to use for checking this. Check qtype first (translated string). + if (page.qtype == Translate.instance.instant('addon.mod_lesson.branchtable')) { + return true; + } + + // The qtype doesn't match, but that doesn't mean it's not a content page, maybe the language is different. + // Check it's not a question page. + if (page.answerdata && !this.answerPageIsQuestion(page)) { + // It isn't a question page, but it can be an end of branch, etc. Check if the first answer has a button. + if (page.answerdata.answers && page.answerdata.answers[0]) { + const element = CoreDomUtils.instance.convertToElement(page.answerdata.answers[0][0]); + + return !!element.querySelector('input[type="button"]'); + } + } + + return false; + } + + /** + * Check if an answer page (from getUserRetake) is a question page. + * + * @param page Answer page. + * @return Whether it's a question page. + */ + answerPageIsQuestion(page: AddonModLessonUserAttemptAnswerPageWSData): boolean { + if (!page.answerdata) { + return false; + } + + if (page.answerdata.score) { + // Only question pages have a score. + return true; + } + + if (page.answerdata.answers) { + for (let i = 0; i < page.answerdata.answers.length; i++) { + const answer = page.answerdata.answers[i]; + if (answer[1]) { + // Only question pages have a statistic. + return true; + } + } + } + + return false; + } + + /** + * Calculate some offline data like progress and ongoingscore. + * + * @param lesson Lesson. + * @param options Other options. + * @return Promise resolved with the data. + */ + protected async calculateOfflineData( + lesson: AddonModLessonLessonWSData, + options: AddonModLessonCalculateOfflineDataOptions = {}, + ): Promise<{reviewmode: boolean; progress?: number; ongoingscore: string}> { + + const reviewMode = !!(options.review || options.accessInfo?.reviewmode); + let ongoingMessage = ''; + let progress: number | undefined; + + if (options.accessInfo && !options.accessInfo.canmanage) { + if (lesson.ongoing && !reviewMode) { + ongoingMessage = await this.getOngoingScoreMessage(lesson, options.accessInfo, options); + } + + if (lesson.progressbar) { + const modOptions = { + cmId: lesson.coursemodule, + ...options, // Include all options. + }; + + progress = await this.calculateProgress(lesson.id, options.accessInfo, modOptions); + } + } + + return { + reviewmode: reviewMode, + progress, + ongoingscore: ongoingMessage, + }; + } + + /** + * Calculate the progress of the current user in the lesson. + * Based on Moodle's calculate_progress. + * + * @param lessonId Lesson ID. + * @param accessInfo Access info. + * @param options Other options. + * @return Promise resolved with a number: the progress (scale 0-100). + */ + async calculateProgress( + lessonId: number, + accessInfo: AddonModLessonGetAccessInformationWSResponse, + options: AddonModLessonCalculateProgressOptions = {}, + ): Promise { + options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId(); + + // Check if the user is reviewing the attempt. + if (options.review) { + return 100; + } + + const retake = accessInfo.attemptscount; + const commonOptions = { + cmId: options.cmId, + siteId: options.siteId, + }; + + if (!options.pageIndex) { + // Retrieve the index. + const pages = await this.getPages(lessonId, { + cmId: options.cmId, + password: options.password, + readingStrategy: CoreSitesReadingStrategy.PreferCache, + siteId: options.siteId, + }); + + options.pageIndex = this.createPagesIndex(pages); + } + + // Get the list of question pages attempted. + let viewedPagesIds = await this.getPagesIdsWithQuestionAttempts(lessonId, retake, commonOptions); + + // Get the list of viewed content pages. + const viewedContentPagesIds = await this.getContentPagesViewedIds(lessonId, retake, commonOptions); + + const validPages = {}; + let pageId = accessInfo.firstpageid; + + viewedPagesIds = CoreUtils.instance.mergeArraysWithoutDuplicates(viewedPagesIds, viewedContentPagesIds); + + // Filter out the following pages: + // - End of Cluster + // - End of Branch + // - Pages found inside of Clusters + // Do not filter out Cluster Page(s) because we count a cluster as one. + // By keeping the cluster page, we get our 1. + while (pageId) { + pageId = this.validPageAndView(options.pageIndex, options.pageIndex[pageId], validPages, viewedPagesIds); + } + + // Progress calculation as a percent. + return CoreTextUtils.instance.roundToDecimals(viewedPagesIds.length / Object.keys(validPages).length, 2) * 100; + } + + /** + * Check if the answer provided by the user is correct or not and return the result object. + * This method is based on the check_answer implementation of all page types (Moodle). + * + * @param lesson Lesson. + * @param pageData Page data. + * @param data Data containing the user answer. + * @param jumps Possible jumps. + * @param pageIndex Object containing all the pages indexed by ID. + * @return Result. + */ + protected checkAnswer( + lesson: AddonModLessonLessonWSData, + pageData: AddonModLessonGetPageDataWSResponse, + data: Record, + jumps: AddonModLessonPossibleJumps, + pageIndex: Record, + ): AddonModLessonCheckAnswerResult { + // Default result. + const result: AddonModLessonCheckAnswerResult = { + answerid: 0, + noanswer: false, + correctanswer: false, + isessayquestion: false, + response: '', + newpageid: 0, + studentanswer: '', + userresponse: '', + feedback: '', + nodefaultresponse: false, + inmediatejump: false, + }; + + switch (pageData.page!.qtype) { + case AddonModLessonProvider.LESSON_PAGE_BRANCHTABLE: + // Load the new page immediately. + result.inmediatejump = true; + result.newpageid = this.getNewPageId(pageData.page!.id, data.jumpto, jumps); + break; + + case AddonModLessonProvider.LESSON_PAGE_ESSAY: + this.checkAnswerEssay(pageData, data, result); + break; + + case AddonModLessonProvider.LESSON_PAGE_MATCHING: + this.checkAnswerMatching(pageData, data, result); + break; + + case AddonModLessonProvider.LESSON_PAGE_MULTICHOICE: + this.checkAnswerMultichoice(lesson, pageData, data, pageIndex, result); + break; + + case AddonModLessonProvider.LESSON_PAGE_NUMERICAL: + this.checkAnswerNumerical(lesson, pageData, data, pageIndex, result); + break; + + case AddonModLessonProvider.LESSON_PAGE_SHORTANSWER: + this.checkAnswerShort(lesson, pageData, data, pageIndex, result); + break; + + case AddonModLessonProvider.LESSON_PAGE_TRUEFALSE: + this.checkAnswerTruefalse(lesson, pageData, data, pageIndex, result); + break; + default: + // Nothing to do. + } + + return result; + } + + /** + * Check an essay answer. + * + * @param pageData Page data. + * @param data Data containing the user answer. + * @param result Object where to store the result. + */ + protected checkAnswerEssay( + pageData: AddonModLessonGetPageDataWSResponse, + data: Record, + result: AddonModLessonCheckAnswerResult, + ): void { + let studentAnswer; + + result.isessayquestion = true; + + if (!data) { + result.inmediatejump = true; + result.newpageid = pageData.page!.id; + + return; + } + + // The name was changed to "answer_editor" in 3.7. Before it was just "answer". Support both cases. + if (typeof data['answer_editor[text]'] != 'undefined') { + studentAnswer = data['answer_editor[text]']; + } else if (typeof data.answer_editor == 'object') { + studentAnswer = (<{text: string}> data.answer_editor).text; + } else if (typeof data['answer[text]'] != 'undefined') { + studentAnswer = data['answer[text]']; + } else if (typeof data.answer == 'object') { + studentAnswer = (<{text: string}> data.answer).text; + } else { + studentAnswer = data.answer; + } + + if (!studentAnswer || studentAnswer.trim() === '') { + result.noanswer = true; + + return; + } + + // Essay pages should only have 1 possible answer. + pageData.answers.forEach((answer) => { + result.answerid = answer.id; + result.newpageid = answer.jumpto || 0; + }); + + result.userresponse = { + sent: 0, + graded: 0, + score: 0, + answer: studentAnswer, + answerformat: 1, + response: '', + responseformat: 1, + }; + result.studentanswerformat = 1; + result.studentanswer = studentAnswer; + } + + /** + * Check a matching answer. + * + * @param pageData Page data for the page to process. + * @param data Data containing the user answer. + * @param result Object where to store the result. + */ + protected checkAnswerMatching( + pageData: AddonModLessonGetPageDataWSResponse, + data: Record, + result: AddonModLessonCheckAnswerResult, + ): void { + if (!data) { + result.inmediatejump = true; + result.newpageid = pageData.page!.id; + + return; + } + + const response = this.getUserResponseMatching(data); + const getAnswers = CoreUtils.instance.clone(pageData.answers); + const correct = getAnswers.shift(); + const wrong = getAnswers.shift(); + const answers: Record = {}; + + getAnswers.forEach((answer) => { + if (answer.answer !== '' || answer.response !== '') { + answers[answer.id] = answer; + } + }); + + // Get the user's exact responses for record keeping. + const userResponse: string[] = []; + let hits = 0; + + result.studentanswer = ''; + result.studentanswerformat = 1; + + for (const id in response) { + let value = response[id]; + + if (!value) { + result.noanswer = true; + + return; + } + + value = CoreTextUtils.instance.decodeHTML(value); + userResponse.push(value); + + if (typeof answers[id] != 'undefined') { + const answer = answers[id]; + + result.studentanswer += '
' + answer.answer + ' = ' + value; + if (answer.response && answer.response.trim() == value.trim()) { + hits++; + } + } + } + + result.userresponse = userResponse.join(','); + + if (hits == Object.keys(answers).length) { + result.correctanswer = true; + result.response = correct!.answer || ''; + result.answerid = correct!.id; + result.newpageid = correct!.jumpto || 0; + } else { + result.correctanswer = false; + result.response = wrong!.answer || ''; + result.answerid = wrong!.id; + result.newpageid = wrong!.jumpto || 0; + } + } + + /** + * Check a multichoice answer. + * + * @param lesson Lesson. + * @param pageData Page data for the page to process. + * @param data Data containing the user answer. + * @param pageIndex Object containing all the pages indexed by ID. + * @param result Object where to store the result. + */ + protected checkAnswerMultichoice( + lesson: AddonModLessonLessonWSData, + pageData: AddonModLessonGetPageDataWSResponse, + data: Record, + pageIndex: Record, + result: AddonModLessonCheckAnswerResult, + ): void { + + if (!data) { + result.inmediatejump = true; + result.newpageid = pageData.page!.id; + + return; + } + + const answers = this.getUsedAnswersMultichoice(pageData); + + if (pageData.page!.qoption) { + // Multianswer allowed, user's answer is an array. + const studentAnswers = this.getUserResponseMultichoice(data); + + if (!studentAnswers || !Array.isArray(studentAnswers)) { + result.noanswer = true; + + return; + } + + // Get what the user answered. + result.userresponse = studentAnswers.join(','); + + // Get the answers in a set order, the id order. + const studentAswersArray: string[] = []; + const responses: string[] = []; + let nHits = 0; + let nCorrect = 0; + let correctAnswerId = 0; + let wrongAnswerId = 0; + let correctPageId: number | undefined; + let wrongPageId: number | undefined; + + // Store student's answers for displaying on feedback page. + result.studentanswer = ''; + result.studentanswerformat = 1; + answers.forEach((answer) => { + for (const i in studentAnswers) { + const answerId = studentAnswers[i]; + + if (answerId == answer.id) { + studentAswersArray.push(answer.answer!); + responses.push(answer.response || ''); + break; + } + } + }); + result.studentanswer = studentAswersArray.join(AddonModLessonProvider.MULTIANSWER_DELIMITER); + + // Iterate over all the possible answers. + answers.forEach((answer) => { + const correctAnswer = this.isAnswerCorrect(lesson, pageData.page!.id, answer, pageIndex); + + // Iterate over all the student answers to check if he selected the current possible answer. + studentAnswers.forEach((answerId) => { + if (answerId == answer.id) { + if (correctAnswer) { + nHits++; + } else { + // Always use the first student wrong answer. + if (typeof wrongPageId == 'undefined') { + wrongPageId = answer.jumpto; + } + // Save the answer id for scoring. + if (!wrongAnswerId) { + wrongAnswerId = answer.id; + } + } + } + }); + + if (correctAnswer) { + nCorrect++; + + // Save the first jumpto. + if (typeof correctPageId == 'undefined') { + correctPageId = answer.jumpto; + } + // Save the answer id for scoring. + if (!correctAnswerId) { + correctAnswerId = answer.id; + } + } + }); + + if (studentAnswers.length == nCorrect && nHits == nCorrect) { + result.correctanswer = true; + result.response = responses.join(AddonModLessonProvider.MULTIANSWER_DELIMITER); + result.newpageid = correctPageId || 0; + result.answerid = correctAnswerId; + } else { + result.correctanswer = false; + result.response = responses.join(AddonModLessonProvider.MULTIANSWER_DELIMITER); + result.newpageid = wrongPageId || 0; + result.answerid = wrongAnswerId; + } + } else { + // Only one answer allowed. + if (typeof data.answerid == 'undefined' || (!data.answerid && Number(data.answerid) !== 0)) { + result.noanswer = true; + + return; + } + + result.answerid = data.answerid; + + // Search the answer. + for (const i in pageData.answers) { + const answer = pageData.answers[i]; + if (answer.id == data.answerid) { + result.correctanswer = this.isAnswerCorrect(lesson, pageData.page!.id, answer, pageIndex); + result.newpageid = answer.jumpto || 0; + result.response = answer.response || ''; + result.userresponse = result.studentanswer = answer.answer || ''; + break; + } + } + } + } + + /** + * Check a numerical answer. + * + * @param lesson Lesson. + * @param pageData Page data for the page to process. + * @param data Data containing the user answer. + * @param pageIndex Object containing all the pages indexed by ID. + * @param result Object where to store the result. + */ + protected checkAnswerNumerical( + lesson: AddonModLessonLessonWSData, + pageData: AddonModLessonGetPageDataWSResponse, + data: Record, + pageIndex: Record, + result: AddonModLessonCheckAnswerResult, + ): void { + + const parsedAnswer = parseFloat( data.answer); + + // Set defaults. + result.response = ''; + result.newpageid = 0; + + if (!data.answer || isNaN(parsedAnswer)) { + result.noanswer = true; + + return; + } + + result.useranswer = parsedAnswer; + result.studentanswer = result.userresponse = String(result.useranswer); + + // Find the answer. + for (const i in pageData.answers) { + const answer = pageData.answers[i]; + let max: number; + let min: number; + + if (answer.answer && answer.answer.indexOf(':') != -1) { + // There's a pair of values. + const split = answer.answer.split(':'); + min = parseFloat(split[0]); + max = parseFloat(split[1]); + } else { + // Only one value. + min = parseFloat(answer.answer || ''); + max = min; + } + + if (parsedAnswer >= min && parsedAnswer <= max) { + result.newpageid = answer.jumpto || 0; + result.response = answer.response || ''; + result.correctanswer = this.isAnswerCorrect(lesson, pageData.page!.id, answer, pageIndex); + result.answerid = answer.id; + break; + } + } + + this.checkOtherAnswers(lesson, pageData, result); + } + + /** + * Check a short answer. + * + * @param lesson Lesson. + * @param pageData Page data for the page to process. + * @param data Data containing the user answer. + * @param pageIndex Object containing all the pages indexed by ID. + * @param result Object where to store the result. + */ + protected checkAnswerShort( + lesson: AddonModLessonLessonWSData, + pageData: AddonModLessonGetPageDataWSResponse, + data: Record, + pageIndex: Record, + result: AddonModLessonCheckAnswerResult, + ): void { + + let studentAnswer = typeof data.answer == 'string' ? data.answer.trim() : false; + if (!studentAnswer) { + result.noanswer = true; + + return; + } + + // Search the answer in the list of possible answers. + for (const i in pageData.answers) { + const answer = pageData.answers[i]; + const useRegExp = pageData.page!.qoption; + let expectedAnswer = answer.answer || ''; + let isMatch = false; + let ignoreCase = ''; + + if (useRegExp) { + if (expectedAnswer.substr(-2) == '/i') { + expectedAnswer = expectedAnswer.substr(0, expectedAnswer.length - 2); + ignoreCase = 'i'; + } + } else { + expectedAnswer = expectedAnswer.replace('*', '#####'); + expectedAnswer = CoreTextUtils.instance.escapeForRegex(expectedAnswer); + expectedAnswer = expectedAnswer.replace('#####', '.*'); + } + + // See if user typed in any of the correct answers. + if (this.isAnswerCorrect(lesson, pageData.page!.id, answer, pageIndex)) { + if (!useRegExp) { // We are using 'normal analysis', which ignores case. + if (studentAnswer.match(new RegExp('^' + expectedAnswer + '$', 'i'))) { + isMatch = true; + } + } else { + if (studentAnswer.match(new RegExp('^' + expectedAnswer + '$', ignoreCase))) { + isMatch = true; + } + } + if (isMatch) { + result.correctanswer = true; + } + } else { + if (!useRegExp) { + // We are using 'normal analysis'. + // See if user typed in any of the wrong answers; don't worry about case. + if (studentAnswer.match(new RegExp('^' + expectedAnswer + '$', 'i'))) { + isMatch = true; + } + } else { // We are using regular expressions analysis. + const startCode = expectedAnswer.substr(0, 2); + + switch (startCode){ + // 1- Check for absence of required string in studentAnswer (coded by initial '--'). + case '--': + expectedAnswer = expectedAnswer.substr(2); + if (!studentAnswer.match(new RegExp('^' + expectedAnswer + '$', ignoreCase))) { + isMatch = true; + } + break; + + // 2- Check for code for marking wrong strings (coded by initial '++'). + case '++': { + expectedAnswer = expectedAnswer.substr(2); + + // Check for one or several matches. + const matches = studentAnswer.match(new RegExp(expectedAnswer, 'g' + ignoreCase)); + if (matches) { + isMatch = true; + const nb = matches[0].length; + const original: string[] = []; + const marked: string[] = []; + + for (let j = 0; j < nb; j++) { + original.push(matches[0][j]); + marked.push('' + matches[0][j] + ''); + } + + for (let j = 0; j < original.length; j++) { + studentAnswer = studentAnswer.replace(original[j], marked[j]); + } + } + break; + } + // 3- Check for wrong answers belonging neither to -- nor to ++ categories. + default: + if (studentAnswer.match(new RegExp('^' + expectedAnswer + '$', ignoreCase))) { + isMatch = true; + } + break; + } + + result.correctanswer = false; + } + } + + if (isMatch) { + result.newpageid = answer.jumpto || 0; + result.response = answer.response || ''; + result.answerid = answer.id; + break; // Quit answer analysis immediately after a match has been found. + } + } + + this.checkOtherAnswers(lesson, pageData, result); + + result.userresponse = studentAnswer; + result.studentanswer = CoreTextUtils.instance.s(studentAnswer); // Clean student answer as it goes to output. + } + + /** + * Check a truefalse answer. + * + * @param lesson Lesson. + * @param pageData Page data for the page to process. + * @param data Data containing the user answer. + * @param pageIndex Object containing all the pages indexed by ID. + * @param result Object where to store the result. + */ + protected checkAnswerTruefalse( + lesson: AddonModLessonLessonWSData, + pageData: AddonModLessonGetPageDataWSResponse, + data: Record, + pageIndex: Record, + result: AddonModLessonCheckAnswerResult, + ): void { + + if (!data.answerid) { + result.noanswer = true; + + return; + } + + result.answerid = data.answerid; + + // Get the answer. + for (const i in pageData.answers) { + const answer = pageData.answers[i]; + if (answer.id == data.answerid) { + // Answer found. + result.correctanswer = this.isAnswerCorrect(lesson, pageData.page!.id, answer, pageIndex); + result.newpageid = answer.jumpto || 0; + result.response = answer.response || ''; + result.studentanswer = result.userresponse = answer.answer || ''; + break; + } + } + } + + /** + * Check the "other answers" value. + * + * @param lesson Lesson. + * @param pageData Page data for the page to process. + * @param result Object where to store the result. + */ + protected checkOtherAnswers( + lesson: AddonModLessonLessonWSData, + pageData: AddonModLessonGetPageDataWSResponse, + result: AddonModLessonCheckAnswerResult, + ): void { + // We could check here to see if we have a wrong answer jump to use. + if (result.answerid == 0) { + // Use the all other answers jump details if it is set up. + const lastAnswer = pageData.answers[pageData.answers.length - 1] || {}; + + // Double check that this is the OTHER_ANSWERS answer. + if (typeof lastAnswer.answer == 'string' && + lastAnswer.answer.indexOf(AddonModLessonProvider.LESSON_OTHER_ANSWERS) != -1) { + result.newpageid = lastAnswer.jumpto || 0; + result.response = lastAnswer.response || ''; + + if (lesson.custom) { + result.correctanswer = !!(lastAnswer.score && lastAnswer.score > 0); + } + result.answerid = lastAnswer.id; + } + } + } + + /** + * Create a list of pages indexed by page ID based on a list of pages. + * + * @param pageList List of pages. + * @return Pages index. + */ + protected createPagesIndex(pageList: AddonModLessonGetPagesPageWSData[]): Record { + // Index the pages by page ID. + const pages: Record = {}; + + pageList.forEach((pageData) => { + pages[pageData.page.id] = pageData.page; + }); + + return pages; + } + + /** + * Finishes a retake. + * + * @param lesson Lesson. + * @param courseId Course ID the lesson belongs to. + * @param options Other options. + * @return Promise resolved with the result. + */ + async finishRetake( + lesson: AddonModLessonLessonWSData, + courseId: number, + options: AddonModLessonFinishRetakeOptions = {}, + ): Promise { + + if (options.offline) { + return this.finishRetakeOffline(lesson, courseId, options); + } + + const response = await this.finishRetakeOnline(lesson.id, options); + + CoreEvents.trigger(AddonModLessonProvider.DATA_SENT_EVENT, { + lessonId: lesson.id, + type: 'finish', + courseId: courseId, + outOfTime: options.outOfTime, + review: options.review, + }, CoreSites.instance.getCurrentSiteId()); + + return response; + } + + /** + * Finishes a retake. It will fail if offline or cannot connect. + * + * @param lessonId Lesson ID. + * @param options Other options. + * @return Promise resolved with the result. + */ + async finishRetakeOnline( + lessonId: number, + options: AddonModLessonFinishRetakeOnlineOptions = {}, + ): Promise { + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModLessonFinishAttemptWSParams = { + lessonid: lessonId, + outoftime: !!options.outOfTime, + review: !!options.review, + }; + if (typeof options.password == 'string') { + params.password = options.password; + } + + const response = await site.write('mod_lesson_finish_attempt', params); + + // Convert the data array into an object and decode the values. + const map: Record = {}; + + response.data.forEach((entry) => { + if (entry.value && typeof entry.value == 'string' && entry.value !== '1') { + // It's a JSON encoded object. Try to decode it. + entry.value = CoreTextUtils.instance.parseJSON(entry.value); + } + + map[entry.name] = entry; + }); + + return Object.assign(response, { data: map }); + } + + /** + * Finishes a retake in offline. + * + * @param lesson Lesson. + * @param courseId Course ID the lesson belongs to. + * @param options Other options. + * @return Promise resolved with the result. + */ + protected async finishRetakeOffline( + lesson: AddonModLessonLessonWSData, + courseId: number, + options: AddonModLessonFinishRetakeOptions = {}, + ): Promise { + // First finish the retake offline. + const retake = options.accessInfo!.attemptscount; + + await AddonModLessonOffline.instance.finishRetake(lesson.id, courseId, retake, true, options.outOfTime, options.siteId); + + // Get the lesson grade. + const newOptions = { + cmId: lesson.coursemodule, + password: options.password, + review: options.review, + siteId: options.siteId, + }; + + const gradeInfo = await CoreUtils.instance.ignoreErrors(this.lessonGrade(lesson, retake, newOptions)); + + // Retake marked, now return the response. + return this.processEolPage(lesson, courseId, options, gradeInfo); + } + + /** + * Create the data returned by finishRetakeOffline, to display the EOL page. It won't return all the possible data. + * This code is based in Moodle's process_eol_page. + * + * @param lesson Lesson. + * @param courseId Course ID the lesson belongs to. + * @param options Other options. + * @param gradeInfo Lesson grade info. + * @return Promise resolved with the data. + */ + protected async processEolPage( + lesson: AddonModLessonLessonWSData, + courseId: number, + options: AddonModLessonFinishRetakeOptions = {}, + gradeInfo: AddonModLessonGrade | undefined, + ): Promise { + if (!options.accessInfo) { + throw new CoreError('Access info not supplied to finishRetake.'); + } + + // This code is based in Moodle's process_eol_page. + const result: AddonModLessonFinishRetakeResponse = { + data: {}, + messages: [], + warnings: [], + }; + let gradeLesson = true; + + this.addResultValueEolPage(result, 'offline', true); // Mark the result as offline. + this.addResultValueEolPage(result, 'gradeinfo', gradeInfo); + + if (lesson.custom && !options.accessInfo.canmanage) { + /* Before we calculate the custom score make sure they answered the minimum number of questions. + We only need to do this for custom scoring as we can not get the miniumum score the user should achieve. + If we are not using custom scoring (so all questions are valued as 1) then we simply check if they + answered more than the minimum questions, if not, we mark it out of the number specified in the minimum + questions setting - which is done in lesson_grade(). */ + + // Get the number of answers given. + if (gradeInfo && lesson.minquestions && gradeInfo.nquestions < lesson.minquestions) { + gradeLesson = false; + this.addMessage(result.messages, 'addon.mod_lesson.numberofpagesviewednotice', { + $a: { + nquestions: gradeInfo.nquestions, + minquestions: lesson.minquestions, + }, + }); + } + } + + if (!options.accessInfo.canmanage) { + if (gradeLesson) { + const progress = await this.calculateProgress(lesson.id, options.accessInfo, { + cmId: lesson.coursemodule, + password: options.password, + review: options.review, + siteId: options.siteId, + }); + + this.addResultValueEolPage(result, 'progresscompleted', progress); + + if (gradeInfo?.attempts) { + // User has answered questions. + if (!lesson.custom) { + this.addResultValueEolPage(result, 'numberofpagesviewed', gradeInfo.nquestions, true); + if (lesson.minquestions) { + if (gradeInfo.nquestions < lesson.minquestions) { + this.addResultValueEolPage(result, 'youshouldview', lesson.minquestions, true); + } + } + this.addResultValueEolPage(result, 'numberofcorrectanswers', gradeInfo.earned, true); + } + + const entryData: Record = { + score: gradeInfo.earned, + grade: gradeInfo.total, + }; + if (gradeInfo.nmanual) { + entryData.tempmaxgrade = gradeInfo.total - gradeInfo.manualpoints; + entryData.essayquestions = gradeInfo.nmanual; + this.addResultValueEolPage(result, 'displayscorewithessays', entryData, true); + } else { + this.addResultValueEolPage(result, 'displayscorewithoutessays', entryData, true); + } + + if (lesson.grade !== undefined && lesson.grade != CoreGradesProvider.TYPE_NONE) { + entryData.grade = CoreTextUtils.instance.roundToDecimals(gradeInfo.grade * lesson.grade / 100, 1); + entryData.total = lesson.grade; + this.addResultValueEolPage(result, 'yourcurrentgradeisoutof', entryData, true); + } + + } else { + // User hasn't answered any question, only content pages. + if (lesson.timelimit) { + if (options.outOfTime) { + this.addResultValueEolPage(result, 'eolstudentoutoftimenoanswers', true, true); + } + } else { + this.addResultValueEolPage(result, 'welldone', true, true); + } + } + } + } else { + // Display for teacher. + if (lesson.grade != CoreGradesProvider.TYPE_NONE) { + this.addResultValueEolPage(result, 'displayofgrade', true, true); + } + } + + if (lesson.modattempts && options.accessInfo.canmanage) { + this.addResultValueEolPage(result, 'modattemptsnoteacher', true, true); + } + + if (gradeLesson) { + this.addResultValueEolPage(result, 'gradelesson', 1); + } + + return result; + } + + /** + * Get the access information of a certain lesson. + * + * @param lessonId Lesson ID. + * @param options Other options. + * @return Promise resolved with the access information. + */ + async getAccessInformation( + lessonId: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModLessonGetAccessInformationWSParams = { + lessonid: lessonId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getAccessInformationCacheKey(lessonId), + updateFrequency: CoreSite.FREQUENCY_OFTEN, + component: AddonModLessonProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + return site.read('mod_lesson_get_lesson_access_information', params, preSets); + } + + /** + * Get cache key for access information WS calls. + * + * @param lessonId Lesson ID. + * @return Cache key. + */ + protected getAccessInformationCacheKey(lessonId: number): string { + return ROOT_CACHE_KEY + 'accessInfo:' + lessonId; + } + + /** + * Get content pages viewed in online and offline. + * + * @param lessonId Lesson ID. + * @param retake Retake number. + * @param options Other options. + * @return Promise resolved with an object with the online and offline viewed pages. + */ + async getContentPagesViewed( + lessonId: number, + retake: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise<{online: AddonModLessonWSContentPageViewed[]; offline: AddonModLessonPageAttemptRecord[]}> { + const type = AddonModLessonProvider.TYPE_STRUCTURE; + + const [online, offline] = await Promise.all([ + this.getContentPagesViewedOnline(lessonId, retake, options), + CoreUtils.instance.ignoreErrors( + AddonModLessonOffline.instance.getRetakeAttemptsForType(lessonId, retake, type, options.siteId), + ), + ]); + + return { + online, + offline: offline || [], + }; + } + + /** + * Get cache key for get content pages viewed WS calls. + * + * @param lessonId Lesson ID. + * @param retake Retake number. + * @return Cache key. + */ + protected getContentPagesViewedCacheKey(lessonId: number, retake: number): string { + return this.getContentPagesViewedCommonCacheKey(lessonId) + ':' + retake; + } + + /** + * Get common cache key for get content pages viewed WS calls. + * + * @param lessonId Lesson ID. + * @return Cache key. + */ + protected getContentPagesViewedCommonCacheKey(lessonId: number): string { + return ROOT_CACHE_KEY + 'contentPagesViewed:' + lessonId; + } + + /** + * Get IDS of content pages viewed in online and offline. + * + * @param lessonId Lesson ID. + * @param retake Retake number. + * @param options Other options. + * @return Promise resolved with list of IDs. + */ + async getContentPagesViewedIds( + lessonId: number, + retake: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + const result = await this.getContentPagesViewed(lessonId, retake, options); + + const ids: Record = {}; + const pages = (<(AddonModLessonContentPageOrRecord)[]> result.online).concat(result.offline); + + pages.forEach((page) => { + if (!ids[page.pageid]) { + ids[page.pageid] = true; + } + }); + + return Object.keys(ids).map((id) => Number(id)); + } + + /** + * Get the list of content pages viewed in the site for a certain retake. + * + * @param lessonId Lesson ID. + * @param retake Retake number. + * @param options Other options. + * @return Promise resolved with the viewed pages. + */ + async getContentPagesViewedOnline( + lessonId: number, + retake: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModLessonGetContentPagesViewedWSParams = { + lessonid: lessonId, + lessonattempt: retake, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getContentPagesViewedCacheKey(lessonId, retake), + component: AddonModLessonProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const result = await site.read( + 'mod_lesson_get_content_pages_viewed', + params, + preSets, + ); + + return result.pages; + } + + /** + * Get the last content page viewed. + * + * @param lessonId Lesson ID. + * @param retake Retake number. + * @param options Other options. + * @return Promise resolved with the last content page viewed. + */ + async getLastContentPageViewed( + lessonId: number, + retake: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + try { + const data = await this.getContentPagesViewed(lessonId, retake, options); + + let lastPage: AddonModLessonContentPageOrRecord | undefined; + let maxTime = 0; + + data.online.forEach((page) => { + if (page.timeseen > maxTime) { + lastPage = page; + maxTime = page.timeseen; + } + }); + + data.offline.forEach((page) => { + if (page.timemodified > maxTime) { + lastPage = page; + maxTime = page.timemodified; + } + }); + + return lastPage; + } catch { + // Error getting last page, don't return anything. + } + } + + /** + * Get the last page seen. + * Based on Moodle's get_last_page_seen. + * + * @param lessonId Lesson ID. + * @param retake Retake number. + * @param options Other options. + * @return Promise resolved with the last page seen. + */ + async getLastPageSeen( + lessonId: number, + retake: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId(); + + let lastPageSeen: number | undefined; + + // Get the last question answered. + const answer = await AddonModLessonOffline.instance.getLastQuestionPageAttempt(lessonId, retake, options.siteId); + + if (answer) { + lastPageSeen = answer.newpageid; + } + + // Now get the last content page viewed. + const page = await this.getLastContentPageViewed(lessonId, retake, options); + + if (page) { + if (answer) { + const pageTime = 'timeseen' in page ? page.timeseen : page.timemodified; + if (pageTime > answer.timemodified) { + // This content page was viewed more recently than the question page. + lastPageSeen = ( page).newpageid || page.pageid; + } + } else { + // Has not answered any questions but has viewed a content page. + lastPageSeen = ( page).newpageid || page.pageid; + } + } + + return lastPageSeen; + } + + /** + * Get a Lesson by module ID. + * + * @param courseId Course ID. + * @param cmid Course module ID. + * @param options Other options. + * @return Promise resolved when the lesson is retrieved. + */ + getLesson(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getLessonByField(courseId, 'coursemodule', cmId, options); + } + + /** + * Get a Lesson with key=value. If more than one is found, only the first will be returned. + * + * @param courseId Course ID. + * @param key Name of the property to check. + * @param value Value to search. + * @param options Other options. + * @return Promise resolved when the lesson is retrieved. + */ + protected async getLessonByField( + courseId: number, + key: string, + value: number, + options: CoreSitesCommonWSOptions = {}, + ): Promise { + + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModLessonGetLessonsByCoursesWSParams = { + courseids: [courseId], + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getLessonDataCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + component: AddonModLessonProvider.COMPONENT, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read( + 'mod_lesson_get_lessons_by_courses', + params, + preSets, + ); + + const currentLesson = response.lessons.find((lesson) => lesson[key] == value); + + if (currentLesson) { + return currentLesson; + } + + throw new CoreError('Lesson not found.'); + } + + /** + * Get a Lesson by lesson ID. + * + * @param courseId Course ID. + * @param id Lesson ID. + * @param options Other options. + * @return Promise resolved when the lesson is retrieved. + */ + getLessonById(courseId: number, id: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getLessonByField(courseId, 'id', id, options); + } + + /** + * Get cache key for Lesson data WS calls. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getLessonDataCacheKey(courseId: number): string { + return ROOT_CACHE_KEY + 'lesson:' + courseId; + } + + /** + * Get a lesson protected with password. + * + * @param lessonId Lesson ID. + * @param options Other options. + * @return Promise resolved with the lesson. + */ + async getLessonWithPassword( + lessonId: number, + options: AddonModLessonGetWithPasswordOptions = {}, + ): Promise { + const validatePassword = options.validatePassword ?? true; + + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModLessonGetLessonWSParams = { + lessonid: lessonId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getLessonWithPasswordCacheKey(lessonId), + component: AddonModLessonProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + if (typeof options.password == 'string') { + params.password = options.password; + } + + const response = await site.read('mod_lesson_get_lesson', params, preSets); + + if (typeof response.lesson.ongoing != 'undefined') { + // Basic data not received, password is wrong. Remove stored password. + this.removeStoredPassword(lessonId, site.id); + + if (validatePassword) { + // Invalidate the data and reject. + await CoreUtils.instance.ignoreErrors(this.invalidateLessonWithPassword(lessonId, site.id)); + + throw new CoreError(Translate.instance.instant('addon.mod_lesson.loginfail')); + } + } + + return response.lesson; + } + + /** + * Get cache key for get lesson with password WS calls. + * + * @param lessonId Lesson ID. + * @return Cache key. + */ + protected getLessonWithPasswordCacheKey(lessonId: number): string { + return ROOT_CACHE_KEY + 'lessonWithPswrd:' + lessonId; + } + + /** + * Given a page ID, a jumpto and all the possible jumps, calcualate the new page ID. + * + * @param pageId Current page ID. + * @param jumpTo The jumpto. + * @param jumps Possible jumps. + * @return New page ID. + */ + protected getNewPageId(pageId: number, jumpTo: number, jumps: AddonModLessonPossibleJumps): number { + // If jump not found, return current jumpTo. + if (jumps && jumps[pageId] && jumps[pageId][jumpTo]) { + return jumps[pageId][jumpTo].calculatedjump; + } else if (!jumpTo) { + // Return current page. + return pageId; + } + + return jumpTo; + } + + /** + * Get the ongoing score message for the user (depending on the user permission and lesson settings). + * + * @param lesson Lesson. + * @param accessInfo Access info. + * @param options Other options. + * @return Promise resolved with the ongoing score message. + */ + getOngoingScoreMessage( + lesson: AddonModLessonLessonWSData, + accessInfo: AddonModLessonGetAccessInformationWSResponse, + options: AddonModLessonGradeOptions = {}, + ): Promise { + + if (accessInfo.canmanage) { + return Promise.resolve(Translate.instance.instant('addon.mod_lesson.teacherongoingwarning')); + } else { + let retake = accessInfo.attemptscount; + if (options.review) { + retake--; + } + + return this.lessonGrade(lesson, retake, options).then((gradeInfo) => { + if (lesson.custom) { + return Translate.instance.instant( + 'addon.mod_lesson.ongoingcustom', + { $a: { score: gradeInfo.earned, currenthigh: gradeInfo.total } }, + ); + } else { + return Translate.instance.instant( + 'addon.mod_lesson.ongoingnormal', + { $a: { correct: gradeInfo.earned, viewed: gradeInfo.attempts } }, + ); + } + }); + } + } + + /** + * Get the possible answers from a page. + * + * @param lesson Lesson. + * @param pageId Page ID. + * @param options Other options. + * @return Promise resolved with the list of possible answers. + */ + protected async getPageAnswers( + lesson: AddonModLessonLessonWSData, + pageId: number, + options: AddonModLessonPwdReviewOptions = {}, + ): Promise { + const data = await this.getPageData(lesson, pageId, { + includeContents: true, + ...options, // Include all options. + readingStrategy: options.readingStrategy || CoreSitesReadingStrategy.PreferCache, + includeOfflineData: false, + }); + + return data.answers; + } + + /** + * Get all the possible answers from a list of pages, indexed by answerId. + * + * @param lesson Lesson. + * @param pageIds List of page IDs. + * @param options Other options. + * @return Promise resolved with an object containing the answers. + */ + protected async getPagesAnswers( + lesson: AddonModLessonLessonWSData, + pageIds: number[], + options: AddonModLessonPwdReviewOptions = {}, + ): Promise> { + + const answers: Record = {}; + + await Promise.all(pageIds.map(async (pageId) => { + const pageAnswers = await this.getPageAnswers(lesson, pageId, options); + + pageAnswers.forEach((answer) => { + // Include the pageid in each answer and add them to the final list. + answers[answer.id] = Object.assign(answer, { pageid: pageId }); + }); + })); + + return answers; + } + + /** + * Get page data. + * + * @param lesson Lesson. + * @param pageId Page ID. + * @param options Other options. + * @return Promise resolved with the page data. + */ + async getPageData( + lesson: AddonModLessonLessonWSData, + pageId: number, + options: AddonModLessonGetPageDataOptions = {}, + ): Promise { + + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModLessonGetPageDataWSParams = { + lessonid: lesson.id, + pageid: Number(pageId), + review: !!options.review, + returncontents: !!options.includeContents, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getPageDataCacheKey(lesson.id, pageId), + component: AddonModLessonProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + if (typeof options.password == 'string') { + params.password = options.password; + } + + if (options.review) { + // Force online mode in review. + preSets.getFromCache = false; + preSets.saveToCache = false; + preSets.emergencyCache = false; + } + + const response = await site.read('mod_lesson_get_page_data', params, preSets); + + if (preSets.omitExpires && options.includeOfflineData && response.page && options.accessInfo && options.jumps) { + // Offline mode and valid page. Calculate the data that might be affected. + const calcData = await this.calculateOfflineData(lesson, options); + + Object.assign(response, calcData); + + response.messages = await this.getPageViewMessages(lesson, options.accessInfo, response.page, options.jumps, { + password: options.password, + siteId: options.siteId, + }); + } + + return response; + } + + /** + * Get cache key for get page data WS calls. + * + * @param lessonId Lesson ID. + * @param pageId Page ID. + * @return Cache key. + */ + protected getPageDataCacheKey(lessonId: number, pageId: number): string { + return this.getPageDataCommonCacheKey(lessonId) + ':' + pageId; + } + + /** + * Get common cache key for get page data WS calls. + * + * @param lessonId Lesson ID. + * @return Cache key. + */ + protected getPageDataCommonCacheKey(lessonId: number): string { + return ROOT_CACHE_KEY + 'pageData:' + lessonId; + } + + /** + * Get lesson pages. + * + * @param lessonId Lesson ID. + * @param options Other options. + * @return Promise resolved with the pages. + */ + async getPages(lessonId: number, options: AddonModLessonPwdReviewOptions = {}): Promise { + + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModLessonGetPagesWSParams = { + lessonid: lessonId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getPagesCacheKey(lessonId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + component: AddonModLessonProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + if (typeof options.password == 'string') { + params.password = options.password; + } + + const response = await site.read('mod_lesson_get_pages', params, preSets); + + return response.pages; + } + + /** + * Get cache key for get pages WS calls. + * + * @param lessonId Lesson ID. + * @return Cache key. + */ + protected getPagesCacheKey(lessonId: number): string { + return ROOT_CACHE_KEY + 'pages:' + lessonId; + } + + /** + * Get possible jumps for a lesson. + * + * @param lessonId Lesson ID. + * @param options Other options. + * @return Promise resolved with the jumps. + */ + async getPagesPossibleJumps( + lessonId: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModLessonGetPagesPossibleJumpsWSParams = { + lessonid: lessonId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getPagesPossibleJumpsCacheKey(lessonId), + component: AddonModLessonProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read( + 'mod_lesson_get_pages_possible_jumps', + params, + preSets, + ); + + // Index the jumps by page and jumpto. + const jumps: AddonModLessonPossibleJumps = {}; + + response.jumps.forEach((jump) => { + if (typeof jumps[jump.pageid] == 'undefined') { + jumps[jump.pageid] = {}; + } + jumps[jump.pageid][jump.jumpto] = jump; + }); + + return jumps; + } + + /** + * Get cache key for get pages possible jumps WS calls. + * + * @param lessonId Lesson ID. + * @return Cache key. + */ + protected getPagesPossibleJumpsCacheKey(lessonId: number): string { + return ROOT_CACHE_KEY + 'pagesJumps:' + lessonId; + } + + /** + * Get different informative messages when processing a lesson page. + * Please try to use WS response messages instead of this function if possible. + * Based on Moodle's add_messages_on_page_process. + * + * @param lesson Lesson. + * @param accessInfo Access info. + * @param result Result of process page. + * @param review If the user wants to review just after finishing (1 hour margin). + * @param jumps Possible jumps. + * @return Array with the messages. + */ + getPageProcessMessages( + lesson: AddonModLessonLessonWSData, + accessInfo: AddonModLessonGetAccessInformationWSResponse, + result: AddonModLessonProcessPageResponse, + review: boolean, + jumps: AddonModLessonPossibleJumps, + ): AddonModLessonMessageWSData[] { + const messages = []; + + if (accessInfo.canmanage) { + // Warning for teachers to inform them that cluster and unseen does not work while logged in as a teacher. + if (this.lessonDisplayTeacherWarning(jumps)) { + this.addMessage(messages, 'addon.mod_lesson.teacherjumpwarning', { + $a: { + cluster: Translate.instance.instant('addon.mod_lesson.clusterjump'), + unseen: Translate.instance.instant('addon.mod_lesson.unseenpageinbranch'), + }, + }); + } + + // Inform teacher that s/he will not see the timer. + if (lesson.timelimit) { + this.addMessage(messages, 'addon.mod_lesson.teachertimerwarning'); + } + } + // Report attempts remaining. + if (result.attemptsremaining && result.attemptsremaining > 0 && lesson.review && !review) { + this.addMessage(messages, 'addon.mod_lesson.attemptsremaining', { $a: result.attemptsremaining }); + } + + return messages; + } + + /** + * Get the IDs of all the pages that have at least 1 question attempt. + * + * @param lessonId Lesson ID. + * @param retake Retake number. + * @param options Other options. + * @return Promise resolved with the IDs. + */ + async getPagesIdsWithQuestionAttempts( + lessonId: number, + retake: number, + options: AddonModLessonGetPagesIdsWithAttemptsOptions = {}, + ): Promise { + + const result = await this.getQuestionsAttempts(lessonId, retake, options); + + const ids: Record = {}; + const attempts = ( result.online).concat(result.offline); + + attempts.forEach((attempt) => { + if (!ids[attempt.pageid]) { + ids[attempt.pageid] = true; + } + }); + + return Object.keys(ids).map((id) => Number(id)); + } + + /** + * Get different informative messages when viewing a lesson page. + * Please try to use WS response messages instead of this function if possible. + * Based on Moodle's add_messages_on_page_view. + * + * @param lesson Lesson. + * @param accessInfo Access info. Required if offline is true. + * @param page Page loaded. + * @param jumps Possible jumps. + * @param options Other options. + * @return Promise resolved with the list of messages. + */ + async getPageViewMessages( + lesson: AddonModLessonLessonWSData, + accessInfo: AddonModLessonGetAccessInformationWSResponse, + page: AddonModLessonPageWSData, + jumps: AddonModLessonPossibleJumps, + options: AddonModLessonGetPageViewMessagesOptions = {}, + ): Promise { + + const messages: AddonModLessonMessageWSData[] = []; + + if (!accessInfo.canmanage) { + if (page.qtype == AddonModLessonProvider.LESSON_PAGE_BRANCHTABLE && lesson.minquestions) { + // Tell student how many questions they have seen, how many are required and their grade. + const retake = accessInfo.attemptscount; + + const gradeInfo = await CoreUtils.instance.ignoreErrors(this.lessonGrade(lesson, retake, options)); + if (gradeInfo?.attempts) { + if (gradeInfo.nquestions < lesson.minquestions) { + this.addMessage(messages, 'addon.mod_lesson.numberofpagesviewednotice', { + $a: { + nquestions: gradeInfo.nquestions, + minquestions: lesson.minquestions, + }, + }); + } + + if (!options.review && !lesson.retake) { + this.addMessage(messages, 'addon.mod_lesson.numberofcorrectanswers', { $a: gradeInfo.earned }); + + if (lesson.grade !== undefined && lesson.grade != CoreGradesProvider.TYPE_NONE) { + this.addMessage(messages, 'addon.mod_lesson.yourcurrentgradeisoutof', { $a: { + grade: CoreTextUtils.instance.roundToDecimals(gradeInfo.grade * lesson.grade / 100, 1), + total: lesson.grade, + } }); + } + } + } + } + } else { + if (lesson.timelimit) { + this.addMessage(messages, 'addon.mod_lesson.teachertimerwarning'); + } + + if (this.lessonDisplayTeacherWarning(jumps)) { + // Warning for teachers to inform them that cluster and unseen does not work while logged in as a teacher. + this.addMessage(messages, 'addon.mod_lesson.teacherjumpwarning', { + $a: { + cluster: Translate.instance.instant('addon.mod_lesson.clusterjump'), + unseen: Translate.instance.instant('addon.mod_lesson.unseenpageinbranch'), + }, + }); + } + } + + return messages; + } + + /** + * Get questions attempts, including offline attempts. + * + * @param lessonId Lesson ID. + * @param retake Retake number. + * @param options Other options. + * @return Promise resolved with the questions attempts. + */ + async getQuestionsAttempts( + lessonId: number, + retake: number, + options: AddonModLessonGetQuestionsAttemptsOptions = {}, + ): Promise<{online: AddonModLessonQuestionAttemptWSData[]; offline: AddonModLessonPageAttemptRecord[]}> { + + const [online, offline] = await Promise.all([ + this.getQuestionsAttemptsOnline(lessonId, retake, options), + CoreUtils.instance.ignoreErrors(AddonModLessonOffline.instance.getQuestionsAttempts( + lessonId, + retake, + options.correct, + options.pageId, + options.siteId, + )), + ]); + + return { + online, + offline: offline || [], + }; + } + + /** + * Get cache key for get questions attempts WS calls. + * + * @param lessonId Lesson ID. + * @param retake Retake number. + * @param userId User ID. + * @return Cache key. + */ + protected getQuestionsAttemptsCacheKey(lessonId: number, retake: number, userId: number): string { + return this.getQuestionsAttemptsCommonCacheKey(lessonId) + ':' + userId + ':' + retake; + } + + /** + * Get common cache key for get questions attempts WS calls. + * + * @param lessonId Lesson ID. + * @return Cache key. + */ + protected getQuestionsAttemptsCommonCacheKey(lessonId: number): string { + return ROOT_CACHE_KEY + 'questionsAttempts:' + lessonId; + } + + /** + * Get questions attempts from the site. + * + * @param lessonId Lesson ID. + * @param retake Retake number. + * @param options Other options. + * @return Promise resolved with the questions attempts. + */ + async getQuestionsAttemptsOnline( + lessonId: number, + retake: number, + options: AddonModLessonGetQuestionsAttemptsOptions = {}, + ): Promise { + + const site = await CoreSites.instance.getSite(options.siteId); + + const userId = options.userId || site.getUserId(); + + // Don't pass "pageId" and "correct" params, they will be filtered locally. + const params: AddonModLessonGetQuestionsAttemptsWSParams = { + lessonid: lessonId, + attempt: retake, + userid: userId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getQuestionsAttemptsCacheKey(lessonId, retake, userId), + component: AddonModLessonProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read( + 'mod_lesson_get_questions_attempts', + params, + preSets, + ); + + if (!options.pageId && !options.correct) { + return response.attempts; + } + + // Filter the attempts. + return response.attempts.filter((attempt) => { + if (options.correct && !attempt.correct) { + return false; + } + + if (options.pageId && attempt.pageid != options.pageId) { + return false; + } + + return true; + }); + } + + /** + * Get the overview of retakes in a lesson (named "attempts overview" in Moodle). + * + * @param lessonId Lesson ID. + * @param options Other options. + * @return Promise resolved with the retakes overview, undefined if no attempts. + */ + async getRetakesOverview( + lessonId: number, + options: AddonModLessonGroupOptions = {}, + ): Promise { + const groupId = options.groupId || 0; + + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModLessonGetAttemptsOverviewWSParams = { + lessonid: lessonId, + groupid: groupId, + }; + const preSets = { + cacheKey: this.getRetakesOverviewCacheKey(lessonId, groupId), + updateFrequency: CoreSite.FREQUENCY_OFTEN, + component: AddonModLessonProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read( + 'mod_lesson_get_attempts_overview', + params, + preSets, + ); + + return response.data; + } + + /** + * Get cache key for get retakes overview WS calls. + * + * @param lessonId Lesson ID. + * @param groupId Group ID. + * @return Cache key. + */ + protected getRetakesOverviewCacheKey(lessonId: number, groupId: number): string { + return this.getRetakesOverviewCommonCacheKey(lessonId) + ':' + groupId; + } + + /** + * Get common cache key for get retakes overview WS calls. + * + * @param lessonId Lesson ID. + * @return Cache key. + */ + protected getRetakesOverviewCommonCacheKey(lessonId: number): string { + return ROOT_CACHE_KEY + 'retakesOverview:' + lessonId; + } + + /** + * Get a password stored in DB. + * + * @param lessonId Lesson ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with password on success, rejected otherwise. + */ + async getStoredPassword(lessonId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const entry = await site.getDb().getRecord(PASSWORD_TABLE_NAME, { lessonid: lessonId }); + + return entry.password; + } + + /** + * Finds all pages that appear to be a subtype of the provided pageId until an end point specified within "ends" is + * encountered or no more pages exist. + * Based on Moodle's get_sub_pages_of. + * + * @param pages Index of lesson pages, indexed by page ID. See createPagesIndex. + * @param pageId Page ID to get subpages of. + * @param end An array of LESSON_PAGE_* types that signify an end of the subtype. + * @return List of subpages. + */ + getSubpagesOf(pages: Record, pageId: number, ends: number[]): AddonModLessonPageWSData[] { + const subPages: AddonModLessonPageWSData[] = []; + + pageId = pages[pageId].nextpageid; // Move to the first page after the given page. + ends = ends || []; + + // Search until there are no more pages or it reaches a page of the searched types. + while (pageId && ends.indexOf(pages[pageId].qtype) == -1) { + subPages.push(pages[pageId]); + pageId = pages[pageId].nextpageid; + } + + return subPages; + } + + /** + * Get lesson timers. + * + * @param lessonId Lesson ID. + * @param options Other options. + * @return Promise resolved with the pages. + */ + async getTimers(lessonId: number, options: AddonModLessonUserOptions = {}): Promise { + const site = await CoreSites.instance.getSite(options.siteId); + + const userId = options.userId || site.getUserId(); + const params: ModLessonGetUserTimersWSParams = { + lessonid: lessonId, + userid: userId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getTimersCacheKey(lessonId, userId), + component: AddonModLessonProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_lesson_get_user_timers', params, preSets); + + return response.timers; + } + + /** + * Get cache key for get timers WS calls. + * + * @param lessonId Lesson ID. + * @param userId User ID. + * @return Cache key. + */ + protected getTimersCacheKey(lessonId: number, userId: number): string { + return this.getTimersCommonCacheKey(lessonId) + ':' + userId; + } + + /** + * Get common cache key for get timers WS calls. + * + * @param lessonId Lesson ID. + * @return Cache key. + */ + protected getTimersCommonCacheKey(lessonId: number): string { + return ROOT_CACHE_KEY + 'timers:' + lessonId; + } + + /** + * Get the list of used answers (with valid answer) in a multichoice question page. + * + * @param pageData Page data for the page to process. + * @return List of used answers. + */ + protected getUsedAnswersMultichoice(pageData: AddonModLessonGetPageDataWSResponse): AddonModLessonPageAnswerWSData[] { + const answers = CoreUtils.instance.clone(pageData.answers); + + return answers.filter((entry) => entry.answer !== undefined && entry.answer !== ''); + } + + /** + * Get the user's response in a matching question page. + * + * @param data Data containing the user answer. + * @return User response. + */ + protected getUserResponseMatching(data: Record): Record { + if (data.response) { + // The data is already stored as expected. Return it. + return > data.response; + } + + // Data is stored in properties like 'response[379]'. Recreate the response object. + const response: Record = {}; + + for (const key in data) { + const match = key.match(/^response\[(\d+)\]/); + + if (match && match.length > 1) { + response[match[1]] = data[key]; + } + } + + return response; + } + + /** + * Get the user's response in a multichoice page if multiple answers are allowed. + * + * @param data Data containing the user answer. + * @return User response. + */ + protected getUserResponseMultichoice(data: Record): number[] | null { + if (data.answer) { + // The data is already stored as expected. If it's valid, parse the values to int. + if (Array.isArray(data.answer)) { + return data.answer.map((value) => parseInt(value, 10)); + } + + return null; + } + + // Data is stored in properties like 'answer[379]'. Recreate the answer array. + const answer: number[] = []; + for (const key in data) { + const match = key.match(/^answer\[(\d+)\]/); + if (match && match.length > 1) { + answer.push(parseInt(match[1], 10)); + } + } + + return answer; + } + + /** + * Get a user's retake. + * + * @param lessonId Lesson ID. + * @param retake Retake number + * @param options Other options. + * @return Promise resolved with the retake data. + */ + async getUserRetake( + lessonId: number, + retake: number, + options: AddonModLessonUserOptions = {}, + ): Promise { + + const site = await CoreSites.instance.getSite(options.siteId); + + const userId = options.userId || site.getUserId(); + const params: AddonModLessonGetUserAttemptWSParams = { + lessonid: lessonId, + userid: userId, + lessonattempt: retake, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getUserRetakeCacheKey(lessonId, userId, retake), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + component: AddonModLessonProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + return site.read('mod_lesson_get_user_attempt', params, preSets); + } + + /** + * Get cache key for get user retake WS calls. + * + * @param lessonId Lesson ID. + * @param userId User ID. + * @param retake Retake number + * @return Cache key. + */ + protected getUserRetakeCacheKey(lessonId: number, userId: number, retake: number): string { + return this.getUserRetakeUserCacheKey(lessonId, userId) + ':' + retake; + } + + /** + * Get user cache key for get user retake WS calls. + * + * @param lessonId Lesson ID. + * @param userId User ID. + * @return Cache key. + */ + protected getUserRetakeUserCacheKey(lessonId: number, userId: number): string { + return this.getUserRetakeLessonCacheKey(lessonId) + ':' + userId; + } + + /** + * Get lesson cache key for get user retake WS calls. + * + * @param lessonId Lesson ID. + * @return Cache key. + */ + protected getUserRetakeLessonCacheKey(lessonId: number): string { + return ROOT_CACHE_KEY + 'userRetake:' + lessonId; + } + + /** + * Get the prevent access reason to display for a certain lesson. + * + * @param info Lesson access info. + * @param ignorePassword Whether password protected reason should be ignored (user already entered the password). + * @param isReview Whether user is reviewing a retake. + * @return Prevent access reason. + */ + getPreventAccessReason( + info: AddonModLessonGetAccessInformationWSResponse, + ignorePassword?: boolean, + isReview?: boolean, + ): AddonModLessonPreventAccessReason | undefined { + if (!info?.preventaccessreasons) { + return; + } + + let reason: AddonModLessonPreventAccessReason | undefined; + for (let i = 0; i < info.preventaccessreasons.length; i++) { + const entry = info.preventaccessreasons[i]; + + if (entry.reason == 'lessonopen' || entry.reason == 'lessonclosed') { + // Time restrictions are the most prioritary, return it. + return reason; + } else if (entry.reason == 'passwordprotectedlesson') { + if (!ignorePassword) { + // Treat password before all other reasons. + reason = entry; + } + } else if (entry.reason == 'noretake' && isReview) { + // Ignore noretake error when reviewing. + } else if (!reason) { + // Rest of cases, just return any of them. + reason = entry; + } + } + + return reason; + } + + /** + * Check if a jump is correct. + * Based in Moodle's jumpto_is_correct. + * + * @param pageId ID of the page from which you are jumping from. + * @param jumpTo The jumpto number. + * @param pageIndex Object containing all the pages indexed by ID. See createPagesIndex. + * @return Whether jump is correct. + */ + jumptoIsCorrect(pageId: number, jumpTo: number, pageIndex: Record): boolean { + // First test the special values. + if (!jumpTo) { + // Same page + return false; + } else if (jumpTo == AddonModLessonProvider.LESSON_NEXTPAGE) { + return true; + } else if (jumpTo == AddonModLessonProvider.LESSON_UNSEENBRANCHPAGE) { + return true; + } else if (jumpTo == AddonModLessonProvider.LESSON_RANDOMPAGE) { + return true; + } else if (jumpTo == AddonModLessonProvider.LESSON_CLUSTERJUMP) { + return true; + } else if (jumpTo == AddonModLessonProvider.LESSON_EOL) { + return true; + } + + let aPageId = pageIndex[pageId].nextpageid; + while (aPageId) { + if (jumpTo == aPageId) { + return true; + } + + aPageId = pageIndex[aPageId].nextpageid; + } + + return false; + } + + /** + * Invalidates Lesson data. + * + * @param lessonId Lesson ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAccessInformation(lessonId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getAccessInformationCacheKey(lessonId)); + } + + /** + * Invalidates content pages viewed for all retakes. + * + * @param lessonId Lesson ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateContentPagesViewed(lessonId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getContentPagesViewedCommonCacheKey(lessonId)); + } + + /** + * Invalidates content pages viewed for a certain retake. + * + * @param lessonId Lesson ID. + * @param retake Retake number. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateContentPagesViewedForRetake(lessonId: number, retake: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getContentPagesViewedCacheKey(lessonId, retake)); + } + + /** + * Invalidates Lesson data. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateLessonData(courseId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getLessonDataCacheKey(courseId)); + } + + /** + * Invalidates lesson with password. + * + * @param lessonId Lesson ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateLessonWithPassword(lessonId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getLessonWithPasswordCacheKey(lessonId)); + } + + /** + * Invalidates page data for all pages. + * + * @param lessonId Lesson ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidatePageData(lessonId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getPageDataCommonCacheKey(lessonId)); + } + + /** + * Invalidates page data for a certain page. + * + * @param lessonId Lesson ID. + * @param pageId Page ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidatePageDataForPage(lessonId: number, pageId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getPageDataCacheKey(lessonId, pageId)); + } + + /** + * Invalidates pages. + * + * @param lessonId Lesson ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidatePages(lessonId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getPagesCacheKey(lessonId)); + } + + /** + * Invalidates pages possible jumps. + * + * @param lessonId Lesson ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidatePagesPossibleJumps(lessonId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getPagesPossibleJumpsCacheKey(lessonId)); + } + + /** + * Invalidates questions attempts for all retakes. + * + * @param lessonId Lesson ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateQuestionsAttempts(lessonId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getQuestionsAttemptsCommonCacheKey(lessonId)); + } + + /** + * Invalidates question attempts for a certain retake and user. + * + * @param lessonId Lesson ID. + * @param retake Retake number. + * @param siteId Site ID. If not defined, current site.. + * @param userId User ID. If not defined, site's user. + * @return Promise resolved when the data is invalidated. + */ + async invalidateQuestionsAttemptsForRetake(lessonId: number, retake: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getQuestionsAttemptsCacheKey(lessonId, retake, userId || site.getUserId())); + } + + /** + * Invalidates retakes overview for all groups in a lesson. + * + * @param lessonId Lesson ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateRetakesOverview(lessonId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getRetakesOverviewCommonCacheKey(lessonId)); + } + + /** + * Invalidates retakes overview for a certain group in a lesson. + * + * @param lessonId Lesson ID. + * @param groupId Group ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateRetakesOverviewForGroup(lessonId: number, groupId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getRetakesOverviewCacheKey(lessonId, groupId)); + } + + /** + * Invalidates timers for all users in a lesson. + * + * @param lessonId Lesson ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateTimers(lessonId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getTimersCommonCacheKey(lessonId)); + } + + /** + * Invalidates timers for a certain user. + * + * @param lessonId Lesson ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined, site's current user. + * @return Promise resolved when the data is invalidated. + */ + async invalidateTimersForUser(lessonId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getTimersCacheKey(lessonId, userId || site.getUserId())); + } + + /** + * Invalidates a certain retake for a certain user. + * + * @param lessonId Lesson ID. + * @param retake Retake number. + * @param userId User ID. Undefined for current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserRetake(lessonId: number, retake: number, userId?: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getUserRetakeCacheKey(lessonId, userId || site.getUserId(), retake)); + } + + /** + * Invalidates all retakes for all users in a lesson. + * + * @param lessonId Lesson ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserRetakesForLesson(lessonId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getUserRetakeLessonCacheKey(lessonId)); + } + + /** + * Invalidates all retakes for a certain user in a lesson. + * + * @param lessonId Lesson ID. + * @param userId User ID. Undefined for current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserRetakesForUser(lessonId: number, userId?: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getUserRetakeUserCacheKey(lessonId, userId || site.getUserId())); + } + + /** + * Check if a page answer is correct. + * + * @param lesson Lesson. + * @param pageId The page ID. + * @param answer The answer to check. + * @param pageIndex Object containing all the pages indexed by ID. + * @return Whether the answer is correct. + */ + protected isAnswerCorrect( + lesson: AddonModLessonLessonWSData, + pageId: number, + answer: AddonModLessonPageAnswerWSData, + pageIndex: Record, + ): boolean { + if (lesson.custom) { + // Custom scores. If score on answer is positive, it is correct. + return !!(answer.score && answer.score > 0); + } else { + return this.jumptoIsCorrect(pageId, answer.jumpto || 0, pageIndex); + } + } + + /** + * Check if a lesson is enabled to be used in offline. + * + * @param lesson Lesson. + * @return Whether offline is enabled. + */ + isLessonOffline(lesson: AddonModLessonLessonWSData): boolean { + return !!lesson.allowofflineattempts; + } + + /** + * Check if a lesson is password protected based in the access info. + * + * @param info Lesson access info. + * @return Whether the lesson is password protected. + */ + isPasswordProtected(info: AddonModLessonGetAccessInformationWSResponse): boolean { + if (!info || !info.preventaccessreasons) { + return false; + } + + for (let i = 0; i < info.preventaccessreasons.length; i++) { + const entry = info.preventaccessreasons[i]; + + if (entry.reason == 'passwordprotectedlesson') { + return true; + } + } + + return false; + } + + /** + * Return whether or not the plugin is enabled in a certain site. Plugin is enabled if the lesson WS are available. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if plugin is enabled, rejected or resolved with false otherwise. + */ + async isPluginEnabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + // All WS were introduced at the same time so checking one is enough. + return site.wsAvailable('mod_lesson_get_lesson_access_information'); + } + + /** + * Check if a page is a question page or a content page. + * + * @param type Type of the page. + * @return True if question page, false if content page. + */ + isQuestionPage(type: number): boolean { + return type == AddonModLessonProvider.TYPE_QUESTION; + } + + /** + * Start or continue a retake. + * + * @param id Lesson ID. + * @param password Lesson password (if any). + * @param pageId Page id to continue from (only when continuing a retake). + * @param review If the user wants to review just after finishing (1 hour margin). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + async launchRetake( + id: number, + password?: string, + pageId?: number, + review?: boolean, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: AddonModLessonLaunchAttemptWSParams = { + lessonid: id, + review: !!review, + }; + if (typeof password == 'string') { + params.password = password; + } + if (typeof pageId == 'number') { + params.pageid = pageId; + } + + const response = await site.write('mod_lesson_launch_attempt', params); + + CoreEvents.trigger(AddonModLessonProvider.DATA_SENT_EVENT, { + lessonId: id, + type: 'launch', + }, CoreSites.instance.getCurrentSiteId()); + + return response; + } + + /** + * Check if the user left during a timed session. + * + * @param info Lesson access info. + * @return True if left during timed, false otherwise. + */ + leftDuringTimed(info?: AddonModLessonGetAccessInformationWSResponse): boolean { + return !!(info?.lastpageseen && info.lastpageseen != AddonModLessonProvider.LESSON_EOL && info.leftduringtimedsession); + } + + /** + * Checks to see if a LESSON_CLUSTERJUMP or a LESSON_UNSEENBRANCHPAGE is used in a lesson. + * Based on Moodle's lesson_display_teacher_warning. + * + * @param jumps Possible jumps. + * @return Whether the lesson uses one of those jumps. + */ + lessonDisplayTeacherWarning(jumps: AddonModLessonPossibleJumps): boolean { + if (!jumps) { + return false; + } + + // Check if any jump is to cluster or unseen content page. + for (const pageId in jumps) { + for (const jumpto in jumps[pageId]) { + const jumptoNum = Number(jumpto); + + if (jumptoNum == AddonModLessonProvider.LESSON_CLUSTERJUMP || + jumptoNum == AddonModLessonProvider.LESSON_UNSEENBRANCHPAGE) { + return true; + } + } + } + + return false; + } + + /** + * Calculates a user's grade for a lesson. + * Based on Moodle's lesson_grade. + * + * @param lesson Lesson. + * @param retake Retake number. + * @param options Other options. + * @return Promise resolved with the grade data. + */ + async lessonGrade( + lesson: AddonModLessonLessonWSData, + retake: number, + options: AddonModLessonGradeOptions = {}, + ): Promise { + + const result: AddonModLessonGrade = { + nquestions: 0, + attempts: 0, + total: 0, + earned: 0, + grade: 0, + nmanual: 0, + manualpoints: 0, + }; + + // Get the questions attempts for the user. + const attemptsData = await this.getQuestionsAttempts(lesson.id, retake, { + cmId: lesson.coursemodule, + siteId: options.siteId, + userId: options.userId, + }); + + const attempts = ( attemptsData.online).concat(attemptsData.offline); + + if (!attempts.length) { + // No attempts. + return result; + } + + // Create the pageIndex if it isn't provided. + if (!options.pageIndex) { + const pages = await this.getPages(lesson.id, { + password: options.password, + cmId: lesson.coursemodule, + readingStrategy: CoreSitesReadingStrategy.PreferCache, + siteId: options.siteId, + }); + + options.pageIndex = this.createPagesIndex(pages); + } + + const attemptSet: Record = {}; + const pageIds: number[] = []; + + // Group each try with its page. + attempts.forEach((attempt) => { + if (!attemptSet[attempt.pageid]) { + attemptSet[attempt.pageid] = []; + pageIds.push(attempt.pageid); + } + attemptSet[attempt.pageid].push(attempt); + }); + + if (lesson.maxattempts && lesson.maxattempts > 0) { + // Drop all attempts that go beyond max attempts for the lesson. + for (const pageId in attemptSet) { + // Sort the list by time in ascending order. + const attempts = attemptSet[pageId].sort((a, b) => + ('timeseen' in a ? a.timeseen : a.timemodified) - ('timeseen' in b ? b.timeseen : b.timemodified)); + + attemptSet[pageId] = attempts.slice(0, lesson.maxattempts); + } + } + + // Get all the answers from the pages the user answered. + const answers = await this.getPagesAnswers(lesson, pageIds, options); + + // Number of pages answered. + result.nquestions = Object.keys(attemptSet).length; + + for (const pageId in attemptSet) { + const attempts = attemptSet[pageId]; + const lastAttempt = attempts[attempts.length - 1]; + + if (lesson.custom) { + // If essay question, handle it, otherwise add to score. + if (options.pageIndex[lastAttempt.pageid].qtype == AddonModLessonProvider.LESSON_PAGE_ESSAY) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const score: number | undefined = ( lastAttempt.useranswer)?.score; + if (typeof score != 'undefined') { + result.earned += score; + } + result.nmanual++; + result.manualpoints += answers[lastAttempt.answerid!].score || 0; + } else if (lastAttempt.answerid) { + result.earned += answers[lastAttempt.answerid!].score || 0; + } + } else { + attempts.forEach((attempt) => { + result.earned += attempt.correct ? 1 : 0; + }); + + // If essay question, increase numbers. + if (options.pageIndex[lastAttempt.pageid].qtype == AddonModLessonProvider.LESSON_PAGE_ESSAY) { + result.nmanual++; + result.manualpoints++; + } + } + + // Number of times answered. + result.attempts += attempts.length; + } + + if (lesson.custom) { + const bestScores: Record = {}; + + // Find the highest possible score per page to get our total. + for (const answerId in answers) { + const answer = answers[answerId]; + + if (typeof bestScores[answer.pageid] == 'undefined') { + bestScores[answer.pageid] = answer.score || 0; + } else if (bestScores[answer.pageid] < (answer.score || 0)) { + bestScores[answer.pageid] = answer.score || 0; + } + } + + // Sum all the scores. + for (const pageId in bestScores) { + result.total += bestScores[pageId]; + } + } else { + // Check to make sure the student has answered the minimum questions. + if (lesson.minquestions && result.nquestions < lesson.minquestions) { + // Nope, increase number viewed by the amount of unanswered questions. + result.total = result.attempts + (lesson.minquestions - result.nquestions); + } else { + result.total = result.attempts; + } + } + + if (result.total) { // Not zero. + result.grade = CoreTextUtils.instance.roundToDecimals(result.earned * 100 / result.total, 5); + } + + return result; + } + + /** + * Report a lesson as being viewed. + * + * @param id Module ID. + * @param password Lesson password (if any). + * @param name Name of the assign. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + async logViewLesson(id: number, password?: string, name?: string, siteId?: string): Promise { + const params: AddonModLessonViewLessonWSParams = { + lessonid: id, + }; + + if (typeof password == 'string') { + params.password = password; + } + + await CoreCourseLogHelper.instance.logSingle( + 'mod_lesson_view_lesson', + params, + AddonModLessonProvider.COMPONENT, + id, + name, + 'lesson', + {}, + siteId, + ); + } + + /** + * Process a lesson page, saving its data. + * + * @param lesson Lesson. + * @param courseId Course ID the lesson belongs to. + * @param pageData Page data for the page to process. + * @param data Data to save. + * @param options Other options. + * @return Promise resolved when done. + */ + async processPage( + lesson: AddonModLessonLessonWSData, + courseId: number, + pageData: AddonModLessonGetPageDataWSResponse, + data: Record, + options: AddonModLessonProcessPageOptions = {}, + ): Promise { + + options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId(); + if (!pageData.page) { + throw new CoreError('Page data not supplied.'); + } + + const page = pageData.page; + const pageId = page.id; + + if (!options.offline) { + const response = await this.processPageOnline(lesson.id, pageId, data, options); + + CoreEvents.trigger(AddonModLessonProvider.DATA_SENT_EVENT, { + lessonId: lesson.id, + type: 'process', + courseId: courseId, + pageId: pageId, + review: options.review, + }, CoreSites.instance.getCurrentSiteId()); + + response.sent = true; + + return response; + } + + if (!options.accessInfo || !options.jumps) { + throw new CoreError('Access info or jumps not supplied to processPage.'); + } + + // Get the list of pages of the lesson. + const pages = await this.getPages(lesson.id, { + cmId: lesson.coursemodule, + password: options.password, + readingStrategy: CoreSitesReadingStrategy.PreferCache, + siteId: options.siteId, + }); + + const pageIndex = this.createPagesIndex(pages); + const result: AddonModLessonProcessPageResponse = { + newpageid: data.newpageid, + inmediatejump: false, + nodefaultresponse: false, + feedback: '', + attemptsremaining: null, + correctanswer: false, + noanswer: false, + isessayquestion: false, + maxattemptsreached: false, + response: '', + studentanswer: '', + userresponse: '', + reviewmode: false, + ongoingscore: '', + progress: null, + displaymenu: false, + messages: [], + }; + + if (pageData.answers.length) { + const recordAttemptResult = await this.recordAttempt( + lesson, + courseId, + pageData, + data, + !!options.review, + options.accessInfo, + options.jumps, + pageIndex, + options.siteId, + ); + + Object.assign(result, recordAttemptResult); + } else { + // If no answers, progress to the next page (as set by newpageid). + result.nodefaultresponse = true; + } + + result.newpageid = this.getNewPageId(pageData.page.id, result.newpageid, options.jumps); + + // Calculate some needed offline data. + const calculatedData = await this.calculateOfflineData(lesson, { + accessInfo: options.accessInfo, + password: options.password, + review: options.review, + pageIndex, + siteId: options.siteId, + }); + + // Add some default data to match the WS response. + return { + ...result, + ...calculatedData, + displaymenu: pageData.displaymenu, // Keep the same value since we can't calculate it in offline. + messages: this.getPageProcessMessages(lesson, options.accessInfo, result, !!options.review, options.jumps), + warnings: [], + sent: false, + }; + } + + /** + * Process a lesson page, saving its data. It will fail if offline or cannot connect. + * + * @param lessonId Lesson ID. + * @param pageId Page ID. + * @param data Data to save. + * @param options Other options. + * @return Promise resolved in success, rejected otherwise. + */ + async processPageOnline( + lessonId: number, + pageId: number, + data: Record, + options: AddonModLessonProcessPageOnlineOptions = {}, + ): Promise { + + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModLessonProcessPageWSParams = { + lessonid: lessonId, + pageid: pageId, + data: CoreUtils.instance.objectToArrayOfObjects(data, 'name', 'value', true), + review: !!options.review, + }; + + if (typeof options.password == 'string') { + params.password = options.password; + } + + return site.write('mod_lesson_process_page', params); + } + + /** + * Records an attempt on a certain page. + * Based on Moodle's record_attempt. + * + * @param lesson Lesson. + * @param courseId Course ID the lesson belongs to. + * @param pageData Page data for the page to process. + * @param data Data to save. + * @param review If the user wants to review just after finishing (1 hour margin). + * @param accessInfo Access info. + * @param jumps Possible jumps. + * @param pageIndex Object containing all the pages indexed by ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the result. + */ + protected async recordAttempt( + lesson: AddonModLessonLessonWSData, + courseId: number, + pageData: AddonModLessonGetPageDataWSResponse, + data: Record, + review: boolean, + accessInfo: AddonModLessonGetAccessInformationWSResponse, + jumps: AddonModLessonPossibleJumps, + pageIndex: Record, + siteId?: string, + ): Promise { + + if (!pageData.page) { + throw new CoreError('Page data not supplied.'); + } + + // Check the user answer. Each page type has its own implementation. + const result: AddonModLessonRecordAttemptResult = this.checkAnswer(lesson, pageData, data, jumps, pageIndex); + const retake = accessInfo.attemptscount; + + // Processes inmediate jumps. + if (result.inmediatejump) { + if (pageData.page?.qtype == AddonModLessonProvider.LESSON_PAGE_BRANCHTABLE) { + // Store the content page data. In Moodle this is stored in a separate table, during checkAnswer. + await AddonModLessonOffline.instance.processPage( + lesson.id, + courseId, + retake, + pageData.page, + data, + result.newpageid, + result.answerid, + false, + result.userresponse, + siteId, + ); + + return result; + } + + return result; + } + + let nAttempts: number | undefined; + result.attemptsremaining = 0; + result.maxattemptsreached = false; + + if (result.noanswer) { + result.newpageid = pageData.page.id; // Display same page again. + result.feedback = Translate.instance.instant('addon.mod_lesson.noanswer'); + + return result; + } + + if (!accessInfo.canmanage) { + // Get the number of attempts that have been made on this question for this student and retake. + const attempts = await this.getQuestionsAttempts(lesson.id, retake, { + cmId: lesson.coursemodule, + pageId: pageData.page.id, + siteId, + }); + + nAttempts = attempts.online.length + attempts.offline.length; + + // Check if they have reached (or exceeded) the maximum number of attempts allowed. + if (lesson.maxattempts && lesson.maxattempts > 0 && nAttempts >= lesson.maxattempts) { + result.maxattemptsreached = true; + result.feedback = Translate.instance.instant('addon.mod_lesson.maximumnumberofattemptsreached'); + result.newpageid = AddonModLessonProvider.LESSON_NEXTPAGE; + + return result; + } + + // Only insert a record if we are not reviewing the lesson. + if (!review && (lesson.retake || (!lesson.retake && !retake))) { + // Store the student's attempt and increase the number of attempts made. + // Calculate and store the new page ID to prevent having to recalculate it later. + const newPageId = this.getNewPageId(pageData.page.id, result.newpageid, jumps); + + await AddonModLessonOffline.instance.processPage( + lesson.id, + courseId, + retake, + pageData.page, + data, + newPageId, + result.answerid, + result.correctanswer, + result.userresponse, + siteId, + ); + + nAttempts++; + } + + // Check if "number of attempts remaining" message is needed. + if (!result.correctanswer && !result.newpageid) { + // Retreive the number of attempts left counter. + if (lesson.maxattempts && lesson.maxattempts > 0 && nAttempts >= lesson.maxattempts) { + if (lesson.maxattempts > 1) { // Don't bother with message if only one attempt. + result.maxattemptsreached = true; + } + result.newpageid = AddonModLessonProvider.LESSON_NEXTPAGE; + } else if (lesson.maxattempts && lesson.maxattempts > 1) { // Don't show message if only one attempt or unlimited. + result.attemptsremaining = lesson.maxattempts - nAttempts; + } + } + } + + // Determine default feedback if necessary. + if (!result.response) { + if (!lesson.feedback && !result.noanswer && !(lesson.review && !result.correctanswer && !result.isessayquestion)) { + // These conditions have been met: + // 1. The lesson manager has not supplied feedback to the student. + // 2. Not displaying default feedback. + // 3. The user did provide an answer. + // 4. We are not reviewing with an incorrect answer (and not reviewing an essay question). + result.nodefaultresponse = true; + } else if (result.isessayquestion) { + result.response = Translate.instance.instant('addon.mod_lesson.defaultessayresponse'); + } else if (result.correctanswer) { + result.response = Translate.instance.instant('addon.mod_lesson.thatsthecorrectanswer'); + } else { + result.response = Translate.instance.instant('addon.mod_lesson.thatsthewronganswer'); + } + } + + if (!result.response) { + return result; + } + + if (lesson.review && !result.correctanswer && !result.isessayquestion) { + // Calculate the number of question attempt in the page if it isn't calculated already. + if (typeof nAttempts == 'undefined') { + const result = await this.getQuestionsAttempts(lesson.id, retake, { + cmId: lesson.coursemodule, + pageId: pageData.page.id, + siteId, + }); + + nAttempts = result.online.length + result.offline.length; + } + + const messageId = nAttempts == 1 ? 'firstwrong' : 'secondpluswrong'; + + result.feedback = ''; + } else { + result.feedback = ''; + } + + let className = 'response'; + if (result.correctanswer) { + className += ' correct'; + } else if (!result.isessayquestion) { + className += ' incorrect'; + } + + result.feedback += '
' + pageData.page.contents + '
'; + result.feedback += '
' + + Translate.instance.instant('addon.mod_lesson.youranswer') + ' : ' + + '
'; + + // Create a table containing the answers and responses. + if (pageData.page.qoption) { + // Multianswer allowed. + const studentAnswerArray = result.studentanswer ? + result.studentanswer.split(AddonModLessonProvider.MULTIANSWER_DELIMITER) : []; + const responseArray = result.response ? result.response.split(AddonModLessonProvider.MULTIANSWER_DELIMITER) : []; + + // Add answers and responses to the table. + for (let i = 0; i < studentAnswerArray.length; i++) { + result.feedback = this.addAnswerAndResponseToFeedback( + result.feedback, + studentAnswerArray[i], + result.studentanswerformat || 1, + responseArray[i], + className, + ); + } + } else { + // Only 1 answer, add it to the table. + result.feedback = this.addAnswerAndResponseToFeedback( + result.feedback, + result.studentanswer, + result.studentanswerformat || 1, + result.response, + className, + ); + } + + result.feedback += '
'; + + return result; + } + + /** + * Remove a password stored in DB. + * + * @param lessonId Lesson ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when removed. + */ + async removeStoredPassword(lessonId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().deleteRecords(PASSWORD_TABLE_NAME, { lessonid: lessonId }); + } + + /** + * Store a password in DB. + * + * @param lessonId Lesson ID. + * @param password Password to store. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when stored. + */ + async storePassword(lessonId: number, password: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const entry: AddonModLessonPasswordDBRecord = { + lessonid: lessonId, + password: password, + timemodified: Date.now(), + }; + + await site.getDb().insertRecord(PASSWORD_TABLE_NAME, entry); + } + + /** + * Function to determine if a page is a valid page. It will add the page to validPages if valid. It can also + * modify the list of viewedPagesIds for cluster pages. + * Based on Moodle's valid_page_and_view. + * + * @param pages Index of lesson pages, indexed by page ID. See createPagesIndex. + * @param page Page to check. + * @param validPages Valid pages, indexed by page ID. + * @param viewedPagesIds List of viewed pages IDs. + * @return Next page ID. + */ + validPageAndView( + pages: Record, + page: AddonModLessonPageWSData, + validPages: Record, + viewedPagesIds: number[], + ): number { + + if (page.qtype != AddonModLessonProvider.LESSON_PAGE_ENDOFCLUSTER && + page.qtype != AddonModLessonProvider.LESSON_PAGE_ENDOFBRANCH) { + // Add this page as a valid page. + validPages[page.id] = 1; + } + + if (page.qtype == AddonModLessonProvider.LESSON_PAGE_CLUSTER) { + // Get list of pages in the cluster. + const subPages = this.getSubpagesOf(pages, page.id, [AddonModLessonProvider.LESSON_PAGE_ENDOFCLUSTER]); + + subPages.forEach((subPage) => { + const position = viewedPagesIds.indexOf(subPage.id); + if (position == -1) { + return; + } + + delete viewedPagesIds[position]; // Remove it. + + // Since the user did see one page in the cluster, add the cluster pageid to the viewedPagesIds. + if (viewedPagesIds.indexOf(page.id) == -1) { + viewedPagesIds.push(page.id); + } + }); + } + + return page.nextpageid; + } + +} + +export class AddonModLesson extends makeSingleton(AddonModLessonProvider) {} + +/** + * Result of check answer. + */ +export type AddonModLessonCheckAnswerResult = { + answerid: number; + noanswer: boolean; + correctanswer: boolean; + isessayquestion: boolean; + response: string; + newpageid: number; + studentanswer: string; + userresponse: unknown; + feedback?: string; + nodefaultresponse?: boolean; + inmediatejump?: boolean; + studentanswerformat?: number; + useranswer?: unknown; +}; + +/** + * Result of record attempt. + */ +export type AddonModLessonRecordAttemptResult = AddonModLessonCheckAnswerResult & { + attemptsremaining?: number; + maxattemptsreached?: boolean; +}; + +/** + * Result of lesson grade. + */ +export type AddonModLessonGrade = { + /** + * Number of questions answered. + */ + nquestions: number; + + /** + * Number of question attempts. + */ + attempts: number; + + /** + * Max points possible. + */ + total: number; + + /** + * Points earned by the student. + */ + earned: number; + + /** + * Calculated percentage grade. + */ + grade: number; + + /** + * Numer of manually graded questions. + */ + nmanual: number; + + /** + * Point value for manually graded questions. + */ + manualpoints: number; +}; + +/** + * Common options including a group ID. + */ +export type AddonModLessonGroupOptions = CoreCourseCommonModWSOptions & { + groupId?: number; // The group to get. If not defined, all participants. +}; + +/** + * Common options including a group ID. + */ +export type AddonModLessonUserOptions = CoreCourseCommonModWSOptions & { + userId?: number; // User ID. If not defined, site's current user. +}; + +/** + * Common options including a password. + */ +export type AddonModLessonPasswordOptions = CoreCourseCommonModWSOptions & { + password?: string; // Lesson password (if any). +}; + +/** + * Common options including password and review. + */ +export type AddonModLessonPwdReviewOptions = AddonModLessonPasswordOptions & { + review?: boolean; // If the user wants to review just after finishing (1 hour margin). +}; + +/** + * Options to pass to get lesson with password. + */ +export type AddonModLessonGetWithPasswordOptions = AddonModLessonPasswordOptions & { + validatePassword?: boolean; // Defauls to true. If true, the function will fail if the password is wrong. +}; + +/** + * Options to pass to calculateProgress. + */ +export type AddonModLessonCalculateProgressBasicOptions = { + password?: string; // Lesson password (if any). + review?: boolean; // If the user wants to review just after finishing (1 hour margin). + pageIndex?: Record; // Page index. If not provided, it will be calculated. + siteId?: string; // Site ID. If not defined, current site. +}; + +/** + * Options to pass to calculateProgress. + */ +export type AddonModLessonCalculateProgressOptions = AddonModLessonCalculateProgressBasicOptions & { + cmId?: number; // Module ID. +}; + +/** + * Options to pass to lessonGrade. + */ +export type AddonModLessonGradeOptions = AddonModLessonCalculateProgressBasicOptions & { + userId?: number; // User ID. If not defined, site's user. +}; + +/** + * Options to pass to calculateOfflineData. + */ +export type AddonModLessonCalculateOfflineDataOptions = AddonModLessonCalculateProgressBasicOptions & { + accessInfo?: AddonModLessonGetAccessInformationWSResponse; // Access info. +}; + +/** + * Options to pass to get page data. + */ +export type AddonModLessonGetPageDataOptions = AddonModLessonPwdReviewOptions & { + includeContents?: boolean; // Include the page rendered contents. + includeOfflineData?: boolean; // Whether to include calculated offline data. Only when ignoring cache. + accessInfo?: AddonModLessonGetAccessInformationWSResponse; // Access info. Required if includeOfflineData is true. + jumps?: AddonModLessonPossibleJumps; // Possible jumps. Required if includeOfflineData is true. +}; + +/** + * Options to pass to get page data. + */ +export type AddonModLessonGetPageViewMessagesOptions = { + password?: string; // Lesson password (if any). + review?: boolean; // If the user wants to review just after finishing (1 hour margin). + siteId?: string; // Site ID. If not defined, current site. +}; + +/** + * Options to pass to get questions attempts. + */ +export type AddonModLessonGetQuestionsAttemptsOptions = CoreCourseCommonModWSOptions & { + correct?: boolean; // True to only fetch correct attempts, false to get them all. + pageId?: number; // If defined, only get attempts on this page. + userId?: number; // User ID. If not defined, site's user. +}; + +/** + * Options to pass to getPagesIdsWithQuestionAttempts. + */ +export type AddonModLessonGetPagesIdsWithAttemptsOptions = CoreCourseCommonModWSOptions & { + correct?: boolean; // True to only fetch correct attempts, false to get them all. + userId?: number; // User ID. If not defined, site's user. +}; + +/** + * Options to pass to processPageOnline. + */ +export type AddonModLessonProcessPageOnlineOptions = { + password?: string; // Lesson password (if any). + review?: boolean; // If the user wants to review just after finishing (1 hour margin). + siteId?: string; // Site ID. If not defined, current site. +}; + +/** + * Options to pass to processPage. + */ +export type AddonModLessonProcessPageOptions = AddonModLessonProcessPageOnlineOptions & { + offline?: boolean; // Whether it's offline mode. + accessInfo?: AddonModLessonGetAccessInformationWSResponse; // Access info. Required if offline is true. + jumps?: AddonModLessonPossibleJumps; // Possible jumps. Required if offline is true. +}; + +/** + * Options to pass to finishRetakeOnline. + */ +export type AddonModLessonFinishRetakeOnlineOptions = { + password?: string; // Lesson password (if any). + outOfTime?: boolean; // Whether the user ran out of time. + review?: boolean; // If the user wants to review just after finishing (1 hour margin). + siteId?: string; // Site ID. If not defined, current site. +}; + +/** + * Options to pass to finishRetake. + */ +export type AddonModLessonFinishRetakeOptions = AddonModLessonFinishRetakeOnlineOptions & { + offline?: boolean; // Whether it's offline mode. + accessInfo?: AddonModLessonGetAccessInformationWSResponse; // Access info. Required if offline is true. +}; + +/** + * Params of mod_lesson_get_lesson_access_information WS. + */ +export type AddonModLessonGetAccessInformationWSParams = { + lessonid: number; // Lesson instance id. +}; + +/** + * Data returned by mod_lesson_get_lesson_access_information WS. + */ +export type AddonModLessonGetAccessInformationWSResponse = { + canmanage: boolean; // Whether the user can manage the lesson or not. + cangrade: boolean; // Whether the user can grade the lesson or not. + canviewreports: boolean; // Whether the user can view the lesson reports or not. + reviewmode: boolean; // Whether the lesson is in review mode for the current user. + attemptscount: number; // The number of attempts done by the user. + lastpageseen: number; // The last page seen id. + leftduringtimedsession: boolean; // Whether the user left during a timed session. + firstpageid: number; // The lesson first page id. + preventaccessreasons: AddonModLessonPreventAccessReason[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Prevent access reason returned by mod_lesson_get_lesson_access_information. + */ +export type AddonModLessonPreventAccessReason = { + reason: string; // Reason lang string code. + data: string; // Additional data. + message: string; // Complete html message. +}; + +/** + * Params of mod_lesson_get_content_pages_viewed WS. + */ +export type AddonModLessonGetContentPagesViewedWSParams = { + lessonid: number; // Lesson instance id. + lessonattempt: number; // Lesson attempt number. + userid?: number; // The user id (empty for current user). +}; + +/** + * Data returned by mod_lesson_get_content_pages_viewed WS. + */ +export type AddonModLessonGetContentPagesViewedWSResponse = { + pages: AddonModLessonWSContentPageViewed[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Page data returned in mod_lesson_get_content_pages_viewed WS. + */ +export type AddonModLessonWSContentPageViewed = { + id: number; // The attempt id. + lessonid: number; // The lesson id. + pageid: number; // The page id. + userid: number; // The user who viewed the page. + retry: number; // The lesson attempt number. + flag: number; // 1 if the next page was calculated randomly. + timeseen: number; // The time the page was seen. + nextpageid: number; // The next page chosen id. +}; + +/** + * Params of mod_lesson_get_lessons_by_courses WS. + */ +export type AddonModLessonGetLessonsByCoursesWSParams = { + courseids?: number[]; // Array of course ids. +}; + +/** + * Data returned by mod_lesson_get_lessons_by_courses WS. + */ +export type AddonModLessonGetLessonsByCoursesWSResponse = { + lessons: AddonModLessonLessonWSData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Lesson data returned by WS. + */ +export type AddonModLessonLessonWSData = { + id: number; // Standard Moodle primary key. + course: number; // Foreign key reference to the course this lesson is part of. + coursemodule: number; // Course module id. + name: string; // Lesson name. + intro?: string; // Lesson introduction text. + introformat?: number; // Intro format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + practice?: boolean; // Practice lesson?. + modattempts?: boolean; // Allow student review?. + usepassword?: boolean; // Password protected lesson?. + password?: string; // Password. + dependency?: number; // Dependent on (another lesson id). + conditions?: string; // Conditions to enable the lesson. + grade?: number; // The total that the grade is scaled to be out of. + custom?: boolean; // Custom scoring?. + ongoing?: boolean; // Display ongoing score?. + usemaxgrade?: number; // How to calculate the final grade. + maxanswers?: number; // Maximum answers per page. + maxattempts?: number; // Maximum attempts. + review?: boolean; // Provide option to try a question again. + nextpagedefault?: number; // Action for a correct answer. + feedback?: boolean; // Display default feedback. + minquestions?: number; // Minimum number of questions. + maxpages?: number; // Number of pages to show. + timelimit?: number; // Time limit. + retake?: boolean; // Re-takes allowed. + activitylink?: number; // Id of the next activity to be linked once the lesson is completed. + mediafile?: string; // Local file path or full external URL. + mediaheight?: number; // Popup for media file height. + mediawidth?: number; // Popup for media with. + mediaclose?: number; // Display a close button in the popup?. + slideshow?: boolean; // Display lesson as slideshow. + width?: number; // Slideshow width. + height?: number; // Slideshow height. + bgcolor?: string; // Slideshow bgcolor. + displayleft?: boolean; // Display left pages menu?. + displayleftif?: number; // Minimum grade to display menu. + progressbar?: boolean; // Display progress bar?. + available?: number; // Available from. + deadline?: number; // Available until. + timemodified?: number; // Last time settings were updated. + completionendreached?: number; // Require end reached for completion?. + completiontimespent?: number; // Student must do this activity at least for. + allowofflineattempts: boolean; // Whether to allow the lesson to be attempted offline in the mobile app. + introfiles?: { // Introfiles. + filename?: string; // File name. + filepath?: string; // File path. + filesize?: number; // File size. + fileurl: string; // Downloadable file url. + timemodified?: number; // Time modified. + mimetype?: string; // File mime type. + isexternalfile?: number; // Whether is an external file. + repositorytype?: string; // The repository type for the external files. + }[]; + mediafiles?: { // Mediafiles. + filename?: string; // File name. + filepath?: string; // File path. + filesize?: number; // File size. + fileurl: string; // Downloadable file url. + timemodified?: number; // Time modified. + mimetype?: string; // File mime type. + isexternalfile?: number; // Whether is an external file. + repositorytype?: string; // The repository type for the external files. + }[]; +}; + +/** + * Params of mod_lesson_get_lesson WS. + */ +export type AddonModLessonGetLessonWSParams = { + lessonid: number; // Lesson instance id. + password?: string; // Lesson password. +}; + +/** + * Data returned by mod_lesson_get_lesson WS. + */ +export type AddonModLessonGetLessonWSResponse = { + lesson: AddonModLessonLessonWSData; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_lesson_get_page_data WS. + */ +export type AddonModLessonGetPageDataWSParams = { + lessonid: number; // Lesson instance id. + pageid: number; // The page id. + password?: string; // Optional password (the lesson may be protected). + review?: boolean; // If we want to review just after finishing (1 hour margin). + returncontents?: boolean; // If we must return the complete page contents once rendered. +}; + +/** + * Data returned by mod_lesson_get_page_data WS. + */ +export type AddonModLessonGetPageDataWSResponse = { + page?: AddonModLessonPageWSData; // Page fields. + newpageid: number; // New page id (if a jump was made). + pagecontent?: string; // Page html content. + ongoingscore: string; // The ongoing score message. + progress: number; // Progress percentage in the lesson. + contentfiles: CoreWSExternalFile[]; + answers: AddonModLessonPageAnswerWSData[]; + messages: AddonModLessonMessageWSData[]; + displaymenu: boolean; // Whether we should display the menu or not in this page. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Page data returned by several WS. + */ +export type AddonModLessonPageWSData = { + id: number; // The id of this lesson page. + lessonid: number; // The id of the lesson this page belongs to. + prevpageid: number; // The id of the page before this one. + nextpageid: number; // The id of the next page in the page sequence. + qtype: number; // Identifies the page type of this page. + qoption: number; // Used to record page type specific options. + layout: number; // Used to record page specific layout selections. + display: number; // Used to record page specific display selections. + timecreated: number; // Timestamp for when the page was created. + timemodified: number; // Timestamp for when the page was last modified. + title?: string; // The title of this page. + contents?: string; // The contents of this page. + contentsformat?: number; // Contents format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + displayinmenublock: boolean; // Toggles display in the left menu block. + type: number; // The type of the page [question | structure]. + typeid: number; // The unique identifier for the page type. + typestring: string; // The string that describes this page type. +}; + +/** + * Page answer data returned by mod_lesson_get_page_data. + */ +export type AddonModLessonPageAnswerWSData = { + id: number; // The ID of this answer in the database. + answerfiles: CoreWSExternalFile[]; + responsefiles: CoreWSExternalFile[]; + jumpto?: number; // Identifies where the user goes upon completing a page with this answer. + grade?: number; // The grade this answer is worth. + score?: number; // The score this answer will give. + flags?: number; // Used to store options for the answer. + timecreated?: number; // A timestamp of when the answer was created. + timemodified?: number; // A timestamp of when the answer was modified. + answer?: string; // Possible answer text. + answerformat?: number; // Answer format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + response?: string; // Response text for the answer. + responseformat?: number; // Response format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). +}; + +/** + * Page answer data with some calculated data. + */ +export type AddonModLessonPageAnswerData = AddonModLessonPageAnswerWSData & { + pageid: number; +}; + +/** + * Message data returned by several WS. + */ +export type AddonModLessonMessageWSData = { + message: string; // Message. + type: string; // Message type: usually a CSS identifier like: success, info, warning, error, ... +}; + +/** + * Params of mod_lesson_get_pages WS. + */ +export type AddonModLessonGetPagesWSParams = { + lessonid: number; // Lesson instance id. + password?: string; // Optional password (the lesson may be protected). +}; + +/** + * Data returned by mod_lesson_get_pages WS. + */ +export type AddonModLessonGetPagesWSResponse = { + pages: AddonModLessonGetPagesPageWSData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Data for each page returned by mod_lesson_get_pages WS. + */ +export type AddonModLessonGetPagesPageWSData = { + page: AddonModLessonPageWSData; // Page fields. + answerids: number[]; // List of answers ids (empty for content pages in Moodle 1.9). + jumps: number[]; // List of possible page jumps. + filescount: number; // The total number of files attached to the page. + filessizetotal: number; // The total size of the files. +}; + +/** + * Params of mod_lesson_get_pages_possible_jumps WS. + */ +export type AddonModLessonGetPagesPossibleJumpsWSParams = { + lessonid: number; // Lesson instance id. +}; + +/** + * Data returned by mod_lesson_get_pages_possible_jumps WS. + */ +export type AddonModLessonGetPagesPossibleJumpsWSResponse = { + jumps: AddonModLessonPossibleJumpWSData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Data for each jump returned by mod_lesson_get_pages_possible_jumps WS. + */ +export type AddonModLessonPossibleJumpWSData = { + pageid: number; // The page id. + answerid: number; // The answer id. + jumpto: number; // The jump (page id or type of jump). + calculatedjump: number; // The real page id (or EOL) to jump. +}; + +/** + * Lesson possible jumps, indexed by page and jumpto. + */ +export type AddonModLessonPossibleJumps = Record>; + +/** + * Params of mod_lesson_get_questions_attempts WS. + */ +export type AddonModLessonGetQuestionsAttemptsWSParams = { + lessonid: number; // Lesson instance id. + attempt: number; // Lesson attempt number. + correct?: boolean; // Only fetch correct attempts. + pageid?: number; // Only fetch attempts at the given page. + userid?: number; // Only fetch attempts of the given user. +}; + +/** + * Data returned by mod_lesson_get_questions_attempts WS. + */ +export type AddonModLessonGetQuestionsAttemptsWSResponse = { + attempts: AddonModLessonQuestionAttemptWSData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Data for each attempt returned by mod_lesson_get_questions_attempts WS. + */ +export type AddonModLessonQuestionAttemptWSData = { + id: number; // The attempt id. + lessonid: number; // The attempt lessonid. + pageid: number; // The attempt pageid. + userid: number; // The user who did the attempt. + answerid: number; // The attempt answerid. + retry: number; // The lesson attempt number. + correct: number; // If it was the correct answer. + useranswer: string; // The complete user answer. + timeseen: number; // The time the question was seen. +}; + +/** + * Params of mod_lesson_get_attempts_overview WS. + */ +export type AddonModLessonGetAttemptsOverviewWSParams = { + lessonid: number; // Lesson instance id. + groupid?: number; // Group id, 0 means that the function will determine the user group. +}; + +/** + * Data returned by mod_lesson_get_attempts_overview WS. + */ +export type AddonModLessonGetAttemptsOverviewWSResponse = { + data?: AddonModLessonAttemptsOverviewWSData; // Attempts overview data (empty for no attemps). + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Overview data returned by mod_lesson_get_attempts_overview WS. + */ +export type AddonModLessonAttemptsOverviewWSData = { + lessonscored: boolean; // True if the lesson was scored. + numofattempts: number; // Number of attempts. + avescore: number; // Average score. + highscore: number; // High score. + lowscore: number; // Low score. + avetime: number; // Average time (spent in taking the lesson). + hightime: number; // High time. + lowtime: number; // Low time. + students?: AddonModLessonAttemptsOverviewsStudentWSData[]; // Students data, including attempts. +}; + +/** + * Student data returned by mod_lesson_get_attempts_overview WS. + */ +export type AddonModLessonAttemptsOverviewsStudentWSData = { + id: number; // User id. + fullname: string; // User full name. + bestgrade: number; // Best grade. + attempts: AddonModLessonAttemptsOverviewsAttemptWSData[]; +}; + +/** + * Attempt data returned by mod_lesson_get_attempts_overview WS. + */ +export type AddonModLessonAttemptsOverviewsAttemptWSData = { + try: number; // Attempt number. + grade: number; // Attempt grade. + timestart: number; // Attempt time started. + timeend: number; // Attempt last time continued. + end: number; // Attempt time ended. +}; + +/** + * Params of mod_lesson_get_user_timers WS. + */ +export type ModLessonGetUserTimersWSParams = { + lessonid: number; // Lesson instance id. + userid?: number; // The user id (empty for current user). +}; + +/** + * Data returned by mod_lesson_get_user_timers WS. + */ +export type ModLessonGetUserTimersWSResponse = { + timers: AddonModLessonUserTimerWSData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Data for each timer returned by mod_lesson_get_user_timers WS. + */ +export type AddonModLessonUserTimerWSData = { + id: number; // The attempt id. + lessonid: number; // The lesson id. + userid: number; // The user id. + starttime: number; // First access time for a new timer session. + lessontime: number; // Last access time to the lesson during the timer session. + completed: number; // If the lesson for this timer was completed. + timemodifiedoffline: number; // Last modified time via webservices. +}; + +/** + * Params of mod_lesson_get_user_attempt WS. + */ +export type AddonModLessonGetUserAttemptWSParams = { + lessonid: number; // Lesson instance id. + userid: number; // The user id. 0 for current user. + lessonattempt: number; // The attempt number. +}; + +/** + * Data returned by mod_lesson_get_user_attempt WS. + */ +export type AddonModLessonGetUserAttemptWSResponse = { + answerpages: AddonModLessonUserAttemptAnswerPageWSData[]; + userstats: { + grade: number; // Attempt final grade. + completed: number; // Time completed. + timetotake: number; // Time taken. + gradeinfo?: AddonModLessonAttemptGradeWSData; // Attempt grade. + }; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Answer page data returned by mod_lesson_get_user_attempt. + */ +export type AddonModLessonUserAttemptAnswerPageWSData = { + page?: AddonModLessonPageWSData; // Page fields. + title: string; // Page title. + contents: string; // Page contents. + qtype: string; // Identifies the page type of this page. + grayout: number; // If is required to apply a grayout. + answerdata?: { + score: string; // The score (text version). + response: string; // The response text. + responseformat: number; // Response. format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + answers?: string[][]; // User answers. + }; // Answer data (empty in content pages created in Moodle 1.x). +}; + +/** + * Attempt grade returned by several WS. + */ +export type AddonModLessonAttemptGradeWSData = { + nquestions: number; // Number of questions answered. + attempts: number; // Number of question attempts. + total: number; // Max points possible. + earned: number; // Points earned by student. + grade: number; // Calculated percentage grade. + nmanual: number; // Number of manually graded questions. + manualpoints: number; // Point value for manually graded questions. +}; + +/** + * Params of mod_lesson_finish_attempt WS. + */ +export type AddonModLessonFinishAttemptWSParams = { + lessonid: number; // Lesson instance id. + password?: string; // Optional password (the lesson may be protected). + outoftime?: boolean; // If the user run out of time. + review?: boolean; // If we want to review just after finishing (1 hour margin). +}; + +/** + * Data returned by mod_lesson_finish_attempt WS. + */ +export type AddonModLessonFinishAttemptWSResponse = { + data: AddonModLessonEOLPageWSDataEntry[]; // The EOL page information data. + messages: AddonModLessonMessageWSData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * EOL page data entry returned by mod_lesson_finish_attempt WS. + */ +export type AddonModLessonEOLPageWSDataEntry = { + name: string; // Data name. + value: string; // Data value. + message: string; // Data message (translated string). +}; + +/** + * Finish retake response. + */ +export type AddonModLessonFinishRetakeResponse = Omit & { + data: Record; +}; + +/** + * Parsed EOL page data. + */ +export type AddonModLessonEOLPageDataEntry = { + name: string; // Data name. + value: unknown; // Data value. + message: string; // Data message (translated string). +}; + +/** + * Params of mod_lesson_view_lesson WS. + */ +export type AddonModLessonViewLessonWSParams = { + lessonid: number; // Lesson instance id. + password?: string; // Lesson password. +}; + +/** + * Params of mod_lesson_process_page WS. + */ +export type AddonModLessonProcessPageWSParams = { + lessonid: number; // Lesson instance id. + pageid: number; // The page id. + data: ProcessPageData[]; // The data to be saved. + password?: string; // Optional password (the lesson may be protected). + review?: boolean; // If we want to review just after finishing (1 hour margin). +}; + +type ProcessPageData = { + name: string; // Data name. + value: string; // Data value. +}; + +/** + * Data returned by mod_lesson_process_page WS. + */ +export type AddonModLessonProcessPageWSResponse = { + newpageid: number; // New page id (if a jump was made). + inmediatejump: boolean; // Whether the page processing redirect directly to anoter page. + nodefaultresponse: boolean; // Whether there is not a default response. + feedback: string; // The response feedback. + attemptsremaining: number | null; // Number of attempts remaining. + correctanswer: boolean; // Whether the answer is correct. + noanswer: boolean; // Whether there aren't answers. + isessayquestion: boolean; // Whether is a essay question. + maxattemptsreached: boolean; // Whether we reachered the max number of attempts. + response: string; // The response. + studentanswer: string; // The student answer. + userresponse: string; // The user response. + reviewmode: boolean; // Whether the user is reviewing. + ongoingscore: string; // The ongoing message. + progress: number | null; // Progress percentage in the lesson. + displaymenu: boolean; // Whether we should display the menu or not in this page. + messages: AddonModLessonMessageWSData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Result of process page. + */ +export type AddonModLessonProcessPageResponse = AddonModLessonProcessPageWSResponse & { + sent?: boolean; // Whether the data was sent to server. +}; + +/** + * Params of mod_lesson_launch_attempt WS. + */ +export type AddonModLessonLaunchAttemptWSParams = { + lessonid: number; // Lesson instance id. + password?: string; // Optional password (the lesson may be protected). + pageid?: number; // Page id to continue from (only when continuing an attempt). + review?: boolean; // If we want to review just after finishing. +}; + +/** + * Data returned by mod_lesson_launch_attempt WS. + */ +export type AddonModLessonLaunchAttemptWSResponse = { + messages: AddonModLessonMessageWSData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Attempt data, either online or offline attempt. + */ +export type AddonModLessonAnyAttemptData = AddonModLessonQuestionAttemptWSData | AddonModLessonPageAttemptRecord; + +/** + * Either content page data or page attempt offline record. + */ +export type AddonModLessonContentPageOrRecord = AddonModLessonWSContentPageViewed | AddonModLessonPageAttemptRecord; + +/** + * Data passed to DATA_SENT_EVENT event. + */ +export type AddonModLessonDataSentData = CoreEventSiteData & { + lessonId: number; + type: string; + courseId?: number; + outOfTime?: boolean; + review?: boolean; + pageId?: number; +}; diff --git a/src/addons/mod/mod.module.ts b/src/addons/mod/mod.module.ts new file mode 100644 index 000000000..fad22f4d3 --- /dev/null +++ b/src/addons/mod/mod.module.ts @@ -0,0 +1,27 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; + +import { AddonModLessonModule } from './lesson/lesson.module'; + +@NgModule({ + declarations: [], + imports: [ + AddonModLessonModule, + ], + providers: [], + exports: [], +}) +export class AddonModModule { } From f1fbb758894ea189e9a13040757ec99a46557d5b Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 1 Feb 2021 12:28:52 +0100 Subject: [PATCH 02/10] MOBILE-3648 lesson: Implement sync service and prefetch handler --- .../lesson/components/components.module.ts | 39 ++ .../password-modal/password-modal.html | 28 + .../password-modal/password-modal.ts | 57 ++ src/addons/mod/lesson/lesson.module.ts | 17 +- .../mod/lesson/services/database/lesson.ts | 45 +- .../mod/lesson/services/handlers/prefetch.ts | 576 ++++++++++++++++++ .../mod/lesson/services/handlers/sync-cron.ts | 52 ++ src/addons/mod/lesson/services/lesson-sync.ts | 518 ++++++++++++++++ 8 files changed, 1330 insertions(+), 2 deletions(-) create mode 100644 src/addons/mod/lesson/components/components.module.ts create mode 100644 src/addons/mod/lesson/components/password-modal/password-modal.html create mode 100644 src/addons/mod/lesson/components/password-modal/password-modal.ts create mode 100644 src/addons/mod/lesson/services/handlers/prefetch.ts create mode 100644 src/addons/mod/lesson/services/handlers/sync-cron.ts create mode 100644 src/addons/mod/lesson/services/lesson-sync.ts diff --git a/src/addons/mod/lesson/components/components.module.ts b/src/addons/mod/lesson/components/components.module.ts new file mode 100644 index 000000000..8999caf02 --- /dev/null +++ b/src/addons/mod/lesson/components/components.module.ts @@ -0,0 +1,39 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonModLessonPasswordModalComponent } from './password-modal/password-modal'; + +@NgModule({ + declarations: [ + AddonModLessonPasswordModalComponent, + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreSharedModule, + ], + providers: [ + ], + exports: [ + AddonModLessonPasswordModalComponent, + ], +}) +export class AddonModLessonComponentsModule {} diff --git a/src/addons/mod/lesson/components/password-modal/password-modal.html b/src/addons/mod/lesson/components/password-modal/password-modal.html new file mode 100644 index 000000000..ba0313e13 --- /dev/null +++ b/src/addons/mod/lesson/components/password-modal/password-modal.html @@ -0,0 +1,28 @@ + + + {{ 'core.login.password' | translate }} + + + + + + + + + +
+ + {{ 'addon.mod_lesson.enterpassword' | translate }} + + + + + + {{ 'addon.mod_lesson.continue' | translate }} + + + + +
+
diff --git a/src/addons/mod/lesson/components/password-modal/password-modal.ts b/src/addons/mod/lesson/components/password-modal/password-modal.ts new file mode 100644 index 000000000..746e525b2 --- /dev/null +++ b/src/addons/mod/lesson/components/password-modal/password-modal.ts @@ -0,0 +1,57 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, ViewChild, ElementRef } from '@angular/core'; + +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { ModalController } from '@singletons'; + + +/** + * Modal that asks the password for a lesson. + */ +@Component({ + selector: 'page-addon-mod-lesson-password-modal', + templateUrl: 'password-modal.html', +}) +export class AddonModLessonPasswordModalComponent { + + @ViewChild('passwordForm') formElement?: ElementRef; + + /** + * Send the password back. + * + * @param e Event. + * @param password The input element. + */ + submitPassword(e: Event, password: HTMLInputElement): void { + e.preventDefault(); + e.stopPropagation(); + + CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, false, CoreSites.instance.getCurrentSiteId()); + + ModalController.instance.dismiss(password.value); + } + + /** + * Close modal. + */ + closeModal(): void { + CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId()); + + ModalController.instance.dismiss(); + } + +} diff --git a/src/addons/mod/lesson/lesson.module.ts b/src/addons/mod/lesson/lesson.module.ts index 83df48fce..9b0785854 100644 --- a/src/addons/mod/lesson/lesson.module.ts +++ b/src/addons/mod/lesson/lesson.module.ts @@ -12,13 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { NgModule } from '@angular/core'; +import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { CoreCronDelegate } from '@services/cron'; import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { AddonModLessonComponentsModule } from './components/components.module'; import { SITE_SCHEMA, OFFLINE_SITE_SCHEMA } from './services/database/lesson'; +import { AddonModLessonPrefetchHandler } from './services/handlers/prefetch'; +import { AddonModLessonSyncCronHandler } from './services/handlers/sync-cron'; @NgModule({ imports: [ + AddonModLessonComponentsModule, ], providers: [ { @@ -26,6 +32,15 @@ import { SITE_SCHEMA, OFFLINE_SITE_SCHEMA } from './services/database/lesson'; useValue: [SITE_SCHEMA, OFFLINE_SITE_SCHEMA], multi: true, }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreCourseModulePrefetchDelegate.instance.registerHandler(AddonModLessonPrefetchHandler.instance); + CoreCronDelegate.instance.register(AddonModLessonSyncCronHandler.instance); + }, + }, ], }) export class AddonModLessonModule {} diff --git a/src/addons/mod/lesson/services/database/lesson.ts b/src/addons/mod/lesson/services/database/lesson.ts index ec4b6ec2b..843a690d6 100644 --- a/src/addons/mod/lesson/services/database/lesson.ts +++ b/src/addons/mod/lesson/services/database/lesson.ts @@ -15,7 +15,7 @@ import { CoreSiteSchema } from '@services/sites'; /** - * Database variables for AddonModLessonOfflineProvider. + * Database variables for AddonModLessonProvider. */ export const PASSWORD_TABLE_NAME = 'addon_mod_lesson_password'; export const SITE_SCHEMA: CoreSiteSchema = { @@ -145,6 +145,39 @@ export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = { ], }; +/** + * Database variables for AddonModLessonSyncProvider. + */ +export const RETAKES_FINISHED_SYNC_TABLE_NAME = 'addon_mod_lesson_retakes_finished_sync'; +export const SYNC_SITE_SCHEMA: CoreSiteSchema = { + name: 'AddonModLessonSyncProvider', + version: 1, + tables: [ + { + name: RETAKES_FINISHED_SYNC_TABLE_NAME, + columns: [ + { + name: 'lessonid', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'retake', + type: 'INTEGER', + }, + { + name: 'pageid', + type: 'INTEGER', + }, + { + name: 'timefinished', + type: 'INTEGER', + }, + ], + }, + ], +}; + /** * Lesson retake data. */ @@ -183,3 +216,13 @@ export type AddonModLessonPageAttemptDBRecord = { answerid: number | null; useranswer: string | null; }; + +/** + * Data about a retake finished in sync. + */ +export type AddonModLessonRetakeFinishedInSyncDBRecord = { + lessonid: number; + retake: number; + pageid: number; + timefinished: number; +}; diff --git a/src/addons/mod/lesson/services/handlers/prefetch.ts b/src/addons/mod/lesson/services/handlers/prefetch.ts new file mode 100644 index 000000000..f81535a66 --- /dev/null +++ b/src/addons/mod/lesson/services/handlers/prefetch.ts @@ -0,0 +1,576 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreCanceledError } from '@classes/errors/cancelederror'; +import { CoreError } from '@classes/errors/error'; + +import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler'; +import { CoreCourse, CoreCourseCommonModWSOptions, CoreCourseAnyModuleData } from '@features/course/services/course'; +import { CoreFilepool } from '@services/filepool'; +import { CoreGroups } from '@services/groups'; +import { CoreFileSizeSum, CorePluginFileDelegate } from '@services/plugin-file-delegate'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton, ModalController, Translate } from '@singletons'; +import { AddonModLessonPasswordModalComponent } from '../../components/password-modal/password-modal'; +import { + AddonModLesson, + AddonModLessonGetAccessInformationWSResponse, + AddonModLessonLessonWSData, + AddonModLessonPasswordOptions, + AddonModLessonProvider, +} from '../lesson'; +import { AddonModLessonSync, AddonModLessonSyncResult } from '../lesson-sync'; + +/** + * Handler to prefetch lessons. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModLessonPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase { + + name = 'AddonModLesson'; + modName = 'lesson'; + component = AddonModLessonProvider.COMPONENT; + // Don't check timers to decrease positives. If a user performs some action it will be reflected in other items. + updatesNames = /^configuration$|^.*files$|^grades$|^gradeitems$|^pages$|^answers$|^questionattempts$|^pagesviewed$/; + + /** + * Ask password. + * + * @return Promise resolved with the password. + */ + protected async askUserPassword(): Promise { + // Create and show the modal. + const modal = await ModalController.instance.create({ + component: AddonModLessonPasswordModalComponent, + }); + + await modal.present(); + + const password = await modal.onWillDismiss(); + + if (typeof password != 'string') { + throw new CoreCanceledError(); + } + + return password; + } + + /** + * Get the download size of a module. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param single True if we're downloading a single module, false if we're downloading a whole section. + * @return Promise resolved with the size. + */ + async getDownloadSize(module: CoreCourseAnyModuleData, courseId: number, single?: boolean): Promise { + const siteId = CoreSites.instance.getCurrentSiteId(); + + let lesson = await AddonModLesson.instance.getLesson(courseId, module.id, { siteId }); + + // Get the lesson password if it's needed. + const passwordData = await this.getLessonPassword(lesson.id, { + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + askPassword: single, + siteId, + }); + + lesson = passwordData.lesson || lesson; + + // Get intro files and media files. + let files = lesson.mediafiles || []; + files = files.concat(this.getIntroFilesFromInstance(module, lesson)); + + const result = await CorePluginFileDelegate.instance.getFilesDownloadSize(files); + + // Get the pages to calculate the size. + const pages = await AddonModLesson.instance.getPages(lesson.id, { + cmId: module.id, + password: passwordData.password, + siteId, + }); + + pages.forEach((page) => { + result.size += page.filessizetotal; + }); + + return result; + } + + /** + * Get the lesson password if needed. If not stored, it can ask the user to enter it. + * + * @param lessonId Lesson ID. + * @param options Other options. + * @return Promise resolved when done. + */ + async getLessonPassword( + lessonId: number, + options: AddonModLessonGetPasswordOptions = {}, + ): Promise { + + options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId(); + + // Get access information to check if password is needed. + const accessInfo = await AddonModLesson.instance.getAccessInformation(lessonId, options); + + if (!accessInfo.preventaccessreasons.length) { + // Password not needed. + return { accessInfo }; + } + + const passwordNeeded = accessInfo.preventaccessreasons.length == 1 && + AddonModLesson.instance.isPasswordProtected(accessInfo); + + if (!passwordNeeded) { + // Lesson cannot be played, reject. + throw new CoreError(accessInfo.preventaccessreasons[0].message); + } + + // The lesson requires a password. Check if there is one in DB. + let password = await CoreUtils.instance.ignoreErrors(AddonModLesson.instance.getStoredPassword(lessonId)); + + if (password) { + try { + return this.validatePassword(lessonId, accessInfo, password, options); + } catch { + // Error validating it. + } + } + + // Ask for the password if allowed. + if (!options.askPassword) { + // Cannot ask for password, reject. + throw new CoreError(accessInfo.preventaccessreasons[0].message); + } + + password = await this.askUserPassword(); + + return this.validatePassword(lessonId, accessInfo, password, options); + } + + /** + * Invalidate the prefetched content. + * + * @param moduleId The module ID. + * @param courseId The course ID the module belongs to. + * @return Promise resolved when the data is invalidated. + */ + async invalidateContent(moduleId: number, courseId: number): Promise { + // Only invalidate the data that doesn't ignore cache when prefetching. + await Promise.all([ + AddonModLesson.instance.invalidateLessonData(courseId), + CoreCourse.instance.invalidateModule(moduleId), + CoreGroups.instance.invalidateActivityAllowedGroups(moduleId), + ]); + } + + /** + * Invalidate WS calls needed to determine module status. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when invalidated. + */ + async invalidateModule(module: CoreCourseAnyModuleData, courseId: number): Promise { + // Invalidate data to determine if module is downloadable. + const siteId = CoreSites.instance.getCurrentSiteId(); + + const lesson = await AddonModLesson.instance.getLesson(courseId, module.id, { + readingStrategy: CoreSitesReadingStrategy.PreferCache, + siteId, + }); + + await Promise.all([ + AddonModLesson.instance.invalidateLessonData(courseId, siteId), + AddonModLesson.instance.invalidateAccessInformation(lesson.id, siteId), + ]); + } + + /** + * Check if a module can be downloaded. If the function is not defined, we assume that all modules are downloadable. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Whether the module can be downloaded. The promise should never be rejected. + */ + async isDownloadable(module: CoreCourseAnyModuleData, courseId: number): Promise { + const siteId = CoreSites.instance.getCurrentSiteId(); + + const lesson = await AddonModLesson.instance.getLesson(courseId, module.id, { siteId }); + const accessInfo = await AddonModLesson.instance.getAccessInformation(lesson.id, { cmId: module.id, siteId }); + + // If it's a student and lesson isn't offline, it isn't downloadable. + if (!accessInfo.canviewreports && !AddonModLesson.instance.isLessonOffline(lesson)) { + return false; + } + + // It's downloadable if there are no prevent access reasons or there is just 1 and it's password. + return !accessInfo.preventaccessreasons.length || + (accessInfo.preventaccessreasons.length == 1 && AddonModLesson.instance.isPasswordProtected(accessInfo)); + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Promise resolved with a boolean indicating if the handler is enabled. + */ + isEnabled(): Promise { + return AddonModLesson.instance.isPluginEnabled(); + } + + /** + * Prefetch a module. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param single True if we're downloading a single module, false if we're downloading a whole section. + * @param dirPath Path of the directory where to store all the content files. + * @return Promise resolved when done. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + prefetch(module: CoreCourseAnyModuleData, courseId?: number, single?: boolean, dirPath?: string): Promise { + return this.prefetchPackage(module, courseId, this.prefetchLesson.bind(this, module, courseId, single)); + } + + /** + * Prefetch a lesson. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param single True if we're downloading a single module, false if we're downloading a whole section. + * @return Promise resolved when done. + */ + protected async prefetchLesson(module: CoreCourseAnyModuleData, courseId?: number, single?: boolean): Promise { + const siteId = CoreSites.instance.getCurrentSiteId(); + courseId = courseId || module.course || 1; + + const commonOptions = { + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + const modOptions = { + cmId: module.id, + ...commonOptions, // Include all common options. + }; + + let lesson = await AddonModLesson.instance.getLesson(courseId, module.id, commonOptions); + + // Get the lesson password if it's needed. + const passwordData = await this.getLessonPassword(lesson.id, { + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + askPassword: single, + siteId, + }); + + lesson = passwordData.lesson || lesson; + let accessInfo = passwordData.accessInfo; + const password = passwordData.password; + + if (AddonModLesson.instance.isLessonOffline(lesson) && !AddonModLesson.instance.leftDuringTimed(accessInfo)) { + // The user didn't left during a timed session. Call launch retake to make sure there is a started retake. + accessInfo = await this.launchRetake(lesson.id, password, modOptions, siteId); + } + + const promises: Promise[] = []; + + // Download intro files and media files. + const files = (lesson.mediafiles || []).concat(this.getIntroFilesFromInstance(module, lesson)); + promises.push(CoreFilepool.instance.addFilesToQueue(siteId, files, this.component, module.id)); + + if (AddonModLesson.instance.isLessonOffline(lesson)) { + promises.push(this.prefetchPlayData(lesson, password, accessInfo.attemptscount, modOptions)); + } + + if (accessInfo.canviewreports) { + promises.push(this.prefetchGroupInfo(module.id, lesson.id, modOptions)); + promises.push(this.prefetchReportsData(module.id, lesson.id, modOptions)); + } + + await Promise.all(promises); + } + + /** + * Launch a retake and return the updated access information. + * + * @param lessonId Lesson ID. + * @param password Password (if needed). + * @param modOptions Options. + * @param siteId Site ID. + */ + protected async launchRetake( + lessonId: number, + password: string | undefined, + modOptions: CoreCourseCommonModWSOptions, + siteId: string, + ): Promise { + // The user didn't left during a timed session. Call launch retake to make sure there is a started retake. + await AddonModLesson.instance.launchRetake(lessonId, password, undefined, false, siteId); + + const results = await Promise.all([ + CoreUtils.instance.ignoreErrors(CoreFilepool.instance.updatePackageDownloadTime(siteId, this.component, module.id)), + AddonModLesson.instance.getAccessInformation(lessonId, modOptions), + ]); + + return results[1]; + } + + /** + * Prefetch data to play the lesson in offline. + * + * @param lesson Lesson. + * @param password Password (if needed). + * @param retake Retake to prefetch. + * @param options Options. + * @return Promise resolved when done. + */ + protected async prefetchPlayData( + lesson: AddonModLessonLessonWSData, + password: string | undefined, + retake: number, + modOptions: CoreCourseCommonModWSOptions, + ): Promise { + const passwordOptions = { + password, + ...modOptions, // Include all mod options. + }; + + await Promise.all([ + this.prefetchPagesData(lesson, passwordOptions), + // Prefetch user timers to be able to calculate timemodified in offline. + CoreUtils.instance.ignoreErrors(AddonModLesson.instance.getTimers(lesson.id, modOptions)), + // Prefetch viewed pages in last retake to calculate progress. + AddonModLesson.instance.getContentPagesViewedOnline(lesson.id, retake, modOptions), + // Prefetch question attempts in last retake for offline calculations. + AddonModLesson.instance.getQuestionsAttemptsOnline(lesson.id, retake, modOptions), + ]); + } + + /** + * Prefetch data related to pages. + * + * @param lesson Lesson. + * @param options Options. + * @return Promise resolved when done. + */ + protected async prefetchPagesData( + lesson: AddonModLessonLessonWSData, + options: AddonModLessonPasswordOptions, + ): Promise { + const pages = await AddonModLesson.instance.getPages(lesson.id, options); + + let hasRandomBranch = false; + + // Get the data for each page. + const promises = pages.map(async (data) => { + // Check if any page has a RANDOMBRANCH jump. + if (!hasRandomBranch) { + for (let i = 0; i < data.jumps.length; i++) { + if (data.jumps[i] == AddonModLessonProvider.LESSON_RANDOMBRANCH) { + hasRandomBranch = true; + break; + } + } + } + + // Get the page data. We don't pass accessInfo because we don't need to calculate the offline data. + const pageData = await AddonModLesson.instance.getPageData(lesson, data.page.id, { + includeContents: true, + includeOfflineData: false, + ...options, // Include all options. + }); + + // Download the page files. + let pageFiles = pageData.contentfiles || []; + + pageData.answers.forEach((answer) => { + pageFiles = pageFiles.concat(answer.answerfiles); + pageFiles = pageFiles.concat(answer.responsefiles); + }); + + await CoreFilepool.instance.addFilesToQueue(options.siteId!, pageFiles, this.component, module.id); + }); + + // Prefetch the list of possible jumps for offline navigation. Do it here because we know hasRandomBranch. + promises.push(this.prefetchPossibleJumps(lesson.id, hasRandomBranch, options)); + + await Promise.all(promises); + } + + /** + * Prefetch possible jumps. + * + * @param lessonId Lesson ID. + * @param hasRandomBranch Whether any page has a random branch jump. + * @param modOptions Options. + * @return Promise resolved when done. + */ + protected async prefetchPossibleJumps( + lessonId: number, + hasRandomBranch: boolean, + modOptions: CoreCourseCommonModWSOptions, + ): Promise { + try { + await AddonModLesson.instance.getPagesPossibleJumps(lessonId, modOptions); + } catch (error) { + if (hasRandomBranch) { + // The WebSevice probably failed because RANDOMBRANCH aren't supported if the user hasn't seen any page. + throw new CoreError(Translate.instance.instant('addon.mod_lesson.errorprefetchrandombranch')); + } + + throw error; + } + } + + /** + * Prefetch group info. + * + * @param moduleId Module ID. + * @param lessonId Lesson ID. + * @param modOptions Options. + * @return Promise resolved when done. + */ + protected async prefetchGroupInfo( + moduleId: number, + lessonId: number, + modOptions: CoreCourseCommonModWSOptions, + ): Promise { + const groupInfo = await CoreGroups.instance.getActivityGroupInfo(moduleId, false, undefined, modOptions.siteId, true); + + await Promise.all(groupInfo.groups?.map(async (group) => { + await AddonModLesson.instance.getRetakesOverview(lessonId, { + groupId: group.id, + ...modOptions, // Include all options. + }); + }) || []); + } + + /** + * Prefetch reports data. + * + * @param moduleId Module ID. + * @param lessonId Lesson ID. + * @param modOptions Options. + * @return Promise resolved when done. + */ + protected async prefetchReportsData( + moduleId: number, + lessonId: number, + modOptions: CoreCourseCommonModWSOptions, + ): Promise { + // Always get all participants, even if there are no groups. + const data = await AddonModLesson.instance.getRetakesOverview(lessonId, modOptions); + if (!data || !data.students) { + return; + } + + // Prefetch the last retake for each user. + await Promise.all(data.students.map(async (student) => { + const lastRetake = student.attempts?.[student.attempts.length - 1]; + if (!lastRetake) { + return; + } + + const attempt = await AddonModLesson.instance.getUserRetake(lessonId, lastRetake.try, { + userId: student.id, + ...modOptions, // Include all options. + }); + + if (!attempt?.answerpages) { + return; + } + + // Download embedded files in essays. + const files: CoreWSExternalFile[] = []; + attempt.answerpages.forEach((answerPage) => { + if (!answerPage.page || answerPage.page.qtype != AddonModLessonProvider.LESSON_PAGE_ESSAY) { + return; + } + + answerPage.answerdata?.answers?.forEach((answer) => { + files.push(...CoreFilepool.instance.extractDownloadableFilesFromHtmlAsFakeFileObjects(answer[0])); + }); + }); + + await CoreFilepool.instance.addFilesToQueue(modOptions.siteId!, files, this.component, moduleId); + })); + } + + /** + * Validate the password. + * + * @param lessonId Lesson ID. + * @param info Lesson access info. + * @param pwd Password to check. + * @param options Other options. + * @return Promise resolved when done. + */ + protected async validatePassword( + lessonId: number, + accessInfo: AddonModLessonGetAccessInformationWSResponse, + password: string, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + + options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId(); + + const lesson = await AddonModLesson.instance.getLessonWithPassword(lessonId, { + password, + ...options, // Include all options. + }); + + // Password is ok, store it and return the data. + await AddonModLesson.instance.storePassword(lesson.id, password, options.siteId); + + return { + password, + lesson, + accessInfo, + }; + } + + /** + * Sync a module. + * + * @param module Module. + * @param courseId Course ID the module belongs to + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + sync(module: CoreCourseAnyModuleData, courseId: number, siteId?: string): Promise { + return AddonModLessonSync.instance.syncLesson(module.instance!, false, false, siteId); + } + +} + +export class AddonModLessonPrefetchHandler extends makeSingleton(AddonModLessonPrefetchHandlerService) {} + +/** + * Options to pass to get lesson password. + */ +export type AddonModLessonGetPasswordOptions = CoreCourseCommonModWSOptions & { + askPassword?: boolean; // True if we should ask for password if needed, false otherwise. +}; + +/** + * Result of getLessonPassword. + */ +export type AddonModLessonGetPasswordResult = { + password?: string; + lesson?: AddonModLessonLessonWSData; + accessInfo: AddonModLessonGetAccessInformationWSResponse; +}; diff --git a/src/addons/mod/lesson/services/handlers/sync-cron.ts b/src/addons/mod/lesson/services/handlers/sync-cron.ts new file mode 100644 index 000000000..c2dfce3ca --- /dev/null +++ b/src/addons/mod/lesson/services/handlers/sync-cron.ts @@ -0,0 +1,52 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; + +import { CoreCronHandler } from '@services/cron'; +import { makeSingleton } from '@singletons'; +import { AddonModLessonSync } from '../lesson-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModLessonSyncCronHandlerService implements CoreCronHandler { + + name = 'AddonModLessonSyncCronHandler'; + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param siteId ID of the site affected, undefined for all sites. + * @param force Wether the execution is forced (manual sync). + * @return Promise resolved when done, rejected if failure. + */ + execute(siteId?: string, force?: boolean): Promise { + return AddonModLessonSync.instance.syncAllLessons(siteId, force); + } + + /** + * Get the time between consecutive executions. + * + * @return Time between consecutive executions (in ms). + */ + getInterval(): number { + return AddonModLessonSync.instance.syncInterval; + } + +} + +export class AddonModLessonSyncCronHandler extends makeSingleton(AddonModLessonSyncCronHandlerService) {} diff --git a/src/addons/mod/lesson/services/lesson-sync.ts b/src/addons/mod/lesson/services/lesson-sync.ts new file mode 100644 index 000000000..48034d781 --- /dev/null +++ b/src/addons/mod/lesson/services/lesson-sync.ts @@ -0,0 +1,518 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; + +import { CoreError } from '@classes/errors/error'; +import { CoreNetworkError } from '@classes/errors/network-error'; +import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreApp } from '@services/app'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreEvents, CoreEventSiteData } from '@singletons/events'; +import { AddonModLessonRetakeFinishedInSyncDBRecord, RETAKES_FINISHED_SYNC_TABLE_NAME } from './database/lesson'; +import { AddonModLessonGetPasswordResult, AddonModLessonPrefetchHandler } from './handlers/prefetch'; +import { AddonModLesson, AddonModLessonLessonWSData, AddonModLessonProvider } from './lesson'; +import { AddonModLessonOffline, AddonModLessonPageAttemptRecord } from './lesson-offline'; + +/** + * Service to sync lesson. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvider { + + static readonly AUTO_SYNCED = 'addon_mod_lesson_autom_synced'; + + protected componentTranslate?: string; + + constructor() { + super('AddonModLessonSyncProvider'); + } + + /** + * Unmark a retake as finished in a synchronization. + * + * @param lessonId Lesson ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteRetakeFinishedInSync(lessonId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + // Ignore errors, maybe there is none. + await CoreUtils.instance.ignoreErrors(site.getDb().deleteRecords(RETAKES_FINISHED_SYNC_TABLE_NAME, { lessonid: lessonId })); + } + + /** + * Get a retake finished in a synchronization for a certain lesson (if any). + * + * @param lessonId Lesson ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the retake entry (undefined if no retake). + */ + async getRetakeFinishedInSync( + lessonId: number, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return CoreUtils.instance.ignoreErrors(site.getDb().getRecord(RETAKES_FINISHED_SYNC_TABLE_NAME, { lessonid: lessonId })); + } + + /** + * Check if a lesson has data to synchronize. + * + * @param lessonId Lesson ID. + * @param retake Retake number. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether it has data to sync. + */ + async hasDataToSync(lessonId: number, retake: number, siteId?: string): Promise { + + const [hasAttempts, hasFinished] = await Promise.all([ + CoreUtils.instance.ignoreErrors(AddonModLessonOffline.instance.hasRetakeAttempts(lessonId, retake, siteId)), + CoreUtils.instance.ignoreErrors(AddonModLessonOffline.instance.hasFinishedRetake(lessonId, siteId)), + ]); + + return !!(hasAttempts || hasFinished); + } + + /** + * Mark a retake as finished in a synchronization. + * + * @param lessonId Lesson ID. + * @param retake The retake number. + * @param pageId The page ID to start reviewing from. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async setRetakeFinishedInSync(lessonId: number, retake: number, pageId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().insertRecord(RETAKES_FINISHED_SYNC_TABLE_NAME, { + lessonid: lessonId, + retake: Number(retake), + pageid: Number(pageId), + timefinished: CoreTimeUtils.instance.timestamp(), + }); + } + + /** + * Try to synchronize all the lessons in a certain site or in all sites. + * + * @param siteId Site ID to sync. If not defined, sync all sites. + * @param force Wether to force sync not depending on last execution. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllLessons(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all lessons', this.syncAllLessonsFunc.bind(this, !!force), siteId); + } + + /** + * Sync all lessons on a site. + * + * @param force Wether to force sync not depending on last execution. + * @param siteId Site ID to sync. + * @param Promise resolved if sync is successful, rejected if sync fails. + */ + protected async syncAllLessonsFunc(force: boolean, siteId: string): Promise { + // Get all the lessons that have something to be synchronized. + const lessons = await AddonModLessonOffline.instance.getAllLessonsWithData(siteId); + + // Sync all lessons that need it. + await Promise.all(lessons.map(async (lesson) => { + const result = force ? + await this.syncLesson(lesson.id, false, false, siteId) : + await this.syncLessonIfNeeded(lesson.id, false, siteId); + + if (result?.updated) { + // Sync successful, send event. + CoreEvents.trigger(AddonModLessonSyncProvider.AUTO_SYNCED, { + lessonId: lesson.id, + warnings: result.warnings, + }, siteId); + } + })); + } + + /** + * Sync a lesson only if a certain time has passed since the last time. + * + * @param lessonId Lesson ID. + * @param askPreflight Whether we should ask for password if needed. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the lesson is synced or if it doesn't need to be synced. + */ + async syncLessonIfNeeded( + lessonId: number, + askPassword?: boolean, + siteId?: string, + ): Promise { + const needed = await this.isSyncNeeded(lessonId, siteId); + + if (needed) { + return this.syncLesson(lessonId, askPassword, false, siteId); + } + } + + /** + * Try to synchronize a lesson. + * + * @param lessonId Lesson ID. + * @param askPassword True if we should ask for password if needed, false otherwise. + * @param ignoreBlock True to ignore the sync block setting. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success. + */ + async syncLesson( + lessonId: number, + askPassword?: boolean, + ignoreBlock?: boolean, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + this.componentTranslate = this.componentTranslate || CoreCourse.instance.translateModuleName('lesson'); + + let syncPromise = this.getOngoingSync(lessonId, siteId); + if (syncPromise) { + // There's already a sync ongoing for this lesson, return the promise. + return syncPromise; + } + + // Verify that lesson isn't blocked. + if (!ignoreBlock && CoreSync.instance.isBlocked(AddonModLessonProvider.COMPONENT, lessonId, siteId)) { + this.logger.debug('Cannot sync lesson ' + lessonId + ' because it is blocked.'); + + throw new CoreError(Translate.instance.instant('core.errorsyncblocked', { $a: this.componentTranslate })); + } + + this.logger.debug('Try to sync lesson ' + lessonId + ' in site ' + siteId); + + syncPromise = this.performSyncLesson(lessonId, askPassword, ignoreBlock, siteId); + + return this.addOngoingSync(lessonId, syncPromise, siteId); + } + + /** + * Try to synchronize a lesson. + * + * @param lessonId Lesson ID. + * @param askPassword True if we should ask for password if needed, false otherwise. + * @param ignoreBlock True to ignore the sync block setting. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success. + */ + protected async performSyncLesson( + lessonId: number, + askPassword?: boolean, + ignoreBlock?: boolean, + siteId?: string, + ): Promise { + // Sync offline logs. + await CoreUtils.instance.ignoreErrors( + CoreCourseLogHelper.instance.syncActivity(AddonModLessonProvider.COMPONENT, lessonId, siteId), + ); + + const result: AddonModLessonSyncResult = { + warnings: [], + updated: false, + }; + + // Try to synchronize the page attempts first. + const passwordData = await this.syncAttempts(lessonId, result, askPassword, siteId); + + // Now sync the retake. + await this.syncRetake(lessonId, result, passwordData, askPassword, ignoreBlock, siteId); + + if (result.updated && result.courseId) { + try { + // Data has been sent to server, update data. + const module = await CoreCourse.instance.getModuleBasicInfoByInstance(lessonId, 'lesson', siteId); + await this.prefetchAfterUpdate(AddonModLessonPrefetchHandler.instance, module, result.courseId, undefined, siteId); + } catch { + // Ignore errors. + } + } + + // Sync finished, set sync time. + await CoreUtils.instance.ignoreErrors(this.setSyncTime(String(lessonId), siteId)); + + // All done, return the result. + return result; + } + + /** + * Sync all page attempts. + * + * @param lessonId Lesson ID. + * @param result Sync result where to store the result. + * @param askPassword True if we should ask for password if needed, false otherwise. + * @param siteId Site ID. If not defined, current site. + */ + protected async syncAttempts( + lessonId: number, + result: AddonModLessonSyncResult, + askPassword?: boolean, + siteId?: string, + ): Promise { + let attempts = await AddonModLessonOffline.instance.getLessonAttempts(lessonId, siteId); + + if (!attempts.length) { + return; + } else if (!CoreApp.instance.isOnline()) { + // Cannot sync in offline. + throw new CoreNetworkError(); + } + + result.courseId = attempts[0].courseid; + const attemptsLength = attempts.length; + + // Get the info, access info and the lesson password if needed. + const lesson = await AddonModLesson.instance.getLessonById(result.courseId, lessonId, { siteId }); + + const passwordData = await AddonModLessonPrefetchHandler.instance.getLessonPassword(lessonId, { + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + askPassword, + siteId, + }); + + const promises: Promise[] = []; + passwordData.lesson = passwordData.lesson || lesson; + + // Filter the attempts, get only the ones that belong to the current retake. + attempts = attempts.filter((attempt) => { + if (attempt.retake == passwordData.accessInfo.attemptscount) { + return true; + } + + // Attempt doesn't belong to current retake, delete. + promises.push(CoreUtils.instance.ignoreErrors(AddonModLessonOffline.instance.deleteAttempt( + lesson.id, + attempt.retake, + attempt.pageid, + attempt.timemodified, + siteId, + ))); + + return false; + }); + + if (attempts.length != attemptsLength) { + // Some attempts won't be sent, add a warning. + result.warnings.push(Translate.instance.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: lesson.name, + error: Translate.instance.instant('addon.mod_lesson.warningretakefinished'), + })); + } + + await Promise.all(promises); + + if (!attempts.length) { + return passwordData; + } + + // Send the attempts in the same order they were answered. + attempts.sort((a, b) => a.timemodified - b.timemodified); + + const promisesData = attempts.map((attempt) => ({ + function: this.sendAttempt.bind(this, lesson, passwordData.password, attempt, result, siteId), + blocking: true, + })); + + await CoreUtils.instance.executeOrderedPromises(promisesData); + + return passwordData; + } + + /** + * Send an attempt to the site and delete it afterwards. + * + * @param lesson Lesson. + * @param password Password (if any). + * @param attempt Attempt to send. + * @param result Result where to store the data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected async sendAttempt( + lesson: AddonModLessonLessonWSData, + password: string, + attempt: AddonModLessonPageAttemptRecord, + result: AddonModLessonSyncResult, + siteId?: string, + ): Promise { + const retake = attempt.retake; + const pageId = attempt.pageid; + const timemodified = attempt.timemodified; + + try { + // Send the page data. + await AddonModLesson.instance.processPageOnline(lesson.id, attempt.pageid, attempt.data || {}, { + password, + siteId, + }); + + result.updated = true; + + await AddonModLessonOffline.instance.deleteAttempt(lesson.id, retake, pageId, timemodified, siteId); + } catch (error) { + if (!error || !CoreUtils.instance.isWebServiceError(error)) { + // Couldn't connect to server. + throw error; + } + + // The WebService has thrown an error, this means that the attempt cannot be submitted. Delete it. + result.updated = true; + + await AddonModLessonOffline.instance.deleteAttempt(lesson.id, retake, pageId, timemodified, siteId); + + // Attempt deleted, add a warning. + result.warnings.push(Translate.instance.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: lesson.name, + error: CoreTextUtils.instance.getErrorMessageFromError(error), + })); + } + } + + /** + * Sync retake. + * + * @param lessonId Lesson ID. + * @param result Sync result where to store the result. + * @param passwordData Password data. If not provided it will be calculated. + * @param askPassword True if we should ask for password if needed, false otherwise. + * @param ignoreBlock True to ignore the sync block setting. + * @param siteId Site ID. If not defined, current site. + */ + protected async syncRetake( + lessonId: number, + result: AddonModLessonSyncResult, + passwordData?: AddonModLessonGetPasswordResult, + askPassword?: boolean, + ignoreBlock?: boolean, + siteId?: string, + ): Promise { + // Attempts sent or there was none. If there is a finished retake, send it. + const retake = await CoreUtils.instance.ignoreErrors(AddonModLessonOffline.instance.getRetake(lessonId, siteId)); + + if (!retake) { + // No retake to sync. + return; + } + + if (!retake.finished) { + // The retake isn't marked as finished, nothing to send. Delete the retake. + await AddonModLessonOffline.instance.deleteRetake(lessonId, siteId); + + return; + } else if (!CoreApp.instance.isOnline()) { + // Cannot sync in offline. + throw new CoreNetworkError(); + } + + result.courseId = retake.courseid || result.courseId; + + if (!passwordData?.lesson) { + // Retrieve the needed data. + const lesson = await AddonModLesson.instance.getLessonById(result.courseId!, lessonId, { siteId }); + passwordData = await AddonModLessonPrefetchHandler.instance.getLessonPassword(lessonId, { + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + askPassword, + siteId, + }); + + passwordData.lesson = passwordData.lesson || lesson; + } + + if (retake.retake != passwordData.accessInfo.attemptscount) { + // The retake changed, add a warning if it isn't there already. + if (!result.warnings.length) { + result.warnings.push(Translate.instance.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: passwordData.lesson.name, + error: Translate.instance.instant('addon.mod_lesson.warningretakefinished'), + })); + } + + await AddonModLessonOffline.instance.deleteRetake(lessonId, siteId); + } + + try { + // All good, finish the retake. + const response = await AddonModLesson.instance.finishRetakeOnline(lessonId, { + password: passwordData.password, + siteId, + }); + + result.updated = true; + + // Mark the retake as finished in a sync if it can be reviewed. + if (!ignoreBlock && response.data?.reviewlesson) { + const params = CoreUrlUtils.instance.extractUrlParams( response.data.reviewlesson.value); + if (params.pageid) { + // The retake can be reviewed, mark it as finished. Don't block the user for this. + this.setRetakeFinishedInSync(lessonId, retake.retake, Number(params.pageid), siteId); + } + } + + await AddonModLessonOffline.instance.deleteRetake(lessonId, siteId); + } catch (error) { + if (!error || !CoreUtils.instance.isWebServiceError(error)) { + // Couldn't connect to server. + throw error; + } + + // The WebService has thrown an error, this means that responses cannot be submitted. Delete them. + result.updated = true; + + await AddonModLessonOffline.instance.deleteRetake(lessonId, siteId); + + // Retake deleted, add a warning. + result.warnings.push(Translate.instance.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: passwordData.lesson.name, + error: CoreTextUtils.instance.getErrorMessageFromError(error), + })); + } + } + +} + +export class AddonModLessonSync extends makeSingleton(AddonModLessonSyncProvider) {} + +/** + * Data returned by a lesson sync. + */ +export type AddonModLessonSyncResult = { + warnings: string[]; // List of warnings. + updated: boolean; // Whether some data was sent to the server or offline data was updated. + courseId?: number; // Course the lesson belongs to (if known). +}; + +/** + * Data passed to AUTO_SYNCED event. + */ +export type AddonModLessonAutoSyncData = CoreEventSiteData & { + lessonId: number; + warnings: string[]; +}; From 80c8ee8cc3cea4eac0836dfd364f4ede49dc10c1 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 1 Feb 2021 12:32:10 +0100 Subject: [PATCH 03/10] MOBILE-3648 core: Implement tabs component without router --- src/core/classes/tabs.ts | 623 ++++++++++++++++ src/core/components/components.module.ts | 6 + .../tabs-outlet/core-tabs-outlet.html | 31 + .../components/tabs-outlet/tabs-outlet.ts | 176 +++++ src/core/components/tabs/core-tabs.html | 59 +- src/core/components/tabs/tab.ts | 147 ++++ src/core/components/tabs/tabs.scss | 22 + src/core/components/tabs/tabs.ts | 671 +++--------------- .../features/course/pages/index/index.html | 2 +- src/core/features/course/pages/index/index.ts | 8 +- .../features/mainmenu/pages/home/home.html | 3 +- src/core/features/mainmenu/pages/home/home.ts | 6 +- src/theme/theme.light.scss | 2 +- 13 files changed, 1132 insertions(+), 624 deletions(-) create mode 100644 src/core/classes/tabs.ts create mode 100644 src/core/components/tabs-outlet/core-tabs-outlet.html create mode 100644 src/core/components/tabs-outlet/tabs-outlet.ts create mode 100644 src/core/components/tabs/tab.ts diff --git a/src/core/classes/tabs.ts b/src/core/classes/tabs.ts new file mode 100644 index 000000000..e9a3ee271 --- /dev/null +++ b/src/core/classes/tabs.ts @@ -0,0 +1,623 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + Component, + Input, + Output, + EventEmitter, + OnInit, + OnChanges, + OnDestroy, + AfterViewInit, + ViewChild, + ElementRef, +} from '@angular/core'; +import { IonSlides } from '@ionic/angular'; +import { Subscription } from 'rxjs'; + +import { CoreApp } from '@services/app'; +import { CoreConfig } from '@services/config'; +import { CoreConstants } from '@/core/constants'; +import { Platform, Translate } from '@singletons'; + +/** + * Class to abstract some common code for tabs. + */ +@Component({ + template: '', +}) +export class CoreTabsBaseComponent implements OnInit, AfterViewInit, OnChanges, OnDestroy { + + // Minimum tab's width. + protected static readonly MIN_TAB_WIDTH = 107; + // Max height that allows tab hiding. + protected static readonly MAX_HEIGHT_TO_HIDE_TABS = 768; + + @Input() protected selectedIndex = 0; // Index of the tab to select. + @Input() hideUntil = false; // Determine when should the contents be shown. + @Output() protected ionChange = new EventEmitter(); // Emitted when the tab changes. + + @ViewChild(IonSlides) protected slides?: IonSlides; + + tabs: T[] = []; // List of tabs. + + selected?: string; // Selected tab id. + showPrevButton = false; + showNextButton = false; + maxSlides = 3; + numTabsShown = 0; + direction = 'ltr'; + description = ''; + lastScroll = 0; + slidesOpts = { + initialSlide: 0, + slidesPerView: 3, + centerInsufficientSlides: true, + }; + + protected initialized = false; + protected afterViewInitTriggered = false; + + protected tabBarHeight = 0; + protected tabsElement?: HTMLElement; // The tabs parent element. It's the element that will be "scrolled" to hide tabs. + protected tabBarElement?: HTMLIonTabBarElement; // The top tab bar element. + protected tabsShown = true; + protected resizeFunction?: EventListenerOrEventListenerObject; + protected isDestroyed = false; + protected isCurrentView = true; + protected shouldSlideToInitial = false; // Whether we need to slide to the initial slide because it's out of view. + protected hasSliddenToInitial = false; // Whether we've already slidden to the initial slide or there was no need. + protected selectHistory: string[] = []; + + protected firstSelectedTab?: string; // ID of the first selected tab to control history. + protected unregisterBackButtonAction: any; + protected languageChangedSubscription?: Subscription; + protected isInTransition = false; // Weather Slides is in transition. + protected slidesSwiper: any; // eslint-disable-line @typescript-eslint/no-explicit-any + protected slidesSwiperLoaded = false; + protected scrollListenersSet: Record = {}; // Prevent setting listeners twice. + + constructor( + protected element: ElementRef, + ) { + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.direction = Platform.instance.isRTL ? 'rtl' : 'ltr'; + + // Change the side when the language changes. + this.languageChangedSubscription = Translate.instance.onLangChange.subscribe(() => { + setTimeout(() => { + this.direction = Platform.instance.isRTL ? 'rtl' : 'ltr'; + }); + }); + } + + /** + * View has been initialized. + */ + async ngAfterViewInit(): Promise { + if (this.isDestroyed) { + return; + } + + this.afterViewInitTriggered = true; + this.tabBarElement = this.element.nativeElement.querySelector('ion-tab-bar'); + + this.slidesSwiper = await this.slides?.getSwiper(); + this.slidesSwiper.once('progress', () => { + this.slidesSwiperLoaded = true; + this.calculateSlides(); + }); + + + if (!this.initialized && this.hideUntil) { + // Tabs should be shown, initialize them. + await this.initializeTabs(); + } + + this.resizeFunction = this.windowResized.bind(this); + + window.addEventListener('resize', this.resizeFunction!); + } + + /** + * Calculate the tab bar height. + */ + protected calculateTabBarHeight(): void { + if (!this.tabBarElement) { + return; + } + + this.tabBarHeight = this.tabBarElement.offsetHeight; + + if (this.tabsShown) { + // Smooth translation. + this.tabBarElement.style.top = - this.lastScroll + 'px'; + this.tabBarElement.style.height = 'calc(100% + ' + scroll + 'px'; + } else { + this.tabBarElement.classList.add('tabs-hidden'); + this.tabBarElement.style.top = '0'; + this.tabBarElement.style.height = ''; + } + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(): void { + // Wait for ngAfterViewInit so it works in the case that each tab has its own component. + if (!this.initialized && this.hideUntil && this.afterViewInitTriggered) { + // Tabs should be shown, initialize them. + // Use a setTimeout so child components update their inputs before initializing the tabs. + setTimeout(() => { + this.initializeTabs(); + }); + } + } + + /** + * User entered the page that contains the component. + */ + ionViewDidEnter(): void { + this.isCurrentView = true; + + this.calculateSlides(); + + this.registerBackButtonAction(); + } + + /** + * Register back button action. + */ + protected registerBackButtonAction(): void { + this.unregisterBackButtonAction = CoreApp.instance.registerBackButtonAction(() => { + // The previous page in history is not the last one, we need the previous one. + if (this.selectHistory.length > 1) { + const tabIndex = this.selectHistory[this.selectHistory.length - 2]; + + // Remove curent and previous tabs from history. + this.selectHistory = this.selectHistory.filter((tabId) => this.selected != tabId && tabIndex != tabId); + + this.selectTab(tabIndex); + + return true; + } else if (this.selected != this.firstSelectedTab) { + // All history is gone but we are not in the first selected tab. + this.selectHistory = []; + + this.selectTab(this.firstSelectedTab!); + + return true; + } + + return false; + }, 750); + } + + /** + * User left the page that contains the component. + */ + ionViewDidLeave(): void { + // Unregister the custom back button action for this page + this.unregisterBackButtonAction && this.unregisterBackButtonAction(); + + this.isCurrentView = false; + } + + /** + * Calculate slides. + */ + protected async calculateSlides(): Promise { + if (!this.isCurrentView || !this.initialized) { + // Don't calculate if component isn't in current view, the calculations are wrong. + return; + } + + if (!this.tabsShown) { + if (window.innerHeight >= CoreTabsBaseComponent.MAX_HEIGHT_TO_HIDE_TABS) { + // Ensure tabbar is shown. + this.tabsShown = true; + this.tabBarElement?.classList.remove('tabs-hidden'); + this.lastScroll = 0; + this.calculateTabBarHeight(); + } else { + // Don't recalculate. + return; + } + } + + await this.calculateMaxSlides(); + + this.updateSlides(); + } + + /** + * Get the tab on a index. + * + * @param tabId Tab ID. + * @return Selected tab. + */ + protected getTabIndex(tabId: string): number { + return this.tabs.findIndex((tab) => tabId == tab.id); + } + + /** + * Get the current selected tab. + * + * @return Selected tab. + */ + getSelected(): T | undefined { + const index = this.selected && this.getTabIndex(this.selected); + + return index !== undefined && index >= 0 ? this.tabs[index] : undefined; + } + + /** + * Initialize the tabs, determining the first tab to be shown. + */ + protected async initializeTabs(): Promise { + let selectedTab: T | undefined = this.tabs[this.selectedIndex || 0] || undefined; + + if (!selectedTab || !selectedTab.enabled) { + // The tab is not enabled or not shown. Get the first tab that is enabled. + selectedTab = this.tabs.find((tab) => tab.enabled) || undefined; + } + + if (!selectedTab) { + return; + } + + this.firstSelectedTab = selectedTab.id!; + this.selectTab(this.firstSelectedTab); + + // Setup tab scrolling. + this.calculateTabBarHeight(); + + this.initialized = true; + + // Check which arrows should be shown. + this.calculateSlides(); + } + + /** + * Method executed when the slides are changed. + */ + async slideChanged(): Promise { + if (!this.slidesSwiperLoaded) { + return; + } + + this.isInTransition = false; + const slidesCount = await this.slides?.length() || 0; + if (slidesCount > 0) { + this.showPrevButton = !await this.slides?.isBeginning(); + this.showNextButton = !await this.slides?.isEnd(); + } else { + this.showPrevButton = false; + this.showNextButton = false; + } + + const currentIndex = await this.slides!.getActiveIndex(); + if (this.shouldSlideToInitial && currentIndex != this.selectedIndex) { + // Current tab has changed, don't slide to initial anymore. + this.shouldSlideToInitial = false; + } + } + + /** + * Updates the number of slides to show. + */ + protected async updateSlides(): Promise { + this.numTabsShown = this.tabs.reduce((prev: number, current) => current.enabled ? prev + 1 : prev, 0); + + this.slidesOpts = { ...this.slidesOpts, slidesPerView: Math.min(this.maxSlides, this.numTabsShown) }; + + this.slideChanged(); + + this.calculateTabBarHeight(); + await this.slides!.update(); + + if (!this.hasSliddenToInitial && this.selectedIndex && this.selectedIndex >= this.slidesOpts.slidesPerView) { + this.hasSliddenToInitial = true; + this.shouldSlideToInitial = true; + + setTimeout(() => { + if (this.shouldSlideToInitial) { + this.slides!.slideTo(this.selectedIndex, 0); + this.shouldSlideToInitial = false; + } + }, 400); + + return; + } else if (this.selectedIndex) { + this.hasSliddenToInitial = true; + } + + setTimeout(() => { + this.slideChanged(); // Call slide changed again, sometimes the slide active index takes a while to be updated. + }, 400); + } + + /** + * Calculate the number of slides that can fit on the screen. + */ + protected async calculateMaxSlides(): Promise { + if (!this.slidesSwiperLoaded) { + return; + } + + this.maxSlides = 3; + const width = this.slidesSwiper.width; + if (!width) { + return; + } + + const fontSize = await CoreConfig.instance.get(CoreConstants.SETTINGS_FONT_SIZE, CoreConstants.CONFIG.font_sizes[0]); + + this.maxSlides = Math.floor(width / (fontSize / CoreConstants.CONFIG.font_sizes[0] * CoreTabsBaseComponent.MIN_TAB_WIDTH)); + } + + /** + * Method that shows the next tab. + */ + async slideNext(): Promise { + // Stop if slides are in transition. + if (!this.showNextButton || this.isInTransition) { + return; + } + + if (await this.slides!.isBeginning()) { + // Slide to the second page. + this.slides!.slideTo(this.maxSlides); + } else { + const currentIndex = await this.slides!.getActiveIndex(); + if (typeof currentIndex !== 'undefined') { + const nextSlideIndex = currentIndex + this.maxSlides; + this.isInTransition = true; + if (nextSlideIndex < this.numTabsShown) { + // Slide to the next page. + await this.slides!.slideTo(nextSlideIndex); + } else { + // Slide to the latest slide. + await this.slides!.slideTo(this.numTabsShown - 1); + } + } + + } + } + + /** + * Method that shows the previous tab. + */ + async slidePrev(): Promise { + // Stop if slides are in transition. + if (!this.showPrevButton || this.isInTransition) { + return; + } + + if (await this.slides!.isEnd()) { + this.slides!.slideTo(this.numTabsShown - this.maxSlides * 2); + // Slide to the previous of the latest page. + } else { + const currentIndex = await this.slides!.getActiveIndex(); + if (typeof currentIndex !== 'undefined') { + const prevSlideIndex = currentIndex - this.maxSlides; + this.isInTransition = true; + if (prevSlideIndex >= 0) { + // Slide to the previous page. + await this.slides!.slideTo(prevSlideIndex); + } else { + // Slide to the first page. + await this.slides!.slideTo(0); + } + } + } + } + + /** + * Show or hide the tabs. This is used when the user is scrolling inside a tab. + * + * @param scrollEvent Scroll event to check scroll position. + * @param content Content element to check measures. + */ + showHideTabs(scrollEvent: CustomEvent, content: HTMLElement): void { + if (!this.tabBarElement || !this.tabsElement || !content) { + return; + } + + // Always show on very tall screens. + if (window.innerHeight >= CoreTabsBaseComponent.MAX_HEIGHT_TO_HIDE_TABS) { + return; + } + + if (!this.tabBarHeight && this.tabBarElement.offsetHeight != this.tabBarHeight) { + // Wrong tab height, recalculate it. + this.calculateTabBarHeight(); + } + + if (!this.tabBarHeight) { + // We don't have the tab bar height, this means the tab bar isn't shown. + return; + } + + const scroll = parseInt(scrollEvent.detail.scrollTop, 10); + if (scroll <= 0) { + // Ensure tabbar is shown. + this.tabsElement.style.top = '0'; + this.tabsElement.style.height = ''; + this.tabBarElement.classList.remove('tabs-hidden'); + this.tabsShown = true; + this.lastScroll = 0; + + return; + } + + if (scroll == this.lastScroll) { + // Ensure scroll has been modified to avoid flicks. + return; + } + + if (this.tabsShown && scroll > this.tabBarHeight) { + this.tabsShown = false; + + // Hide tabs. + this.tabBarElement.classList.add('tabs-hidden'); + this.tabsElement.style.top = '0'; + this.tabsElement.style.height = ''; + } else if (!this.tabsShown && scroll <= this.tabBarHeight) { + this.tabsShown = true; + this.tabBarElement.classList.remove('tabs-hidden'); + } + + if (this.tabsShown && content.scrollHeight > content.clientHeight + (this.tabBarHeight - scroll)) { + // Smooth translation. + this.tabsElement.style.top = - scroll + 'px'; + this.tabsElement.style.height = 'calc(100% + ' + scroll + 'px'; + } + // Use lastScroll after moving the tabs to avoid flickering. + this.lastScroll = parseInt(scrollEvent.detail.scrollTop, 10); + } + + /** + * Select a tab by ID. + * + * @param tabId Tab ID. + * @param e Event. + * @return Promise resolved when done. + */ + async selectTab(tabId: string, e?: Event): Promise { + const index = this.tabs.findIndex((tab) => tabId == tab.id); + + return this.selectByIndex(index, e); + } + + /** + * Select a tab by index. + * + * @param index Index to select. + * @param e Event. + * @return Promise resolved when done. + */ + async selectByIndex(index: number, e?: Event): Promise { + if (index < 0 || index >= this.tabs.length) { + if (this.selected) { + // Invalid index do not change tab. + e?.preventDefault(); + e?.stopPropagation(); + + return; + } + + // Index isn't valid, select the first one. + index = 0; + } + + const tabToSelect = this.tabs[index]; + if (!tabToSelect || !tabToSelect.enabled || tabToSelect.id == this.selected) { + // Already selected or not enabled. + e?.preventDefault(); + e?.stopPropagation(); + + return; + } + + if (this.selected) { + await this.slides!.slideTo(index); + } + + const ok = await this.loadTab(tabToSelect); + + if (ok !== false) { + this.selectHistory.push(tabToSelect.id!); + this.selected = tabToSelect.id; + this.selectedIndex = index; + + this.ionChange.emit(tabToSelect); + } + } + + /** + * Load the tab. + * + * @param tabToSelect Tab to load. + * @return Promise resolved with true if tab is successfully loaded. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected async loadTab(tabToSelect: T): Promise { + // Each implementation should override this function. + return true; + } + + /** + * Listen scroll events in an element's inner ion-content (if any). + * + * @param element Element to search ion-content in. + * @param id ID of the tab/page. + * @return Promise resolved when done. + */ + async listenContentScroll(element: HTMLElement, id: number | string): Promise { + const content = element.querySelector('ion-content'); + + if (!content || this.scrollListenersSet[id]) { + return; + } + + const scroll = await content.getScrollElement(); + content.scrollEvents = true; + this.scrollListenersSet[id] = true; + content.addEventListener('ionScroll', (e: CustomEvent): void => { + this.showHideTabs(e, scroll); + }); + } + + /** + * Adapt tabs to a window resize. + */ + protected windowResized(): void { + setTimeout(() => { + this.calculateSlides(); + }); + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.isDestroyed = true; + + if (this.resizeFunction) { + window.removeEventListener('resize', this.resizeFunction); + } + this.languageChangedSubscription?.unsubscribe(); + } + +} + +/** + * Data for each tab. + */ +export type CoreTabBase = { + title: string; // The translatable tab title. + id?: string; // Unique tab id. + class?: string; // Class, if needed. + icon?: string; // The tab icon. + badge?: string; // A badge to add in the tab. + badgeStyle?: string; // The badge color. + enabled?: boolean; // Whether the tab is enabled. +}; diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index 0155f8833..b467f6126 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -32,6 +32,8 @@ import { CoreShowPasswordComponent } from './show-password/show-password'; import { CoreSplitViewComponent } from './split-view/split-view'; import { CoreEmptyBoxComponent } from './empty-box/empty-box'; import { CoreTabsComponent } from './tabs/tabs'; +import { CoreTabComponent } from './tabs/tab'; +import { CoreTabsOutletComponent } from './tabs-outlet/tabs-outlet'; import { CoreInfiniteLoadingComponent } from './infinite-loading/infinite-loading'; import { CoreProgressBarComponent } from './progress-bar/progress-bar'; import { CoreContextMenuComponent } from './context-menu/context-menu'; @@ -61,6 +63,8 @@ import { CorePipesModule } from '@pipes/pipes.module'; CoreSplitViewComponent, CoreEmptyBoxComponent, CoreTabsComponent, + CoreTabComponent, + CoreTabsOutletComponent, CoreInfiniteLoadingComponent, CoreProgressBarComponent, CoreContextMenuComponent, @@ -94,6 +98,8 @@ import { CorePipesModule } from '@pipes/pipes.module'; CoreSplitViewComponent, CoreEmptyBoxComponent, CoreTabsComponent, + CoreTabComponent, + CoreTabsOutletComponent, CoreInfiniteLoadingComponent, CoreProgressBarComponent, CoreContextMenuComponent, diff --git a/src/core/components/tabs-outlet/core-tabs-outlet.html b/src/core/components/tabs-outlet/core-tabs-outlet.html new file mode 100644 index 000000000..58c5efbeb --- /dev/null +++ b/src/core/components/tabs-outlet/core-tabs-outlet.html @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + {{ tab.title | translate}} + {{ tab.badge }} + + + + + + + + + + + diff --git a/src/core/components/tabs-outlet/tabs-outlet.ts b/src/core/components/tabs-outlet/tabs-outlet.ts new file mode 100644 index 000000000..d4df2c488 --- /dev/null +++ b/src/core/components/tabs-outlet/tabs-outlet.ts @@ -0,0 +1,176 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + Component, + Input, + OnInit, + OnChanges, + OnDestroy, + AfterViewInit, + ViewChild, + ElementRef, +} from '@angular/core'; +import { IonTabs } from '@ionic/angular'; +import { Subscription } from 'rxjs'; + +import { CoreUtils } from '@services/utils/utils'; +import { Params } from '@angular/router'; +import { CoreNavBarButtonsComponent } from '../navbar-buttons/navbar-buttons'; +import { CoreDomUtils } from '@services/utils/dom'; +import { StackEvent } from '@ionic/angular/directives/navigation/stack-utils'; +import { CoreNavigator } from '@services/navigator'; +import { CoreTabBase, CoreTabsBaseComponent } from '@classes/tabs'; + +/** + * This component displays some top scrollable tabs that will autohide on vertical scroll. + * Each tab will load a page using Angular router. + * + * Example usage: + * + * + * + * Tab contents will only be shown if that tab is selected. + * + * @todo: Test behaviour when tabs are added late. + * @todo: Test RTL and tab history. + */ +@Component({ + selector: 'core-tabs-outlet', + templateUrl: 'core-tabs-outlet.html', + styleUrls: ['../tabs/tabs.scss'], +}) +export class CoreTabsOutletComponent extends CoreTabsBaseComponent + implements OnInit, AfterViewInit, OnChanges, OnDestroy { + + /** + * Determine tabs layout. + */ + @Input() layout: 'icon-top' | 'icon-start' | 'icon-end' | 'icon-bottom' | 'icon-hide' | 'label-hide' = 'icon-hide'; + @Input() tabs: CoreTabsOutletTab[] = []; + + @ViewChild(IonTabs) protected ionTabs?: IonTabs; + + protected stackEventsSubscription?: Subscription; + + constructor( + element: ElementRef, + ) { + super(element); + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + super.ngOnInit(); + + this.tabs.forEach((tab) => { + this.initTab(tab); + }); + } + + /** + * Init tab info. + * + * @param tab Tab. + */ + protected initTab(tab: CoreTabsOutletTab): void { + tab.id = tab.id || 'core-tab-outlet-' + CoreUtils.instance.getUniqueId('CoreTabsOutletComponent'); + if (typeof tab.enabled == 'undefined') { + tab.enabled = true; + } + } + + /** + * View has been initialized. + */ + async ngAfterViewInit(): Promise { + super.ngAfterViewInit(); + + if (this.isDestroyed) { + return; + } + + this.tabsElement = this.element.nativeElement.querySelector('ion-tabs'); + this.stackEventsSubscription = this.ionTabs?.outlet.stackEvents.subscribe(async (stackEvent: StackEvent) => { + if (!this.isCurrentView) { + return; + } + + this.listenContentScroll(stackEvent.enteringView.element, stackEvent.enteringView.id); + this.showHideNavBarButtons(stackEvent.enteringView.element.tagName); + }); + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(): void { + this.tabs.forEach((tab) => { + this.initTab(tab); + }); + + super.ngOnChanges(); + } + + /** + * Load the tab. + * + * @param tabToSelect Tab to load. + * @return Promise resolved with true if tab is successfully loaded. + */ + protected async loadTab(tabToSelect: CoreTabsOutletTab): Promise { + return CoreNavigator.instance.navigate(tabToSelect.page, { + params: tabToSelect.pageParams, + }); + } + + /** + * Get all child core-navbar-buttons and show or hide depending on the page state. + * We need to use querySelectorAll because ContentChildren doesn't work with ng-template. + * https://github.com/angular/angular/issues/14842 + * + * @param activatedPageName Activated page name. + */ + protected showHideNavBarButtons(activatedPageName: string): void { + const elements = this.ionTabs!.outlet.nativeEl.querySelectorAll('core-navbar-buttons'); + const domUtils = CoreDomUtils.instance; + elements.forEach((element) => { + const instance: CoreNavBarButtonsComponent = domUtils.getInstanceByElement(element); + + if (instance) { + const pagetagName = element.closest('.ion-page')?.tagName; + instance.forceHide(activatedPageName != pagetagName); + } + }); + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + super.ngOnDestroy(); + this.stackEventsSubscription?.unsubscribe(); + } + +} + +/** + * Tab to be displayed in CoreTabsOutlet. + */ +export type CoreTabsOutletTab = CoreTabBase & { + page: string; // Page to navigate to. + pageParams?: Params; // Page params. +}; diff --git a/src/core/components/tabs/core-tabs.html b/src/core/components/tabs/core-tabs.html index 815137ca6..947eff9db 100644 --- a/src/core/components/tabs/core-tabs.html +++ b/src/core/components/tabs/core-tabs.html @@ -1,31 +1,28 @@ - - - - - - - - - - - - - - - {{ tab.title | translate}} - {{ tab.badge }} - - - - - - - - - - - + + + + + + + + + + + + {{ tab.title | translate}} + {{ tab.badge }} + + + + + + + + + +
+ +
diff --git a/src/core/components/tabs/tab.ts b/src/core/components/tabs/tab.ts new file mode 100644 index 000000000..17876ff91 --- /dev/null +++ b/src/core/components/tabs/tab.ts @@ -0,0 +1,147 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, Output, OnInit, OnDestroy, ElementRef, EventEmitter, ContentChild, TemplateRef } from '@angular/core'; +import { CoreTabBase } from '@classes/tabs'; + +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreNavBarButtonsComponent } from '../navbar-buttons/navbar-buttons'; +import { CoreTabsComponent } from './tabs'; + +/** + * A tab to use inside core-tabs. The content of this tab will be displayed when the tab is selected. + * + * You must provide either a title or an icon for the tab. + * + * The tab content MUST be surrounded by ng-template. This component uses ngTemplateOutlet instead of ng-content because the + * latter executes all the code immediately. This means that all the tabs would be initialized as soon as your view is + * loaded, leading to performance issues. + * + * Example usage: + * + * + * + * + * + * + * + * + */ +@Component({ + selector: 'core-tab', + template: '', +}) +export class CoreTabComponent implements OnInit, OnDestroy, CoreTabBase { + + @Input() title!: string; // The tab title. + @Input() icon?: string; // The tab icon. + @Input() badge?: string; // A badge to add in the tab. + @Input() badgeStyle?: string; // The badge color. + @Input() enabled = true; // Whether the tab is enabled. + @Input() class?: string; // Class, if needed. + @Input() set show(val: boolean) { // Whether the tab should be shown. Use a setter to detect changes on the value. + if (typeof val != 'undefined') { + const hasChanged = this.isShown != val; + this.isShown = val; + + if (this.initialized && hasChanged) { + this.tabs.tabVisibilityChanged(); + } + } + } + + @Input() id?: string; // An ID to identify the tab. + @Output() ionSelect: EventEmitter = new EventEmitter(); + + @ContentChild(TemplateRef) template?: TemplateRef; // Template defined by the content. + + element: HTMLElement; // The core-tab element. + loaded = false; + initialized = false; + isShown = true; + tabElement?: HTMLElement | null; + + constructor( + protected tabs: CoreTabsComponent, + element: ElementRef, + ) { + this.element = element.nativeElement; + + this.element.setAttribute('role', 'tabpanel'); + this.element.setAttribute('tabindex', '0'); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.id = this.id || 'core-tab-' + CoreUtils.instance.getUniqueId('CoreTabComponent'); + this.element.setAttribute('aria-labelledby', this.id + '-tab'); + this.element.setAttribute('id', this.id); + + this.tabs.addTab(this); + this.initialized = true; + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.tabs.removeTab(this); + } + + /** + * Select tab. + */ + async selectTab(): Promise { + this.element.classList.add('selected'); + + this.tabElement = this.tabElement || document.getElementById(this.id + '-tab'); + this.tabElement?.setAttribute('aria-selected', 'true'); + + this.loaded = true; + this.ionSelect.emit(this); + this.showHideNavBarButtons(true); + + // Setup tab scrolling. + this.tabs.listenContentScroll(this.element, this.id!); + } + + /** + * Unselect tab. + */ + unselectTab(): void { + this.tabElement?.setAttribute('aria-selected', 'false'); + this.element.classList.remove('selected'); + this.showHideNavBarButtons(false); + } + + /** + * Show all hide all children navbar buttons. + * + * @param show Whether to show or hide the buttons. + */ + protected showHideNavBarButtons(show: boolean): void { + const elements = this.element.querySelectorAll('core-navbar-buttons'); + elements.forEach((element) => { + const instance: CoreNavBarButtonsComponent = CoreDomUtils.instance.getInstanceByElement(element); + + if (instance) { + instance.forceHide(!show); + } + }); + } + +} diff --git a/src/core/components/tabs/tabs.scss b/src/core/components/tabs/tabs.scss index 3c926bc70..52e8a9074 100644 --- a/src/core/components/tabs/tabs.scss +++ b/src/core/components/tabs/tabs.scss @@ -69,4 +69,26 @@ transform: translateY(0) !important; } } + + ::ng-deep { + core-tab, .core-tab { + display: none; + height: 100%; + position: relative; + z-index: 1; + + &.selected { + display: block; + } + + ion-header { + display: none; + } + + .fixed-content, .scroll-content { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + } + } } diff --git a/src/core/components/tabs/tabs.ts b/src/core/components/tabs/tabs.ts index a8eb31188..a06e28af0 100644 --- a/src/core/components/tabs/tabs.ts +++ b/src/core/components/tabs/tabs.ts @@ -15,652 +15,157 @@ import { Component, Input, - Output, - EventEmitter, - OnInit, - OnChanges, - OnDestroy, AfterViewInit, ViewChild, ElementRef, } from '@angular/core'; -import { Platform, IonSlides, IonTabs } from '@ionic/angular'; -import { TranslateService } from '@ngx-translate/core'; -import { Subscription } from 'rxjs'; -import { CoreApp } from '@services/app'; -import { CoreConfig } from '@services/config'; -import { CoreConstants } from '@/core/constants'; -import { CoreUtils } from '@services/utils/utils'; -import { Params } from '@angular/router'; -import { CoreNavBarButtonsComponent } from '../navbar-buttons/navbar-buttons'; -import { CoreDomUtils } from '@services/utils/dom'; -import { StackEvent } from '@ionic/angular/directives/navigation/stack-utils'; -import { CoreNavigator } from '@services/navigator'; + +import { CoreTabsBaseComponent } from '@classes/tabs'; +import { CoreTabComponent } from './tab'; /** * This component displays some top scrollable tabs that will autohide on vertical scroll. + * Unlike core-tabs-outlet, this component does NOT use Angular router. * * Example usage: * - * - * - * Tab contents will only be shown if that tab is selected. - * - * @todo: Test behaviour when tabs are added late. - * @todo: Test RTL and tab history. + * + * + * + * + * + * + * */ @Component({ selector: 'core-tabs', templateUrl: 'core-tabs.html', styleUrls: ['tabs.scss'], }) -export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDestroy { +export class CoreTabsComponent extends CoreTabsBaseComponent implements AfterViewInit { + @Input() parentScrollable = false; // Determine if the scroll should be in the parent content or the tab itself. - // Minimum tab's width to display fully the word "Competencies" which is the longest tab in the app. - protected static readonly MIN_TAB_WIDTH = 107; - // Max height that allows tab hiding. - protected static readonly MAX_HEIGHT_TO_HIDE_TABS = 768; + @ViewChild('originalTabs') originalTabsRef?: ElementRef; - @Input() protected selectedIndex = 0; // Index of the tab to select. - @Input() hideUntil = false; // Determine when should the contents be shown. - /** - * Determine tabs layout. - */ - @Input() layout: 'icon-top' | 'icon-start' | 'icon-end' | 'icon-bottom' | 'icon-hide' | 'label-hide' = 'icon-hide'; - @Input() tabs: CoreTab[] = []; - @Output() protected ionChange: EventEmitter = new EventEmitter(); // Emitted when the tab changes. - - @ViewChild(IonSlides) protected slides?: IonSlides; - @ViewChild(IonTabs) protected ionTabs?: IonTabs; - - selected?: string; // Selected tab id. - showPrevButton = false; - showNextButton = false; - maxSlides = 3; - numTabsShown = 0; - direction = 'ltr'; - description = ''; - lastScroll = 0; - slidesOpts = { - initialSlide: 0, - slidesPerView: 3, - centerInsufficientSlides: true, - }; - - protected initialized = false; - protected afterViewInitTriggered = false; - - protected tabBarHeight = 0; - protected tabBarElement?: HTMLIonTabBarElement; // The top tab bar element. - protected tabsElement?: HTMLIonTabsElement; // The ionTabs native Element. - protected tabsShown = true; - protected resizeFunction?: EventListenerOrEventListenerObject; - protected isDestroyed = false; - protected isCurrentView = true; - protected shouldSlideToInitial = false; // Whether we need to slide to the initial slide because it's out of view. - protected hasSliddenToInitial = false; // Whether we've already slidden to the initial slide or there was no need. - protected selectHistory: string[] = []; - - protected firstSelectedTab?: string; // ID of the first selected tab to control history. - protected unregisterBackButtonAction: any; - protected languageChangedSubscription: Subscription; - protected isInTransition = false; // Weather Slides is in transition. - protected slidesSwiper: any; // eslint-disable-line @typescript-eslint/no-explicit-any - protected slidesSwiperLoaded = false; - protected stackEventsSubscription?: Subscription; + protected originalTabsContainer?: HTMLElement; // The container of the original tabs. It will include each tab's content. constructor( - protected element: ElementRef, - platform: Platform, - translate: TranslateService, + element: ElementRef, ) { - this.direction = platform.isRTL ? 'rtl' : 'ltr'; - - // Change the side when the language changes. - this.languageChangedSubscription = translate.onLangChange.subscribe(() => { - setTimeout(() => { - this.direction = platform.isRTL ? 'rtl' : 'ltr'; - }); - }); - } - - /** - * Component being initialized. - */ - async ngOnInit(): Promise { - this.tabs.forEach((tab) => { - this.initTab(tab); - }); - } - - /** - * Init tab info. - * - * @param tab Tab class. - */ - protected initTab(tab: CoreTab): void { - tab.id = tab.id || 'core-tab-' + CoreUtils.instance.getUniqueId('CoreTabsComponent'); - if (typeof tab.enabled == 'undefined') { - tab.enabled = true; - } + super(element); } /** * View has been initialized. */ async ngAfterViewInit(): Promise { + super.ngAfterViewInit(); + if (this.isDestroyed) { return; } - this.stackEventsSubscription = this.ionTabs!.outlet.stackEvents.subscribe(async (stackEvent: StackEvent) => { - if (this.isCurrentView) { - const content = stackEvent.enteringView.element.querySelector('ion-content'); - - this.showHideNavBarButtons(stackEvent.enteringView.element.tagName); - if (content) { - const scroll = await content.getScrollElement(); - content.scrollEvents = true; - content.addEventListener('ionScroll', (e: CustomEvent): void => { - this.showHideTabs(e, scroll); - }); - } - } - }); - - this.tabBarElement = this.element.nativeElement.querySelector('ion-tab-bar'); - this.tabsElement = this.element.nativeElement.querySelector('ion-tabs'); - - this.slidesSwiper = await this.slides?.getSwiper(); - this.slidesSwiper.once('progress', () => { - this.slidesSwiperLoaded = true; - this.calculateSlides(); - }); - - this.afterViewInitTriggered = true; - - if (!this.initialized && this.hideUntil) { - // Tabs should be shown, initialize them. - await this.initializeTabs(); - } - - this.resizeFunction = this.windowResized.bind(this); - - window.addEventListener('resize', this.resizeFunction!); - } - - /** - * Detect changes on input properties. - */ - ngOnChanges(): void { - this.tabs.forEach((tab) => { - this.initTab(tab); - }); - - // We need to wait for ngAfterViewInit because we need core-tab components to be executed. - if (!this.initialized && this.hideUntil && this.afterViewInitTriggered) { - // Tabs should be shown, initialize them. - // Use a setTimeout so child core-tab update their inputs before initializing the tabs. - setTimeout(() => { - this.initializeTabs(); - }); - } - } - - /** - * User entered the page that contains the component. - */ - ionViewDidEnter(): void { - this.isCurrentView = true; - - this.calculateSlides(); - - this.registerBackButtonAction(); - } - - /** - * Register back button action. - */ - protected registerBackButtonAction(): void { - this.unregisterBackButtonAction = CoreApp.instance.registerBackButtonAction(() => { - // The previous page in history is not the last one, we need the previous one. - if (this.selectHistory.length > 1) { - const tabIndex = this.selectHistory[this.selectHistory.length - 2]; - - // Remove curent and previous tabs from history. - this.selectHistory = this.selectHistory.filter((tabId) => this.selected != tabId && tabIndex != tabId); - - this.selectTab(tabIndex); - - return true; - } else if (this.selected != this.firstSelectedTab) { - // All history is gone but we are not in the first selected tab. - this.selectHistory = []; - - this.selectTab(this.firstSelectedTab!); - - return true; - } - - return false; - }, 750); - } - - /** - * User left the page that contains the component. - */ - ionViewDidLeave(): void { - // Unregister the custom back button action for this page - this.unregisterBackButtonAction && this.unregisterBackButtonAction(); - - this.isCurrentView = false; - } - - /** - * Calculate slides. - */ - protected async calculateSlides(): Promise { - if (!this.isCurrentView || !this.initialized) { - // Don't calculate if component isn't in current view, the calculations are wrong. - return; - } - - if (!this.tabsShown) { - if (window.innerHeight >= CoreTabsComponent.MAX_HEIGHT_TO_HIDE_TABS) { - // Ensure tabbar is shown. - this.tabsShown = true; - this.tabBarElement!.classList.remove('tabs-hidden'); - this.lastScroll = 0; - } - } - - await this.calculateMaxSlides(); - - this.updateSlides(); - } - - /** - * Calculate the tab bar height. - */ - protected calculateTabBarHeight(): void { - if (!this.tabBarElement || !this.tabsElement) { - return; - } - - this.tabBarHeight = this.tabBarElement.offsetHeight; - - if (this.tabsShown) { - // Smooth translation. - this.tabsElement.style.top = - this.lastScroll + 'px'; - this.tabsElement.style.height = 'calc(100% + ' + scroll + 'px'; - } else { - this.tabBarElement.classList.add('tabs-hidden'); - this.tabsElement.style.top = '0'; - this.tabsElement.style.height = ''; - } - } - - /** - * Get the tab on a index. - * - * @param tabId Tab ID. - * @return Selected tab. - */ - protected getTabIndex(tabId: string): number { - return this.tabs.findIndex((tab) => tabId == tab.id); - } - - /** - * Get the current selected tab. - * - * @return Selected tab. - */ - getSelected(): CoreTab | undefined { - const index = this.selected && this.getTabIndex(this.selected); - - return index && index >= 0 ? this.tabs[index] : undefined; + this.tabsElement = this.element.nativeElement; + this.originalTabsContainer = this.originalTabsRef?.nativeElement; } /** * Initialize the tabs, determining the first tab to be shown. */ protected async initializeTabs(): Promise { - let selectedTab: CoreTab | undefined = this.tabs[this.selectedIndex || 0] || undefined; + await super.initializeTabs(); - if (!selectedTab || !selectedTab.enabled) { - // The tab is not enabled or not shown. Get the first tab that is enabled. - selectedTab = this.tabs.find((tab) => tab.enabled) || undefined; + // @todo: Is this still needed? + // if (this.content) { + // if (!this.parentScrollable) { + // // Parent scroll element (if core-tabs is inside a ion-content). + // const scroll = await this.content.getScrollElement(); + // if (scroll) { + // scroll.classList.add('no-scroll'); + // } + // } else { + // this.originalTabsContainer?.classList.add('no-scroll'); + // } + // } + } + + /** + * Add a new tab if it isn't already in the list of tabs. + * + * @param tab The tab to add. + */ + addTab(tab: CoreTabComponent): void { + // Check if tab is already in the list. + if (this.getTabIndex(tab.id!) == -1) { + this.tabs.push(tab); + this.sortTabs(); + + setTimeout(() => { + this.calculateSlides(); + }); + + if (this.initialized && this.tabs.length > 1 && this.tabBarHeight == 0) { + // Calculate the tabBarHeight again now that there is more than 1 tab and the bar will be seen. + // Use timeout to wait for the view to be rendered. 0 ms should be enough, use 50 to be sure. + setTimeout(() => { + this.calculateTabBarHeight(); + }, 50); + } } + } - if (!selectedTab) { - return; - } + /** + * Remove a tab from the list of tabs. + * + * @param tab The tab to remove. + */ + removeTab(tab: CoreTabComponent): void { + const index = this.getTabIndex(tab.id!); + this.tabs.splice(index, 1); - this.firstSelectedTab = selectedTab.id!; - this.selectTab(this.firstSelectedTab); - - // Setup tab scrolling. - this.calculateTabBarHeight(); - - this.initialized = true; - - // Check which arrows should be shown. this.calculateSlides(); } /** - * Method executed when the slides are changed. - */ - async slideChanged(): Promise { - if (!this.slidesSwiperLoaded) { - return; - } - - this.isInTransition = false; - const slidesCount = await this.slides?.length() || 0; - if (slidesCount > 0) { - this.showPrevButton = !await this.slides?.isBeginning(); - this.showNextButton = !await this.slides?.isEnd(); - } else { - this.showPrevButton = false; - this.showNextButton = false; - } - - const currentIndex = await this.slides!.getActiveIndex(); - if (this.shouldSlideToInitial && currentIndex != this.selectedIndex) { - // Current tab has changed, don't slide to initial anymore. - this.shouldSlideToInitial = false; - } - } - - /** - * Updates the number of slides to show. - */ - protected async updateSlides(): Promise { - this.numTabsShown = this.tabs.reduce((prev: number, current: CoreTab) => current.enabled ? prev + 1 : prev, 0); - - this.slidesOpts = { ...this.slidesOpts, slidesPerView: Math.min(this.maxSlides, this.numTabsShown) }; - - this.calculateTabBarHeight(); - await this.slides!.update(); - - if (!this.hasSliddenToInitial && this.selectedIndex && this.selectedIndex >= this.slidesOpts.slidesPerView) { - this.hasSliddenToInitial = true; - this.shouldSlideToInitial = true; - - setTimeout(() => { - if (this.shouldSlideToInitial) { - this.slides!.slideTo(this.selectedIndex, 0); - this.shouldSlideToInitial = false; - } - }, 400); - - return; - } else if (this.selectedIndex) { - this.hasSliddenToInitial = true; - } - - setTimeout(() => { - this.slideChanged(); // Call slide changed again, sometimes the slide active index takes a while to be updated. - }, 400); - } - - /** - * Calculate the number of slides that can fit on the screen. - */ - protected async calculateMaxSlides(): Promise { - if (!this.slidesSwiperLoaded) { - return; - } - - this.maxSlides = 3; - const width = this.slidesSwiper.width; - if (width) { - const fontSize = await - CoreConfig.instance.get(CoreConstants.SETTINGS_FONT_SIZE, CoreConstants.CONFIG.font_sizes[0]); - - this.maxSlides = Math.floor(width / (fontSize / CoreConstants.CONFIG.font_sizes[0] * - CoreTabsComponent.MIN_TAB_WIDTH)); - } - } - - /** - * Method that shows the next tab. - */ - async slideNext(): Promise { - // Stop if slides are in transition. - if (!this.showNextButton || this.isInTransition) { - return; - } - - if (await this.slides!.isBeginning()) { - // Slide to the second page. - this.slides!.slideTo(this.maxSlides); - } else { - const currentIndex = await this.slides!.getActiveIndex(); - if (typeof currentIndex !== 'undefined') { - const nextSlideIndex = currentIndex + this.maxSlides; - this.isInTransition = true; - if (nextSlideIndex < this.numTabsShown) { - // Slide to the next page. - await this.slides!.slideTo(nextSlideIndex); - } else { - // Slide to the latest slide. - await this.slides!.slideTo(this.numTabsShown - 1); - } - } - - } - } - - /** - * Method that shows the previous tab. - */ - async slidePrev(): Promise { - // Stop if slides are in transition. - if (!this.showPrevButton || this.isInTransition) { - return; - } - - if (await this.slides!.isEnd()) { - this.slides!.slideTo(this.numTabsShown - this.maxSlides * 2); - // Slide to the previous of the latest page. - } else { - const currentIndex = await this.slides!.getActiveIndex(); - if (typeof currentIndex !== 'undefined') { - const prevSlideIndex = currentIndex - this.maxSlides; - this.isInTransition = true; - if (prevSlideIndex >= 0) { - // Slide to the previous page. - await this.slides!.slideTo(prevSlideIndex); - } else { - // Slide to the first page. - await this.slides!.slideTo(0); - } - } - } - } - - /** - * Show or hide the tabs. This is used when the user is scrolling inside a tab. + * Load the tab. * - * @param scrollEvent Scroll event to check scroll position. - * @param content Content element to check measures. + * @param tabToSelect Tab to load. + * @return Promise resolved with true if tab is successfully loaded. */ - protected showHideTabs(scrollEvent: CustomEvent, content: HTMLElement): void { - if (!this.tabBarElement || !this.tabsElement || !content) { - return; - } + protected async loadTab(tabToSelect: CoreTabComponent): Promise { + const currentTab = this.getSelected(); + currentTab?.unselectTab(); + tabToSelect.selectTab(); - // Always show on very tall screens. - if (window.innerHeight >= CoreTabsComponent.MAX_HEIGHT_TO_HIDE_TABS) { - return; - } - - if (!this.tabBarHeight && this.tabBarElement.offsetHeight != this.tabBarHeight) { - // Wrong tab height, recalculate it. - this.calculateTabBarHeight(); - } - - if (!this.tabBarHeight) { - // We don't have the tab bar height, this means the tab bar isn't shown. - return; - } - - const scroll = parseInt(scrollEvent.detail.scrollTop, 10); - if (scroll <= 0) { - // Ensure tabbar is shown. - this.tabsElement.style.top = '0'; - this.tabsElement.style.height = ''; - this.tabBarElement!.classList.remove('tabs-hidden'); - this.tabsShown = true; - this.lastScroll = 0; - - return; - } - - if (scroll == this.lastScroll) { - // Ensure scroll has been modified to avoid flicks. - return; - } - - if (this.tabsShown && scroll > this.tabBarHeight) { - this.tabsShown = false; - - // Hide tabs. - this.tabBarElement.classList.add('tabs-hidden'); - this.tabsElement.style.top = '0'; - this.tabsElement.style.height = ''; - } else if (!this.tabsShown && scroll <= this.tabBarHeight) { - this.tabsShown = true; - this.tabBarElement!.classList.remove('tabs-hidden'); - } - - if (this.tabsShown && content.scrollHeight > content.clientHeight + (this.tabBarHeight - scroll)) { - // Smooth translation. - this.tabsElement.style.top = - scroll + 'px'; - this.tabsElement.style.height = 'calc(100% + ' + scroll + 'px'; - } - // Use lastScroll after moving the tabs to avoid flickering. - this.lastScroll = parseInt(scrollEvent.detail.scrollTop, 10); + return true; } /** - * Select a tab by ID. - * - * @param tabId Tab ID. - * @param e Event. - * @return Promise resolved when done. + * Sort the tabs, keeping the same order as in the original list. */ - async selectTab(tabId: string, e?: Event): Promise { - const index = this.tabs.findIndex((tab) => tabId == tab.id); - - return this.selectByIndex(index, e); - } - - /** - * Select a tab by index. - * - * @param index Index to select. - * @param e Event. - * @return Promise resolved when done. - */ - async selectByIndex(index: number, e?: Event): Promise { - if (index < 0 || index >= this.tabs.length) { - if (this.selected) { - // Invalid index do not change tab. - e?.preventDefault(); - e?.stopPropagation(); - - return; - } - - // Index isn't valid, select the first one. - index = 0; - } - - const tabToSelect = this.tabs[index]; - if (!tabToSelect || !tabToSelect.enabled || tabToSelect.id == this.selected) { - // Already selected or not enabled. - e?.preventDefault(); - e?.stopPropagation(); - + protected sortTabs(): void { + if (!this.originalTabsContainer) { return; } - if (this.selected) { - await this.slides!.slideTo(index); - } + const newTabs: CoreTabComponent[] = []; - const ok = await CoreNavigator.instance.navigate(tabToSelect.page, { - params: tabToSelect.pageParams, - }); - - if (ok !== false) { - this.selectHistory.push(tabToSelect.id!); - this.selected = tabToSelect.id; - this.selectedIndex = index; - - this.ionChange.emit(tabToSelect); - } - } - - /** - * Get all child core-navbar-buttons and show or hide depending on the page state. - * We need to use querySelectorAll because ContentChildren doesn't work with ng-template. - * https://github.com/angular/angular/issues/14842 - * - * @param activatedPageName Activated page name. - */ - protected showHideNavBarButtons(activatedPageName: string): void { - const elements = this.ionTabs!.outlet.nativeEl.querySelectorAll('core-navbar-buttons'); - const domUtils = CoreDomUtils.instance; - elements.forEach((element) => { - const instance: CoreNavBarButtonsComponent = domUtils.getInstanceByElement(element); - - if (instance) { - const pagetagName = element.closest('.ion-page')?.tagName; - instance.forceHide(activatedPageName != pagetagName); + this.tabs.forEach((tab) => { + const originalIndex = Array.prototype.indexOf.call(this.originalTabsContainer?.children, tab.element); + if (originalIndex != -1) { + newTabs[originalIndex] = tab; } }); + + this.tabs = newTabs; } /** - * Adapt tabs to a window resize. + * Function to call when the visibility of a tab has changed. */ - protected windowResized(): void { - setTimeout(() => { - this.calculateSlides(); - }); - } - - /** - * Component destroyed. - */ - ngOnDestroy(): void { - this.isDestroyed = true; - - if (this.resizeFunction) { - window.removeEventListener('resize', this.resizeFunction); - } - this.stackEventsSubscription?.unsubscribe(); - this.languageChangedSubscription.unsubscribe(); + tabVisibilityChanged(): void { + this.calculateSlides(); } } - -/** - * Core Tab class. - */ -export type CoreTab = { - page: string; // Page to navigate to. - title: string; // The translatable tab title. - id?: string; // Unique tab id. - class?: string; // Class, if needed. - icon?: string; // The tab icon. - badge?: string; // A badge to add in the tab. - badgeStyle?: string; // The badge color. - enabled?: boolean; // Whether the tab is enabled. - pageParams?: Params; // Page params. -}; diff --git a/src/core/features/course/pages/index/index.html b/src/core/features/course/pages/index/index.html index 221b8b63d..a974e5f75 100644 --- a/src/core/features/course/pages/index/index.html +++ b/src/core/features/course/pages/index/index.html @@ -11,5 +11,5 @@ - + \ No newline at end of file diff --git a/src/core/features/course/pages/index/index.ts b/src/core/features/course/pages/index/index.ts index 27f0a6b42..9c5b834e4 100644 --- a/src/core/features/course/pages/index/index.ts +++ b/src/core/features/course/pages/index/index.ts @@ -15,7 +15,7 @@ import { Component, ViewChild, OnDestroy, OnInit } from '@angular/core'; import { Params } from '@angular/router'; -import { CoreTab, CoreTabsComponent } from '@components/tabs/tabs'; +import { CoreTabsOutletTab, CoreTabsOutletComponent } from '@components/tabs-outlet/tabs-outlet'; import { CoreCourseFormatDelegate } from '../../services/format-delegate'; import { CoreCourseOptionsDelegate } from '../../services/course-options-delegate'; import { CoreCourseAnyCourseData } from '@features/courses/services/courses'; @@ -35,7 +35,7 @@ import { CoreNavigator } from '@services/navigator'; }) export class CoreCourseIndexPage implements OnInit, OnDestroy { - @ViewChild(CoreTabsComponent) tabsComponent?: CoreTabsComponent; + @ViewChild(CoreTabsOutletComponent) tabsComponent?: CoreTabsOutletComponent; title?: string; course?: CoreCourseAnyCourseData; @@ -45,7 +45,7 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy { protected currentPagePath = ''; protected selectTabObserver: CoreEventObserver; protected firstTabName?: string; - protected contentsTab: CoreTab = { + protected contentsTab: CoreTabsOutletTab = { page: 'contents', title: 'core.course.contents', pageParams: {}, @@ -183,6 +183,6 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy { } -type CourseTab = CoreTab & { +type CourseTab = CoreTabsOutletTab & { name?: string; }; diff --git a/src/core/features/mainmenu/pages/home/home.html b/src/core/features/mainmenu/pages/home/home.html index ad107680e..fee27cc92 100644 --- a/src/core/features/mainmenu/pages/home/home.html +++ b/src/core/features/mainmenu/pages/home/home.html @@ -15,7 +15,8 @@ - + + diff --git a/src/core/features/mainmenu/pages/home/home.ts b/src/core/features/mainmenu/pages/home/home.ts index 005951458..1acc0bc13 100644 --- a/src/core/features/mainmenu/pages/home/home.ts +++ b/src/core/features/mainmenu/pages/home/home.ts @@ -17,7 +17,7 @@ import { Subscription } from 'rxjs'; import { CoreSites } from '@services/sites'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; -import { CoreTab, CoreTabsComponent } from '@components/tabs/tabs'; +import { CoreTabsOutletComponent, CoreTabsOutletTab } from '@components/tabs-outlet/tabs-outlet'; import { CoreMainMenuHomeDelegate, CoreMainMenuHomeHandlerToDisplay } from '../../services/home-delegate'; /** @@ -30,10 +30,10 @@ import { CoreMainMenuHomeDelegate, CoreMainMenuHomeHandlerToDisplay } from '../. }) export class CoreMainMenuHomePage implements OnInit { - @ViewChild(CoreTabsComponent) tabsComponent?: CoreTabsComponent; + @ViewChild(CoreTabsOutletComponent) tabsComponent?: CoreTabsOutletComponent; siteName!: string; - tabs: CoreTab[] = []; + tabs: CoreTabsOutletTab[] = []; loaded = false; selectedTab?: number; diff --git a/src/theme/theme.light.scss b/src/theme/theme.light.scss index eb28548b9..40feb3112 100644 --- a/src/theme/theme.light.scss +++ b/src/theme/theme.light.scss @@ -156,7 +156,7 @@ --core-tab-color-active: var(--custom-tab-color-active, var(--core-color)); --core-tab-border-color-active: var(--custom-tab-border-color-active, var(--core-color)); - core-tabs { + core-tabs, core-tabs-outlet { --background: var(--core-tabs-background); ion-slide { --background: var(--core-tab-background); From 4da342befddc2031e0bf91dfc5aa9f6c194644b6 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 2 Feb 2021 08:10:12 +0100 Subject: [PATCH 04/10] MOBILE-3648 navigator: Store params in localStorage in browser --- src/core/services/navigator.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/core/services/navigator.ts b/src/core/services/navigator.ts index 79e32efcd..1789848fa 100644 --- a/src/core/services/navigator.ts +++ b/src/core/services/navigator.ts @@ -29,6 +29,7 @@ import { CoreTextUtils } from '@services/utils/text'; import { makeSingleton, NavController, Router } from '@singletons'; import { CoreScreen } from './screen'; import { filter } from 'rxjs/operators'; +import { CoreApp } from './app'; const DEFAULT_MAIN_MENU_TAB = CoreMainMenuHomeHandlerService.PAGE_NAME; @@ -255,10 +256,19 @@ export class CoreNavigatorService { value = params[name]; } - const storedParam = this.storedParams[value]; + let storedParam = this.storedParams[value]; + // Remove the parameter from our map if it's in there. delete this.storedParams[value]; + if (!CoreApp.instance.isMobile() && !storedParam) { + // Try to retrieve the param from local storage in browser. + const storageParam = localStorage.getItem(value); + if (storageParam) { + storedParam = CoreTextUtils.instance.parseJSON(storageParam); + } + } + return storedParam ?? value; } @@ -368,6 +378,11 @@ export class CoreNavigatorService { const id = this.getNewParamId(); this.storedParams[id] = value; queryParams[name] = id; + + if (!CoreApp.instance.isMobile()) { + // In browser, save the param in local storage to be able to retrieve it if the app is refreshed. + localStorage.setItem(id, JSON.stringify(value)); + } } } From 71bcb07c744cab7e8b1464617b3f40ccdfe28942 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 2 Feb 2021 08:10:58 +0100 Subject: [PATCH 05/10] MOBILE-3648 lesson: Implement lesson index page --- .../lesson/components/components.module.ts | 7 + .../index/addon-mod-lesson-index.html | 309 ++++++++ .../mod/lesson/components/index/index.ts | 719 ++++++++++++++++++ .../password-modal/password-modal.ts | 3 +- src/addons/mod/lesson/lesson-lazy.module.ts | 33 + src/addons/mod/lesson/lesson.module.ts | 13 + src/addons/mod/lesson/pages/index/index.html | 23 + .../mod/lesson/pages/index/index.module.ts | 46 ++ src/addons/mod/lesson/pages/index/index.ts | 73 ++ .../mod/lesson/services/handlers/module.ts | 104 +++ src/core/classes/tabs.ts | 14 +- .../course/classes/main-resource-component.ts | 2 +- .../features/course/services/course-helper.ts | 2 +- src/core/features/course/services/course.ts | 2 +- .../services/handlers/default-module.ts | 6 +- .../course/services/module-delegate.ts | 12 +- .../mainmenu/mainmenu-tab-routing.module.ts | 3 + 17 files changed, 1350 insertions(+), 21 deletions(-) create mode 100644 src/addons/mod/lesson/components/index/addon-mod-lesson-index.html create mode 100644 src/addons/mod/lesson/components/index/index.ts create mode 100644 src/addons/mod/lesson/lesson-lazy.module.ts create mode 100644 src/addons/mod/lesson/pages/index/index.html create mode 100644 src/addons/mod/lesson/pages/index/index.module.ts create mode 100644 src/addons/mod/lesson/pages/index/index.ts create mode 100644 src/addons/mod/lesson/services/handlers/module.ts diff --git a/src/addons/mod/lesson/components/components.module.ts b/src/addons/mod/lesson/components/components.module.ts index 8999caf02..91abab0d8 100644 --- a/src/addons/mod/lesson/components/components.module.ts +++ b/src/addons/mod/lesson/components/components.module.ts @@ -14,25 +14,32 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; import { IonicModule } from '@ionic/angular'; import { TranslateModule } from '@ngx-translate/core'; import { CoreSharedModule } from '@/core/shared.module'; +import { CoreCourseComponentsModule } from '@features/course/components/components.module'; +import { AddonModLessonIndexComponent } from './index/index'; import { AddonModLessonPasswordModalComponent } from './password-modal/password-modal'; @NgModule({ declarations: [ + AddonModLessonIndexComponent, AddonModLessonPasswordModalComponent, ], imports: [ CommonModule, IonicModule, TranslateModule.forChild(), + FormsModule, CoreSharedModule, + CoreCourseComponentsModule, ], providers: [ ], exports: [ + AddonModLessonIndexComponent, AddonModLessonPasswordModalComponent, ], }) diff --git a/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html b/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html new file mode 100644 index 000000000..1b760bbed --- /dev/null +++ b/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html @@ -0,0 +1,309 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ 'core.hasdatatosync' | translate: {$a: moduleName} }} + + + + + +
+ + {{ 'addon.mod_lesson.enterpassword' | translate }} + + + + + + + {{ 'addon.mod_lesson.continue' | translate }} + + + + +
+
+ + + + + + + {{ 'addon.mod_lesson.retakefinishedinsync' | translate }} + + {{ 'addon.mod_lesson.review' | translate }} + + + + + + +

+ + + + + {{ 'core.no' | translate }} + + + + + {{ 'core.yes' | translate }} + + + + +
+
+ + + + + + {{ 'addon.mod_lesson.continue' | translate }} + + + + + + + + + + + + + {{ 'core.start' | translate }} + + + + {{ 'addon.mod_lesson.preview' | translate }} + + + + + + + {{ 'addon.mod_lesson.continue' | translate }} + + +
+
+
+
+
+ + + + + + + + + {{ 'core.groupsseparate' | translate }} + {{ 'core.groupsvisible' | translate }} + + + {{groupOpt.name}} + + + + + + + + + + + + + {{ 'addon.mod_lesson.lessonstats' | translate }} + + + +
+ + + +

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

+

+ {{ 'core.percentagenumber' | translate:{$a: overview.avescore} }} +

+

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

+
+ + +

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

+

+ {{ 'core.percentagenumber' | translate:{$a: overview.highscore} }} +

+

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

+
+ + +

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

+

+ {{ 'core.percentagenumber' | translate:{$a: overview.lowscore} }} +

+

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

+
+
+
+ + + + +

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

+

{{ avetimeReadable }}

+

+ {{ 'addon.mod_lesson.notcompleted' | translate }} +

+
+ + +

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

+

{{ hightimeReadable }}

+

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

+
+ + +

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

+

{{ lowtimeReadable }}

+

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

+
+
+
+
+ + +
+ + + +

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

+

+ {{ 'core.percentagenumber' | translate:{$a: overview.avescore} }} +

+

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

+
+ + +

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

+

{{ avetimeReadable }}

+

+ {{ 'addon.mod_lesson.notcompleted' | translate }} +

+
+
+
+ + + + +

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

+

+ {{ 'core.percentagenumber' | translate:{$a: overview.highscore} }} +

+

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

+
+ + +

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

+

{{ hightimeReadable }}

+

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

+
+
+
+ + + + +

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

+

+ {{ 'core.percentagenumber' | translate:{$a: overview.lowscore} }} +

+

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

+
+ + +

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

+

{{ lowtimeReadable }}

+

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

+
+
+
+
+
+ + + + + {{ 'addon.mod_lesson.overview' | translate }} + + + + + + +

{{ student.fullname }}

+ +
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/src/addons/mod/lesson/components/index/index.ts b/src/addons/mod/lesson/components/index/index.ts new file mode 100644 index 000000000..88d4f67ec --- /dev/null +++ b/src/addons/mod/lesson/components/index/index.ts @@ -0,0 +1,719 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreConstants } from '@/core/constants'; +import { Component, Input, ViewChild, ElementRef, OnInit, OnDestroy, Optional } from '@angular/core'; + +import { CoreTabsComponent } from '@components/tabs/tabs'; +import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; +import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreUser } from '@features/user/services/user'; +import { IonContent, IonInput } from '@ionic/angular'; +import { CoreGroupInfo, CoreGroups } from '@services/groups'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { AddonModLessonRetakeFinishedInSyncDBRecord } from '../../services/database/lesson'; +import { AddonModLessonPrefetchHandler } from '../../services/handlers/prefetch'; +import { + AddonModLesson, + AddonModLessonAttemptsOverviewsStudentWSData, + AddonModLessonAttemptsOverviewWSData, + AddonModLessonDataSentData, + AddonModLessonGetAccessInformationWSResponse, + AddonModLessonLessonWSData, + AddonModLessonPreventAccessReason, + AddonModLessonProvider, +} from '../../services/lesson'; +import { AddonModLessonOffline } from '../../services/lesson-offline'; +import { + AddonModLessonAutoSyncData, + AddonModLessonSync, + AddonModLessonSyncProvider, + AddonModLessonSyncResult, +} from '../../services/lesson-sync'; + +/** + * Component that displays a lesson entry page. + */ +@Component({ + selector: 'addon-mod-lesson-index', + templateUrl: 'addon-mod-lesson-index.html', +}) +export class AddonModLessonIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit, OnDestroy { + + @ViewChild(CoreTabsComponent) tabsComponent?: CoreTabsComponent; + @ViewChild('passwordForm') formElement?: ElementRef; + + @Input() group = 0; // The group to display. + @Input() action?: string; // The "action" to display first. + + component = AddonModLessonProvider.COMPONENT; + moduleName = 'lesson'; + + lesson?: AddonModLessonLessonWSData; // The lesson. + selectedTab?: number; // The initial selected tab. + askPassword?: boolean; // Whether to ask the password. + canManage?: boolean; // Whether the user can manage the lesson. + canViewReports?: boolean; // Whether the user can view the lesson reports. + showSpinner?: boolean; // Whether to display a spinner. + hasOffline?: boolean; // Whether there's offline data. + retakeToReview?: AddonModLessonRetakeFinishedInSyncDBRecord; // A retake to review. + preventReasons: AddonModLessonPreventAccessReason[] = []; // List of reasons that prevent the lesson from being seen. + leftDuringTimed?: boolean; // Whether the user has started and left a retake. + groupInfo?: CoreGroupInfo; // The group info. + reportLoaded?: boolean; // Whether the report data has been loaded. + selectedGroupName?: string; // The name of the selected group. + overview?: AttemptsOverview; // Reports overview data. + finishedOffline?: boolean; // Whether a retake was finished in offline. + avetimeReadable?: string; // Average time in a readable format. + hightimeReadable?: string; // High time in a readable format. + lowtimeReadable?: string; // Low time in a readable format. + + protected syncEventName = AddonModLessonSyncProvider.AUTO_SYNCED; + protected accessInfo?: AddonModLessonGetAccessInformationWSResponse; // Lesson access info. + protected password?: string; // The password for the lesson. + protected hasPlayed = false; // Whether the user has gone to the lesson player (attempted). + protected dataSentObserver?: CoreEventObserver; // To detect data sent to server. + protected dataSent = false; // Whether some data was sent to server while playing the lesson. + + constructor( + protected content?: IonContent, + @Optional() courseContentsPage?: CoreCourseContentsPage, + ) { + super('AddonModLessonIndexComponent', content, courseContentsPage); + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + super.ngOnInit(); + + this.selectedTab = this.action == 'report' ? 1 : 0; + + await this.loadContent(false, true); + + if (!this.lesson || this.preventReasons.length) { + return; + } + + this.logView(); + } + + /** + * Change the group displayed. + * + * @param groupId Group ID to display. + * @return Promise resolved when done. + */ + async changeGroup(groupId: number): Promise { + this.reportLoaded = false; + + try { + await this.setGroup(groupId); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting report.'); + } finally { + this.reportLoaded = true; + } + } + + /** + * Get the lesson data. + * + * @param refresh If it's refreshing content. + * @param sync If it should try to sync. + * @param showErrors If show errors to the user of hide them. + * @return Promise resolved when done. + */ + protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { + try { + let lessonReady = true; + this.askPassword = false; + + this.lesson = await AddonModLesson.instance.getLesson(this.courseId!, this.module!.id); + + this.dataRetrieved.emit(this.lesson); + this.description = this.lesson.intro; // Show description only if intro is present. + + if (sync) { + // Try to synchronize the lesson. + await this.syncActivity(showErrors); + } + + this.accessInfo = await AddonModLesson.instance.getAccessInformation(this.lesson.id, { cmId: this.module!.id }); + this.canManage = this.accessInfo.canmanage; + this.canViewReports = this.accessInfo.canviewreports; + this.preventReasons = []; + const promises: Promise[] = []; + + if (AddonModLesson.instance.isLessonOffline(this.lesson)) { + // Handle status. + this.setStatusListener(); + + promises.push(this.loadOfflineData()); + } + + if (this.accessInfo.preventaccessreasons.length) { + let preventReason = AddonModLesson.instance.getPreventAccessReason(this.accessInfo, false); + const askPassword = preventReason?.reason == 'passwordprotectedlesson'; + + if (askPassword) { + try { + // The lesson requires a password. Check if there is one in memory or DB. + const password = this.password ? + this.password : + await AddonModLesson.instance.getStoredPassword(this.lesson.id); + + await this.validatePassword(password); + + // Now that we have the password, get the access reason again ignoring the password. + preventReason = AddonModLesson.instance.getPreventAccessReason(this.accessInfo, true); + if (preventReason) { + this.preventReasons = [preventReason]; + } + } catch { + // No password or the validation failed. Show password form. + this.askPassword = true; + this.preventReasons = [preventReason!]; + lessonReady = false; + } + } else { + // Lesson cannot be started. + this.preventReasons = [preventReason!]; + lessonReady = false; + } + } + + if (this.selectedTab == 1 && this.canViewReports) { + // Only fetch the report data if the tab is selected. + promises.push(this.fetchReportData()); + } + + await Promise.all(promises); + + if (lessonReady) { + // Lesson can be started, don't ask the password and don't show prevent messages. + this.lessonReady(); + } + } finally { + this.fillContextMenu(refresh); + } + } + + /** + * Load offline data for the lesson. + * + * @return Promise resolved when done. + */ + protected async loadOfflineData(): Promise { + if (!this.lesson || !this.accessInfo) { + return; + } + + const promises: Promise[] = []; + const options = { cmId: this.module!.id }; + + // Check if there is offline data. + promises.push(AddonModLessonSync.instance.hasDataToSync(this.lesson.id, this.accessInfo.attemptscount).then((hasData) => { + this.hasOffline = hasData; + + return; + })); + + // Check if there is a retake finished in a synchronization. + promises.push(AddonModLessonSync.instance.getRetakeFinishedInSync(this.lesson.id).then((retake) => { + if (retake && retake.retake == this.accessInfo!.attemptscount - 1) { + // The retake finished is still the last retake. Allow reviewing it. + this.retakeToReview = retake; + } else { + this.retakeToReview = undefined; + if (retake) { + AddonModLessonSync.instance.deleteRetakeFinishedInSync(this.lesson!.id); + } + } + + return; + })); + + // Check if the ser has a finished retake in offline. + promises.push(AddonModLessonOffline.instance.hasFinishedRetake(this.lesson.id).then((finished) => { + this.finishedOffline = finished; + + return; + })); + + // Update the list of content pages viewed and question attempts. + promises.push(AddonModLesson.instance.getContentPagesViewedOnline(this.lesson.id, this.accessInfo.attemptscount, options)); + promises.push(AddonModLesson.instance.getQuestionsAttemptsOnline(this.lesson.id, this.accessInfo.attemptscount, options)); + + await Promise.all(promises); + } + + /** + * Fetch the reports data. + * + * @return Promise resolved when done. + */ + protected async fetchReportData(): Promise { + if (!this.module) { + return; + } + + try { + this.groupInfo = await CoreGroups.instance.getActivityGroupInfo(this.module.id); + + await this.setGroup(CoreGroups.instance.validateGroupId(this.group, this.groupInfo)); + } finally { + this.reportLoaded = true; + } + } + + /** + * Checks if sync has succeed from result sync data. + * + * @param result Data returned on the sync function. + * @return If suceed or not. + */ + protected hasSyncSucceed(result: AddonModLessonSyncResult): boolean { + if (result.updated || this.dataSent) { + // Check completion status if something was sent. + CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata); + } + + this.dataSent = false; + + return result.updated; + } + + /** + * User entered the page that contains the component. + */ + ionViewDidEnter(): void { + super.ionViewDidEnter(); + + this.tabsComponent?.ionViewDidEnter(); + + if (!this.hasPlayed) { + return; + } + + // Update data when we come back from the player since the status could have changed. + this.hasPlayed = false; + this.dataSentObserver?.off(); // Stop listening for changes. + this.dataSentObserver = undefined; + + // Refresh data. + this.showLoadingAndRefresh(true, false); + } + + /** + * User left the page that contains the component. + */ + ionViewDidLeave(): void { + super.ionViewDidLeave(); + + this.tabsComponent?.ionViewDidLeave(); + + // @todo if (this.navCtrl.getActive().component.name != 'AddonModLessonPlayerPage') { + // return; + // } + + // Detect if anything was sent to server. + this.hasPlayed = true; + this.dataSentObserver?.off(); + + this.dataSentObserver = CoreEvents.on(AddonModLessonProvider.DATA_SENT_EVENT, (data) => { + // Ignore launch sending because it only affects timers. + if (data.lessonId === this.lesson?.id && data.type != 'launch') { + this.dataSent = true; + } + }, this.siteId); + } + + /** + * Perform the invalidate content function. + * + * @return Promise resolved when done. + */ + protected async invalidateContent(): Promise { + const promises: Promise[] = []; + + promises.push(AddonModLesson.instance.invalidateLessonData(this.courseId!)); + + if (this.lesson) { + promises.push(AddonModLesson.instance.invalidateAccessInformation(this.lesson.id)); + promises.push(AddonModLesson.instance.invalidatePages(this.lesson.id)); + promises.push(AddonModLesson.instance.invalidateLessonWithPassword(this.lesson.id)); + promises.push(AddonModLesson.instance.invalidateTimers(this.lesson.id)); + promises.push(AddonModLesson.instance.invalidateContentPagesViewed(this.lesson.id)); + promises.push(AddonModLesson.instance.invalidateQuestionsAttempts(this.lesson.id)); + promises.push(AddonModLesson.instance.invalidateRetakesOverview(this.lesson.id)); + if (this.module) { + promises.push(CoreGroups.instance.invalidateActivityGroupInfo(this.module.id)); + } + } + + await Promise.all(promises); + } + + /** + * Compares sync event data with current data to check if refresh content is needed. + * + * @param syncEventData Data receiven on sync observer. + * @return True if refresh is needed, false otherwise. + */ + protected isRefreshSyncNeeded(syncEventData: AddonModLessonAutoSyncData): boolean { + return !!(this.lesson && syncEventData.lessonId == this.lesson.id); + } + + /** + * Function called when the lesson is ready to be seen (no pending prevent access reasons). + */ + protected lessonReady(): void { + this.askPassword = false; + this.leftDuringTimed = this.hasOffline || AddonModLesson.instance.leftDuringTimed(this.accessInfo); + + if (this.password) { + // Store the password in DB. + AddonModLesson.instance.storePassword(this.lesson!.id, this.password); + } + } + + /** + * Log viewing the lesson. + * + * @return Promise resolved when done. + */ + protected async logView(): Promise { + if (!this.lesson) { + return; + } + + await CoreUtils.instance.ignoreErrors( + AddonModLesson.instance.logViewLesson(this.lesson.id, this.password, this.lesson.name), + ); + + CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata); + } + + /** + * Open the lesson player. + * + * @param continueLast Whether to continue the last retake. + * @return Promise resolved when done. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected async playLesson(continueLast?: boolean): Promise { + if (!this.lesson || !this.accessInfo) { + return; + } + + // @todo + // Calculate the pageId to load. If there is timelimit, lesson is always restarted from the start. + // let pageId: number | undefined; + + // if (this.hasOffline) { + // if (continueLast) { + // pageId = await AddonModLesson.instance.getLastPageSeen(this.lesson.id, this.accessInfo.attemptscount, { + // cmId: this.module!.id, + // }); + // } else { + // pageId = this.accessInfo.firstpageid; + // } + // } else if (this.leftDuringTimed && !this.lesson.timelimit) { + // pageId = continueLast ? this.accessInfo.lastpageseen : this.accessInfo.firstpageid; + // } + + // this.navCtrl.push('AddonModLessonPlayerPage', { + // courseId: this.courseId, + // lessonId: this.lesson.id, + // pageId: pageId, + // password: this.password, + // }); + } + + /** + * First tab selected. + */ + indexSelected(): void { + this.selectedTab = 0; + } + + /** + * Reports tab selected. + */ + reportsSelected(): void { + this.selectedTab = 1; + + if (!this.groupInfo) { + this.fetchReportData().catch((error) => { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting report.'); + }); + } + } + + /** + * Review the lesson. + */ + review(): void { + if (!this.retakeToReview) { + // No retake to review, stop. + return; + } + + // @todo this.navCtrl.push('AddonModLessonPlayerPage', { + // courseId: this.courseId, + // lessonId: this.lesson.id, + // pageId: this.retakeToReview.pageid, + // password: this.password, + // review: true, + // retake: this.retakeToReview.retake + // }); + } + + /** + * Set a group to view the reports. + * + * @param groupId Group ID. + * @return Promise resolved when done. + */ + async setGroup(groupId: number): Promise { + if (!this.lesson) { + return; + } + + this.group = groupId; + this.selectedGroupName = ''; + + // Search the name of the group if it isn't all participants. + if (groupId && this.groupInfo && this.groupInfo.groups) { + const group = this.groupInfo.groups.find(group => groupId == group.id); + this.selectedGroupName = group?.name || ''; + } + + // Get the overview of retakes for the group. + const data = await AddonModLesson.instance.getRetakesOverview(this.lesson.id, { + groupId, + cmId: this.lesson.coursemodule, + }); + + if (!data) { + this.overview = data; + + return; + } + + const formattedData = data; + + // Format times and grades. + if (formattedData.avetime != null && formattedData.numofattempts) { + formattedData.avetime = Math.floor(formattedData.avetime / formattedData.numofattempts); + this.avetimeReadable = CoreTimeUtils.instance.formatTime(formattedData.avetime); + } + + if (formattedData.hightime != null) { + this.hightimeReadable = CoreTimeUtils.instance.formatTime(formattedData.hightime); + } + + if (formattedData.lowtime != null) { + this.lowtimeReadable = CoreTimeUtils.instance.formatTime(formattedData.lowtime); + } + + if (formattedData.lessonscored) { + if (formattedData.numofattempts) { + formattedData.avescore = CoreTextUtils.instance.roundToDecimals(formattedData.avescore, 2); + } + if (formattedData.highscore != null) { + formattedData.highscore = CoreTextUtils.instance.roundToDecimals(formattedData.highscore, 2); + } + if (formattedData.lowscore != null) { + formattedData.lowscore = CoreTextUtils.instance.roundToDecimals(formattedData.lowscore, 2); + } + } + + if (formattedData.students) { + // Get the user data for each student returned. + await CoreUtils.instance.allPromises(formattedData.students.map(async (student) => { + student.bestgrade = CoreTextUtils.instance.roundToDecimals(student.bestgrade, 2); + + const user = await CoreUtils.instance.ignoreErrors(CoreUser.instance.getProfile(student.id, this.courseId, true)); + if (user) { + student.profileimageurl = user.profileimageurl; + } + })); + } + + this.overview = formattedData; + } + + /** + * Displays some data based on the current status. + * + * @param status The current status. + * @param previousStatus The previous status. If not defined, there is no previous status. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected showStatus(status: string, previousStatus?: string): void { + this.showSpinner = status == CoreConstants.DOWNLOADING; + } + + /** + * Start the lesson. + * + * @param continueLast Whether to continue the last attempt. + */ + async start(continueLast?: boolean): Promise { + if (this.showSpinner || !this.lesson) { + // Lesson is being downloaded or not retrieved, abort. + return; + } + + if (!AddonModLesson.instance.isLessonOffline(this.lesson) || this.currentStatus == CoreConstants.DOWNLOADED) { + // Not downloadable or already downloaded, open it. + this.playLesson(continueLast); + + return; + } + + // Lesson supports offline and isn't downloaded, download it. + this.showSpinner = true; + + try { + await AddonModLessonPrefetchHandler.instance.prefetch(this.module!, this.courseId, true); + + // Success downloading, open lesson. + this.playLesson(continueLast); + } catch (error) { + if (this.hasOffline) { + // Error downloading but there is something offline, allow continuing it. + this.playLesson(continueLast); + } else { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.errordownloading', true); + } + } finally { + this.showSpinner = false; + } + } + + /** + * Submit password for password protected lessons. + * + * @param e Event. + * @param passwordEl The password input. + */ + async submitPassword(e: Event, passwordEl: IonInput): Promise { + e.preventDefault(); + e.stopPropagation(); + + const password = passwordEl?.value; + if (!password) { + CoreDomUtils.instance.showErrorModal('addon.mod_lesson.emptypassword', true); + + return; + } + + this.loaded = false; + this.refreshIcon = 'spinner'; + this.syncIcon = 'spinner'; + + try { + await this.validatePassword( password); + + // Password validated. + this.lessonReady(); + + // Now that we have the password, get the access reason again ignoring the password. + const preventReason = AddonModLesson.instance.getPreventAccessReason(this.accessInfo!, true); + this.preventReasons = preventReason ? [preventReason] : []; + + // Log view now that we have the password. + this.logView(); + } catch (error) { + CoreDomUtils.instance.showErrorModal(error); + } finally { + this.loaded = true; + this.refreshIcon = 'refresh'; + this.syncIcon = 'sync'; + + CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, true, this.siteId); + } + } + + /** + * Performs the sync of the activity. + * + * @return Promise resolved when done. + */ + protected async sync(): Promise { + const result = await AddonModLessonSync.instance.syncLesson(this.lesson!.id, true); + + if (!result.updated && this.dataSent && this.isPrefetched()) { + // The user sent data to server, but not in the sync process. Check if we need to fetch data. + await CoreUtils.instance.ignoreErrors(AddonModLessonSync.instance.prefetchAfterUpdate( + AddonModLessonPrefetchHandler.instance, + this.module!, + this.courseId!, + )); + } + + return result; + } + + /** + * Validate a password and retrieve extra data. + * + * @param password The password to validate. + * @return Promise resolved when done. + */ + protected async validatePassword(password: string): Promise { + try { + this.lesson = await AddonModLesson.instance.getLessonWithPassword(this.lesson!.id, { password, cmId: this.module!.id }); + + this.password = password; + } catch (error) { + this.password = ''; + + throw error; + } + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + super.ngOnDestroy(); + + this.dataSentObserver?.off(); + } + +} + +/** + * Overview data including user avatars, calculated in this component. + */ +type AttemptsOverview = Omit & { + students?: StudentWithImage[]; +}; + +/** + * Overview student data with the avatar, calculated in this component. + */ +type StudentWithImage = AddonModLessonAttemptsOverviewsStudentWSData & { + profileimageurl?: string; +}; diff --git a/src/addons/mod/lesson/components/password-modal/password-modal.ts b/src/addons/mod/lesson/components/password-modal/password-modal.ts index 746e525b2..f92c513db 100644 --- a/src/addons/mod/lesson/components/password-modal/password-modal.ts +++ b/src/addons/mod/lesson/components/password-modal/password-modal.ts @@ -13,6 +13,7 @@ // limitations under the License. import { Component, ViewChild, ElementRef } from '@angular/core'; +import { IonInput } from '@ionic/angular'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; @@ -36,7 +37,7 @@ export class AddonModLessonPasswordModalComponent { * @param e Event. * @param password The input element. */ - submitPassword(e: Event, password: HTMLInputElement): void { + submitPassword(e: Event, password: IonInput): void { e.preventDefault(); e.stopPropagation(); diff --git a/src/addons/mod/lesson/lesson-lazy.module.ts b/src/addons/mod/lesson/lesson-lazy.module.ts new file mode 100644 index 000000000..9fb389ac3 --- /dev/null +++ b/src/addons/mod/lesson/lesson-lazy.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +const routes: Routes = [ + { + path: '', + redirectTo: 'index', + pathMatch: 'full', + }, + { + path: 'index', + loadChildren: () => import('./pages/index/index.module').then( m => m.AddonModLessonIndexPageModule), + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], +}) +export class AddonModLessonLazyModule {} diff --git a/src/addons/mod/lesson/lesson.module.ts b/src/addons/mod/lesson/lesson.module.ts index 9b0785854..39a7ca269 100644 --- a/src/addons/mod/lesson/lesson.module.ts +++ b/src/addons/mod/lesson/lesson.module.ts @@ -13,17 +13,29 @@ // limitations under the License. import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { Routes } from '@angular/router'; +import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; import { CoreCronDelegate } from '@services/cron'; import { CORE_SITE_SCHEMAS } from '@services/sites'; import { AddonModLessonComponentsModule } from './components/components.module'; import { SITE_SCHEMA, OFFLINE_SITE_SCHEMA } from './services/database/lesson'; +import { AddonModLessonModuleHandler, AddonModLessonModuleHandlerService } from './services/handlers/module'; import { AddonModLessonPrefetchHandler } from './services/handlers/prefetch'; import { AddonModLessonSyncCronHandler } from './services/handlers/sync-cron'; +const routes: Routes = [ + { + path: AddonModLessonModuleHandlerService.PAGE_NAME, + loadChildren: () => import('./lesson-lazy.module').then(m => m.AddonModLessonLazyModule), + }, +]; + @NgModule({ imports: [ + CoreMainMenuTabRoutingModule.forChild(routes), AddonModLessonComponentsModule, ], providers: [ @@ -37,6 +49,7 @@ import { AddonModLessonSyncCronHandler } from './services/handlers/sync-cron'; multi: true, deps: [], useFactory: () => () => { + CoreCourseModuleDelegate.instance.registerHandler(AddonModLessonModuleHandler.instance); CoreCourseModulePrefetchDelegate.instance.registerHandler(AddonModLessonPrefetchHandler.instance); CoreCronDelegate.instance.register(AddonModLessonSyncCronHandler.instance); }, diff --git a/src/addons/mod/lesson/pages/index/index.html b/src/addons/mod/lesson/pages/index/index.html new file mode 100644 index 000000000..25eef8568 --- /dev/null +++ b/src/addons/mod/lesson/pages/index/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/addons/mod/lesson/pages/index/index.module.ts b/src/addons/mod/lesson/pages/index/index.module.ts new file mode 100644 index 000000000..ab65bd8f2 --- /dev/null +++ b/src/addons/mod/lesson/pages/index/index.module.ts @@ -0,0 +1,46 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonModLessonComponentsModule } from '../../components/components.module'; +import { AddonModLessonIndexPage } from './index'; + +const routes: Routes = [ + { + path: '', + component: AddonModLessonIndexPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreSharedModule, + AddonModLessonComponentsModule, + ], + declarations: [ + AddonModLessonIndexPage, + ], + exports: [RouterModule], +}) +export class AddonModLessonIndexPageModule {} diff --git a/src/addons/mod/lesson/pages/index/index.ts b/src/addons/mod/lesson/pages/index/index.ts new file mode 100644 index 000000000..976f7c627 --- /dev/null +++ b/src/addons/mod/lesson/pages/index/index.ts @@ -0,0 +1,73 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, ViewChild } from '@angular/core'; + +import { CoreCourseWSModule } from '@features/course/services/course'; +import { CoreNavigator } from '@services/navigator'; +import { AddonModLessonIndexComponent } from '../../components/index/index'; +import { AddonModLessonLessonWSData } from '../../services/lesson'; + +/** + * Page that displays the lesson entry page. + */ +@Component({ + selector: 'page-addon-mod-lesson-index', + templateUrl: 'index.html', +}) +export class AddonModLessonIndexPage implements OnInit { + + @ViewChild(AddonModLessonIndexComponent) lessonComponent?: AddonModLessonIndexComponent; + + title?: string; + module?: CoreCourseWSModule; + courseId?: number; + group?: number; // The group to display. + action?: string; // The "action" to display first. + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.module = CoreNavigator.instance.getRouteParam('module'); + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId'); + this.group = CoreNavigator.instance.getRouteNumberParam('group'); + this.action = CoreNavigator.instance.getRouteParam('action'); + this.title = this.module?.name; + } + + /** + * Update some data based on the lesson instance. + * + * @param lesson Lesson instance. + */ + updateData(lesson: AddonModLessonLessonWSData): void { + this.title = lesson.name || this.title; + } + + /** + * User entered the page. + */ + ionViewDidEnter(): void { + this.lessonComponent?.ionViewDidEnter(); + } + + /** + * User left the page. + */ + ionViewDidLeave(): void { + this.lessonComponent?.ionViewDidLeave(); + } + +} diff --git a/src/addons/mod/lesson/services/handlers/module.ts b/src/addons/mod/lesson/services/handlers/module.ts new file mode 100644 index 000000000..e32e1af54 --- /dev/null +++ b/src/addons/mod/lesson/services/handlers/module.ts @@ -0,0 +1,104 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Type } from '@angular/core'; + +import { CoreConstants } from '@/core/constants'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate'; +import { CoreCourse, CoreCourseAnyModuleData, CoreCourseWSModule } from '@features/course/services/course'; +import { CoreCourseModule } from '@features/course/services/course-helper'; +import { AddonModLesson } from '../lesson'; +import { AddonModLessonIndexComponent } from '../../components/index'; +import { CoreCourseAnyCourseData } from '@features/courses/services/courses'; +import { CoreNavigationOptions, CoreNavigator } from '@services/navigator'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support quiz modules. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModLessonModuleHandlerService implements CoreCourseModuleHandler { + + static readonly PAGE_NAME = 'lesson'; + + name = 'AddonModLesson'; + modName = 'lesson'; + + supportedFeatures = { + [CoreConstants.FEATURE_GROUPS]: true, + [CoreConstants.FEATURE_GROUPINGS]: true, + [CoreConstants.FEATURE_MOD_INTRO]: true, + [CoreConstants.FEATURE_COMPLETION_TRACKS_VIEWS]: true, + [CoreConstants.FEATURE_COMPLETION_HAS_RULES]: true, + [CoreConstants.FEATURE_GRADE_HAS_GRADE]: true, + [CoreConstants.FEATURE_GRADE_OUTCOMES]: true, + [CoreConstants.FEATURE_BACKUP_MOODLE2]: true, + [CoreConstants.FEATURE_SHOW_DESCRIPTION]: true, + }; + + /** + * Check if the handler is enabled on a site level. + * + * @return Promise resolved with boolean: whether or not the handler is enabled on a site level. + */ + isEnabled(): Promise { + return AddonModLesson.instance.isPluginEnabled(); + } + + /** + * Get the data required to display the module in the course contents view. + * + * @param module The module object. + * @param courseId The course ID. + * @param sectionId The section ID. + * @param forCoursePage Whether the data will be used to render the course page. + * @return Data to render the module. + */ + getData( + module: CoreCourseAnyModuleData, + courseId: number, // eslint-disable-line @typescript-eslint/no-unused-vars + sectionId: number, // eslint-disable-line @typescript-eslint/no-unused-vars + forCoursePage?: boolean, // eslint-disable-line @typescript-eslint/no-unused-vars + ): CoreCourseModuleHandlerData { + return { + icon: CoreCourse.instance.getModuleIconSrc(this.modName, 'modicon' in module ? module.modicon : undefined), + title: module.name, + class: 'addon-mod_lesson-handler', + showDownloadButton: true, + action: (event: Event, module: CoreCourseModule, courseId: number, options?: CoreNavigationOptions) => { + options = options || {}; + options.params = options.params || {}; + Object.assign(options.params, { module, courseId }); + + CoreNavigator.instance.navigateToSitePath(AddonModLessonModuleHandlerService.PAGE_NAME, options); + }, + }; + } + + /** + * Get the component to render the module. This is needed to support singleactivity course format. + * The component returned must implement CoreCourseModuleMainComponent. + * + * @param course The course object. + * @param module The module object. + * @return The component to use, undefined if not found. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getMainComponent(course: CoreCourseAnyCourseData, module: CoreCourseWSModule): Promise | undefined> { + return AddonModLessonIndexComponent; + } + +} + +export class AddonModLessonModuleHandler extends makeSingleton(AddonModLessonModuleHandlerService) {} diff --git a/src/core/classes/tabs.ts b/src/core/classes/tabs.ts index e9a3ee271..55836e270 100644 --- a/src/core/classes/tabs.ts +++ b/src/core/classes/tabs.ts @@ -119,13 +119,6 @@ export class CoreTabsBaseComponent implements OnInit, Aft this.afterViewInitTriggered = true; this.tabBarElement = this.element.nativeElement.querySelector('ion-tab-bar'); - this.slidesSwiper = await this.slides?.getSwiper(); - this.slidesSwiper.once('progress', () => { - this.slidesSwiperLoaded = true; - this.calculateSlides(); - }); - - if (!this.initialized && this.hideUntil) { // Tabs should be shown, initialize them. await this.initializeTabs(); @@ -272,6 +265,13 @@ export class CoreTabsBaseComponent implements OnInit, Aft * Initialize the tabs, determining the first tab to be shown. */ protected async initializeTabs(): Promise { + // Initialize slider. + this.slidesSwiper = await this.slides?.getSwiper(); + this.slidesSwiper.once('progress', () => { + this.slidesSwiperLoaded = true; + this.calculateSlides(); + }); + let selectedTab: T | undefined = this.tabs[this.selectedIndex || 0] || undefined; if (!selectedTab || !selectedTab.enabled) { diff --git a/src/core/features/course/classes/main-resource-component.ts b/src/core/features/course/classes/main-resource-component.ts index 7d838a22e..b6647d3a3 100644 --- a/src/core/features/course/classes/main-resource-component.ts +++ b/src/core/features/course/classes/main-resource-component.ts @@ -101,7 +101,7 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy, * @param showErrors If show errors to the user of hide them. * @return Promise resolved when done. */ - async doRefresh(refresher?: CustomEvent, done?: () => void, showErrors: boolean = false): Promise { + async doRefresh(refresher?: CustomEvent | null, done?: () => void, showErrors: boolean = false): Promise { if (!this.loaded || !this.module) { return; } diff --git a/src/core/features/course/services/course-helper.ts b/src/core/features/course/services/course-helper.ts index 417ea149e..703405cba 100644 --- a/src/core/features/course/services/course-helper.ts +++ b/src/core/features/course/services/course-helper.ts @@ -1433,7 +1433,7 @@ export class CoreCourseHelperProvider { } if (module.handlerData?.action) { - module.handlerData.action(new Event('click'), module, courseId, { animated: false }, modParams); + module.handlerData.action(new Event('click'), module, courseId, { animated: false, params: modParams }); return true; } diff --git a/src/core/features/course/services/course.ts b/src/core/features/course/services/course.ts index 25f2fab6f..e857dc766 100644 --- a/src/core/features/course/services/course.ts +++ b/src/core/features/course/services/course.ts @@ -133,7 +133,7 @@ export class CoreCourseProvider { * @param courseId Course ID. * @param completion Completion status of the module. */ - checkModuleCompletion(courseId: number, completion: CoreCourseModuleCompletionData): void { + checkModuleCompletion(courseId: number, completion?: CoreCourseModuleCompletionData): void { if (completion && completion.tracking === 2 && completion.state === 0) { this.invalidateSections(courseId).finally(() => { CoreEvents.trigger(CoreEvents.COMPLETION_MODULE_VIEWED, { courseId: courseId }); diff --git a/src/core/features/course/services/handlers/default-module.ts b/src/core/features/course/services/handlers/default-module.ts index 95f9d7b5e..cfcda4a52 100644 --- a/src/core/features/course/services/handlers/default-module.ts +++ b/src/core/features/course/services/handlers/default-module.ts @@ -16,7 +16,7 @@ import { Injectable, Type } from '@angular/core'; import { CoreSites } from '@services/sites'; import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '../module-delegate'; -import { CoreCourse, CoreCourseModuleBasicInfo, CoreCourseWSModule } from '../course'; +import { CoreCourse, CoreCourseAnyModuleData, CoreCourseWSModule } from '../course'; import { CoreCourseAnyCourseData } from '@features/courses/services/courses'; import { CoreCourseModule } from '../course-helper'; import { CoreCourseUnsupportedModuleComponent } from '@features/course/components/unsupported-module/unsupported-module'; @@ -49,7 +49,7 @@ export class CoreCourseModuleDefaultHandler implements CoreCourseModuleHandler { * @return Data to render the module. */ getData( - module: CoreCourseWSModule | CoreCourseModuleBasicInfo, + module: CoreCourseAnyModuleData, courseId: number, // eslint-disable-line @typescript-eslint/no-unused-vars sectionId?: number, // eslint-disable-line @typescript-eslint/no-unused-vars forCoursePage?: boolean, // eslint-disable-line @typescript-eslint/no-unused-vars @@ -59,7 +59,7 @@ export class CoreCourseModuleDefaultHandler implements CoreCourseModuleHandler { icon: CoreCourse.instance.getModuleIconSrc(module.modname, 'modicon' in module ? module.modicon : undefined), title: module.name, class: 'core-course-default-handler core-course-module-' + module.modname + '-handler', - action: (event: Event, module: CoreCourseModule, courseId: number, options?: CoreNavigationOptions): void => { + action: (event: Event, module: CoreCourseModule, courseId: number, options?: CoreNavigationOptions) => { event.preventDefault(); event.stopPropagation(); diff --git a/src/core/features/course/services/module-delegate.ts b/src/core/features/course/services/module-delegate.ts index e8559eb9f..793fe12d1 100644 --- a/src/core/features/course/services/module-delegate.ts +++ b/src/core/features/course/services/module-delegate.ts @@ -14,18 +14,17 @@ import { Injectable, Type } from '@angular/core'; import { SafeUrl } from '@angular/platform-browser'; -import { Params } from '@angular/router'; import { IonRefresher } from '@ionic/angular'; import { CoreSite } from '@classes/site'; import { CoreCourseModuleDefaultHandler } from './handlers/default-module'; import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; import { CoreCourseAnyCourseData } from '@features/courses/services/courses'; -import { CoreCourse, CoreCourseModuleBasicInfo, CoreCourseWSModule } from './course'; +import { CoreCourse, CoreCourseAnyModuleData, CoreCourseWSModule } from './course'; import { CoreSites } from '@services/sites'; -import { NavigationOptions } from '@ionic/angular/providers/nav-controller'; import { makeSingleton } from '@singletons'; import { CoreCourseModule } from './course-helper'; +import { CoreNavigationOptions } from '@services/navigator'; /** * Interface that all course module handlers must implement. @@ -53,7 +52,7 @@ export interface CoreCourseModuleHandler extends CoreDelegateHandler { * @return Data to render the module. */ getData( - module: CoreCourseWSModule | CoreCourseModuleBasicInfo, + module: CoreCourseAnyModuleData, courseId: number, sectionId?: number, forCoursePage?: boolean, @@ -158,9 +157,8 @@ export interface CoreCourseModuleHandlerData { * @param module The module object. * @param courseId The course ID. * @param options Options for the navigation. - * @param params Params for the new page. */ - action?(event: Event, module: CoreCourseModule, courseId: number, options?: NavigationOptions, params?: Params): void; + action?(event: Event, module: CoreCourseModule, courseId: number, options?: CoreNavigationOptions): void; /** * Updates the status of the module. @@ -272,7 +270,7 @@ export class CoreCourseModuleDelegateService extends CoreDelegate { return { ngModule: CoreMainMenuTabRoutingModule, From 90b3add5dfbbd7a8d2048f229c32a044f71f9384 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 3 Feb 2021 10:55:01 +0100 Subject: [PATCH 06/10] MOBILE-3648 lesson: Implement lesson player --- .../filter/addon-calendar-filter-popover.html | 34 +- .../calendar/pages/edit-event/edit-event.html | 10 +- .../lesson/components/components.module.ts | 3 + .../index/addon-mod-lesson-index.html | 49 +- .../mod/lesson/components/index/index.ts | 87 +- .../components/menu-modal/menu-modal.html | 49 ++ .../components/menu-modal/menu-modal.ts | 55 ++ .../password-modal/password-modal.html | 2 +- src/addons/mod/lesson/lesson-lazy.module.ts | 4 + .../mod/lesson/pages/player/player.html | 292 +++++++ .../mod/lesson/pages/player/player.module.ts | 51 ++ .../mod/lesson/pages/player/player.scss | 46 + src/addons/mod/lesson/pages/player/player.ts | 806 ++++++++++++++++++ .../mod/lesson/services/lesson-helper.ts | 30 +- src/addons/mod/lesson/services/lesson.ts | 2 +- src/core/classes/tabs.ts | 7 +- src/core/components/components.module.ts | 3 + src/core/components/timer/core-timer.html | 11 + src/core/components/timer/timer.scss | 29 + src/core/components/timer/timer.ts | 88 ++ .../forgotten-password.html | 4 +- src/core/guards/can-leave.ts | 43 + src/theme/theme.base.scss | 21 + src/theme/theme.light.scss | 1 + 24 files changed, 1627 insertions(+), 100 deletions(-) create mode 100644 src/addons/mod/lesson/components/menu-modal/menu-modal.html create mode 100644 src/addons/mod/lesson/components/menu-modal/menu-modal.ts create mode 100644 src/addons/mod/lesson/pages/player/player.html create mode 100644 src/addons/mod/lesson/pages/player/player.module.ts create mode 100644 src/addons/mod/lesson/pages/player/player.scss create mode 100644 src/addons/mod/lesson/pages/player/player.ts create mode 100644 src/core/components/timer/core-timer.html create mode 100644 src/core/components/timer/timer.scss create mode 100644 src/core/components/timer/timer.ts create mode 100644 src/core/guards/can-leave.ts diff --git a/src/addons/calendar/components/filter/addon-calendar-filter-popover.html b/src/addons/calendar/components/filter/addon-calendar-filter-popover.html index c2d603559..d89538bf2 100644 --- a/src/addons/calendar/components/filter/addon-calendar-filter-popover.html +++ b/src/addons/calendar/components/filter/addon-calendar-filter-popover.html @@ -1,20 +1,18 @@ - - - - {{ 'addon.calendar.' + type + 'events' | translate}} - - - - - - - - - - - - - - + + + {{ 'addon.calendar.' + type + 'events' | translate}} + + + + + + + + + + + + + diff --git a/src/addons/calendar/pages/edit-event/edit-event.html b/src/addons/calendar/pages/edit-event/edit-event.html index 09b1bdf45..27a1912bf 100644 --- a/src/addons/calendar/pages/edit-event/edit-event.html +++ b/src/addons/calendar/pages/edit-event/edit-event.html @@ -157,18 +157,18 @@ - + {{ 'addon.calendar.durationnone' | translate }} - + {{ 'addon.calendar.durationuntil' | translate }} - + {{ 'addon.calendar.durationminutes' | translate }} {{ 'addon.calendar.repeateditall' | translate:{$a: otherEventsCount} }} - + {{ 'addon.calendar.repeateditthis' | translate }} - + diff --git a/src/addons/mod/lesson/components/components.module.ts b/src/addons/mod/lesson/components/components.module.ts index 91abab0d8..0bdd0735f 100644 --- a/src/addons/mod/lesson/components/components.module.ts +++ b/src/addons/mod/lesson/components/components.module.ts @@ -21,11 +21,13 @@ import { TranslateModule } from '@ngx-translate/core'; import { CoreSharedModule } from '@/core/shared.module'; import { CoreCourseComponentsModule } from '@features/course/components/components.module'; import { AddonModLessonIndexComponent } from './index/index'; +import { AddonModLessonMenuModalPage } from './menu-modal/menu-modal'; import { AddonModLessonPasswordModalComponent } from './password-modal/password-modal'; @NgModule({ declarations: [ AddonModLessonIndexComponent, + AddonModLessonMenuModalPage, AddonModLessonPasswordModalComponent, ], imports: [ @@ -40,6 +42,7 @@ import { AddonModLessonPasswordModalComponent } from './password-modal/password- ], exports: [ AddonModLessonIndexComponent, + AddonModLessonMenuModalPage, AddonModLessonPasswordModalComponent, ], }) diff --git a/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html b/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html index 1b760bbed..59d07b5a6 100644 --- a/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html +++ b/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html @@ -64,7 +64,7 @@ {{ 'addon.mod_lesson.continue' | translate }} - + @@ -73,13 +73,17 @@ - + - - {{ 'addon.mod_lesson.retakefinishedinsync' | translate }} - - {{ 'addon.mod_lesson.review' | translate }} - + + + {{ 'addon.mod_lesson.retakefinishedinsync' | translate }} + + + + {{ 'addon.mod_lesson.review' | translate }} + + @@ -103,15 +107,16 @@ - - - - + + + + + + {{ 'addon.mod_lesson.continue' | translate }} - + - + - + {{ 'core.start' | translate }} - + - + {{ 'addon.mod_lesson.preview' | translate }} - + {{ 'addon.mod_lesson.continue' | translate }} - + @@ -306,4 +313,4 @@ - \ No newline at end of file +
diff --git a/src/addons/mod/lesson/components/index/index.ts b/src/addons/mod/lesson/components/index/index.ts index 88d4f67ec..b6afb0bb2 100644 --- a/src/addons/mod/lesson/components/index/index.ts +++ b/src/addons/mod/lesson/components/index/index.ts @@ -22,6 +22,7 @@ import { CoreCourse } from '@features/course/services/course'; import { CoreUser } from '@features/user/services/user'; import { IonContent, IonInput } from '@ionic/angular'; import { CoreGroupInfo, CoreGroups } from '@services/groups'; +import { CoreNavigator } from '@services/navigator'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreTextUtils } from '@services/utils/text'; import { CoreTimeUtils } from '@services/utils/time'; @@ -329,21 +330,6 @@ export class AddonModLessonIndexComponent extends CoreCourseModuleMainActivityCo super.ionViewDidLeave(); this.tabsComponent?.ionViewDidLeave(); - - // @todo if (this.navCtrl.getActive().component.name != 'AddonModLessonPlayerPage') { - // return; - // } - - // Detect if anything was sent to server. - this.hasPlayed = true; - this.dataSentObserver?.off(); - - this.dataSentObserver = CoreEvents.on(AddonModLessonProvider.DATA_SENT_EVENT, (data) => { - // Ignore launch sending because it only affects timers. - if (data.lessonId === this.lesson?.id && data.type != 'launch') { - this.dataSent = true; - } - }, this.siteId); } /** @@ -418,34 +404,45 @@ export class AddonModLessonIndexComponent extends CoreCourseModuleMainActivityCo * @param continueLast Whether to continue the last retake. * @return Promise resolved when done. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars protected async playLesson(continueLast?: boolean): Promise { if (!this.lesson || !this.accessInfo) { return; } - // @todo // Calculate the pageId to load. If there is timelimit, lesson is always restarted from the start. - // let pageId: number | undefined; + let pageId: number | undefined; - // if (this.hasOffline) { - // if (continueLast) { - // pageId = await AddonModLesson.instance.getLastPageSeen(this.lesson.id, this.accessInfo.attemptscount, { - // cmId: this.module!.id, - // }); - // } else { - // pageId = this.accessInfo.firstpageid; - // } - // } else if (this.leftDuringTimed && !this.lesson.timelimit) { - // pageId = continueLast ? this.accessInfo.lastpageseen : this.accessInfo.firstpageid; - // } + if (this.hasOffline) { + if (continueLast) { + pageId = await AddonModLesson.instance.getLastPageSeen(this.lesson.id, this.accessInfo.attemptscount, { + cmId: this.module!.id, + }); + } else { + pageId = this.accessInfo.firstpageid; + } + } else if (this.leftDuringTimed && !this.lesson.timelimit) { + pageId = continueLast ? this.accessInfo.lastpageseen : this.accessInfo.firstpageid; + } - // this.navCtrl.push('AddonModLessonPlayerPage', { - // courseId: this.courseId, - // lessonId: this.lesson.id, - // pageId: pageId, - // password: this.password, - // }); + CoreNavigator.instance.navigate('../player', { + params: { + courseId: this.courseId, + lessonId: this.lesson.id, + pageId: pageId, + password: this.password, + }, + }); + + // Detect if anything was sent to server. + this.hasPlayed = true; + this.dataSentObserver?.off(); + + this.dataSentObserver = CoreEvents.on(AddonModLessonProvider.DATA_SENT_EVENT, (data) => { + // Ignore launch sending because it only affects timers. + if (data.lessonId === this.lesson?.id && data.type != 'launch') { + this.dataSent = true; + } + }, this.siteId); } /** @@ -472,19 +469,21 @@ export class AddonModLessonIndexComponent extends CoreCourseModuleMainActivityCo * Review the lesson. */ review(): void { - if (!this.retakeToReview) { + if (!this.retakeToReview || !this.lesson) { // No retake to review, stop. return; } - // @todo this.navCtrl.push('AddonModLessonPlayerPage', { - // courseId: this.courseId, - // lessonId: this.lesson.id, - // pageId: this.retakeToReview.pageid, - // password: this.password, - // review: true, - // retake: this.retakeToReview.retake - // }); + CoreNavigator.instance.navigate('../player', { + params: { + courseId: this.courseId, + lessonId: this.lesson.id, + pageId: this.retakeToReview.pageid, + password: this.password, + review: true, + retake: this.retakeToReview.retake, + }, + }); } /** diff --git a/src/addons/mod/lesson/components/menu-modal/menu-modal.html b/src/addons/mod/lesson/components/menu-modal/menu-modal.html new file mode 100644 index 000000000..b442fb3b3 --- /dev/null +++ b/src/addons/mod/lesson/components/menu-modal/menu-modal.html @@ -0,0 +1,49 @@ + + + {{ pageInstance?.lesson?.name }} + + + + + + + + + + + diff --git a/src/addons/mod/lesson/components/menu-modal/menu-modal.ts b/src/addons/mod/lesson/components/menu-modal/menu-modal.ts new file mode 100644 index 000000000..0b14d58e1 --- /dev/null +++ b/src/addons/mod/lesson/components/menu-modal/menu-modal.ts @@ -0,0 +1,55 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input } from '@angular/core'; + +import { ModalController } from '@singletons'; +import { AddonModLessonPlayerPage } from '../../pages/player/player'; + +/** + * Modal that renders the lesson menu and media file. + */ +@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. + */ + @Input() pageInstance?: AddonModLessonPlayerPage; + + /** + * Close modal. + */ + closeModal(): void { + ModalController.instance.dismiss(); + } + + /** + * Load a certain page. + * + * @param pageId The page ID to load. + */ + loadPage(pageId: number): void { + this.pageInstance?.changePage(pageId); + this.closeModal(); + } + +} diff --git a/src/addons/mod/lesson/components/password-modal/password-modal.html b/src/addons/mod/lesson/components/password-modal/password-modal.html index ba0313e13..9b9ff4658 100644 --- a/src/addons/mod/lesson/components/password-modal/password-modal.html +++ b/src/addons/mod/lesson/components/password-modal/password-modal.html @@ -20,7 +20,7 @@ {{ 'addon.mod_lesson.continue' | translate }} - + diff --git a/src/addons/mod/lesson/lesson-lazy.module.ts b/src/addons/mod/lesson/lesson-lazy.module.ts index 9fb389ac3..d58df67b4 100644 --- a/src/addons/mod/lesson/lesson-lazy.module.ts +++ b/src/addons/mod/lesson/lesson-lazy.module.ts @@ -25,6 +25,10 @@ const routes: Routes = [ path: 'index', loadChildren: () => import('./pages/index/index.module').then( m => m.AddonModLessonIndexPageModule), }, + { + path: 'player', + loadChildren: () => import('./pages/player/player.module').then( m => m.AddonModLessonPlayerPageModule), + }, ]; @NgModule({ diff --git a/src/addons/mod/lesson/pages/player/player.html b/src/addons/mod/lesson/pages/player/player.html new file mode 100644 index 000000000..fc16197fb --- /dev/null +++ b/src/addons/mod/lesson/pages/player/player.html @@ -0,0 +1,292 @@ + + + + + + + + + + + + + + + + + + + + + + + {{ messages[0].message }} + + + +
+ + + + + + +

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

+
+ + {{ pageData.ongoingscore }} + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + +

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

+

+ + +

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

+

+
+ + + + {{option.label}} + + + +
+
+
+
+
+ + + {{ question.submitLabel }} + + + +
+
+ + + + + + + + {{ 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 }} + + + {{ eolData.notenoughtimespent.message }} + + + {{ eolData.numberofpagesviewed.message }} + + + {{ eolData.youshouldview.message }} + + + {{ eolData.numberofcorrectanswers.message }} + + + + + + {{ eolData.displayscorewithoutessays.message }} + + + {{ eolData.yourcurrentgradeisoutof.message }} + + + {{ eolData.eolstudentoutoftimenoanswers.message }} + + + {{ eolData.welldone.message }} + + + + {{ 'addon.mod_lesson.progresscompleted' | translate:{$a: eolData.progresscompleted.value} }} + + + + + {{ eolData.displayofgrade.message }} + + + {{ 'addon.mod_lesson.reviewlesson' | translate }} + + + {{ eolData.modattemptsnoteacher.message }} + + + + + + + + + + + + + +
+ + + + + {{ processData.ongoingscore }} + + + +
+ + +
+
+

{{ '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/addons/mod/lesson/pages/player/player.module.ts b/src/addons/mod/lesson/pages/player/player.module.ts new file mode 100644 index 000000000..4a04f1b0d --- /dev/null +++ b/src/addons/mod/lesson/pages/player/player.module.ts @@ -0,0 +1,51 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonModLessonPlayerPage } from './player'; +import { CoreEditorComponentsModule } from '@features/editor/components/components.module'; +import { CanLeaveGuard } from '@guards/can-leave'; + +const routes: Routes = [ + { + path: '', + component: AddonModLessonPlayerPage, + canDeactivate: [CanLeaveGuard], + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + FormsModule, + ReactiveFormsModule, + CoreSharedModule, + CoreEditorComponentsModule, + ], + declarations: [ + AddonModLessonPlayerPage, + ], + exports: [RouterModule], +}) +export class AddonModLessonPlayerPageModule {} diff --git a/src/addons/mod/lesson/pages/player/player.scss b/src/addons/mod/lesson/pages/player/player.scss new file mode 100644 index 000000000..79a950420 --- /dev/null +++ b/src/addons/mod/lesson/pages/player/player.scss @@ -0,0 +1,46 @@ +:host ::ng-deep { + .addon-mod_lesson-slideshow { + max-width: 100%; + max-height: 100%; + margin: 0 auto; + } + + table { + width: 100%; + margin-top: 1.5rem; + + tr:nth-child(odd) { + background-color: var(--gray-lighter); + // @include darkmode() { + // background-color: $core-dark-item-divider-bg-color; + // } + } + + tr:last-child td { + border-bottom: 0; + } + + td { + padding: 5px; + line-height: 1.5; + border-bottom: 1px solid var(--gray); + } + } + + // @todo + // .item-ios table { + // @extend .card-ios; + // @include darkmode() { + // color: $white; + // background-color: $core-dark-item-bg-color; + // } + // } + + // .item-md table { + // @extend .card-md; + // @include darkmode() { + // color: $white; + // background-color: $core-dark-item-bg-color; + // } + // } +} diff --git a/src/addons/mod/lesson/pages/player/player.ts b/src/addons/mod/lesson/pages/player/player.ts new file mode 100644 index 000000000..3a256c6de --- /dev/null +++ b/src/addons/mod/lesson/pages/player/player.ts @@ -0,0 +1,806 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, OnDestroy, ViewChild, ChangeDetectorRef, ElementRef } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { IonContent } from '@ionic/angular'; + +import { CoreError } from '@classes/errors/error'; +import { CanLeave } from '@guards/can-leave'; +import { CoreApp } from '@services/app'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; +import { ModalController, Translate } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { AddonModLessonMenuModalPage } from '../../components/menu-modal/menu-modal'; +import { + AddonModLesson, + AddonModLessonEOLPageDataEntry, + AddonModLessonFinishRetakeResponse, + AddonModLessonGetAccessInformationWSResponse, + AddonModLessonGetPageDataWSResponse, + AddonModLessonGetPagesPageWSData, + AddonModLessonLaunchAttemptWSResponse, + AddonModLessonLessonWSData, + AddonModLessonMessageWSData, + AddonModLessonPageWSData, + AddonModLessonPossibleJumps, + AddonModLessonProcessPageOptions, + AddonModLessonProcessPageResponse, + AddonModLessonProvider, +} from '../../services/lesson'; +import { + AddonModLessonActivityLink, + AddonModLessonHelper, + AddonModLessonPageButton, + AddonModLessonQuestion, +} from '../../services/lesson-helper'; +import { AddonModLessonOffline } from '../../services/lesson-offline'; +import { AddonModLessonSync } from '../../services/lesson-sync'; + +/** + * Page that allows attempting and reviewing a lesson. + */ +@Component({ + selector: 'page-addon-mod-lesson-player', + templateUrl: 'player.html', + styleUrls: ['player.scss'], +}) +export class AddonModLessonPlayerPage implements OnInit, OnDestroy, CanLeave { + + @ViewChild(IonContent) content?: IonContent; + @ViewChild('questionFormEl') formElement?: ElementRef; + + component = AddonModLessonProvider.COMPONENT; + readonly LESSON_EOL = AddonModLessonProvider.LESSON_EOL; + questionForm?: FormGroup; // The FormGroup for question pages. + title?: string; // The page title. + lesson?: AddonModLessonLessonWSData; // The lesson object. + currentPage?: number; // Current page being viewed. + review?: boolean; // Whether the user is reviewing. + messages: AddonModLessonMessageWSData[] = []; // Messages to display to the user. + 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?: AddonModLessonGetPageDataWSResponse; // Current page data. + pageContent?: string; // Current page contents. + pageButtons?: AddonModLessonPageButton[]; // List of buttons of the current page. + question?: AddonModLessonQuestion; // Question of the current page (if it's a question page). + eolData?: Record; // Data for EOL page (if current page is EOL). + processData?: AddonModLessonProcessPageResponse; // Data to display after processing a page. + processDataButtons: ProcessDataButton[] = []; // Buttons to display after processing a page. + loaded?: boolean; // Whether data has been loaded. + displayMenu?: boolean; // Whether the lesson menu should be displayed. + originalData?: Record; // Original question data. It is used to check if data has changed. + reviewPageId?: number; // Page to open if the user wants to review the attempt. + courseId!: number; // The course ID the lesson belongs to. + lessonPages?: AddonModLessonPageWSData[]; // Lesson pages (for the lesson menu). + loadingMenu?: boolean; // Whether the lesson menu is being loaded. + mediaFile?: CoreWSExternalFile; // Media file of the lesson. + activityLink?: AddonModLessonActivityLink; // Next activity link data. + + 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?: AddonModLessonGetAccessInformationWSResponse; // Lesson access info. + protected jumps?: AddonModLessonPossibleJumps; // All possible jumps. + protected firstPageLoaded?: boolean; // Whether the first page has been loaded. + protected retakeToReview?: number; // Retake to review. + protected menuShown = false; // Whether menu is shown. + + constructor( + protected changeDetector: ChangeDetectorRef, + protected formBuilder: FormBuilder, + ) { + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + const lessonId = CoreNavigator.instance.getRouteNumberParam('lessonId'); + const courseId = CoreNavigator.instance.getRouteNumberParam('courseId'); + if (!lessonId || !courseId) { + CoreDomUtils.instance.showErrorModal('No lesson ID or course ID supplied.'); + this.forceLeave = true; + CoreNavigator.instance.back(); + + return; + } + + this.lessonId = lessonId; + this.courseId = courseId; + this.password = CoreNavigator.instance.getRouteParam('password'); + this.review = !!CoreNavigator.instance.getRouteBooleanParam('review'); + this.currentPage = CoreNavigator.instance.getRouteNumberParam('pageId'); + this.retakeToReview = CoreNavigator.instance.getRouteNumberParam('retake'); + + // Block the lesson so it cannot be synced. + CoreSync.instance.blockOperation(this.component, this.lessonId); + + try { + // Fetch the Lesson data. + const success = await this.fetchLessonData(); + if (success) { + // Review data loaded or new retake started, remove any retake being finished in sync. + AddonModLessonSync.instance.deleteRetakeFinishedInSync(this.lessonId); + } + } finally { + this.loaded = true; + } + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + // Unblock the lesson so it can be synced. + CoreSync.instance.unblockOperation(this.component, this.lessonId); + } + + /** + * Check if we can leave the page or not. + * + * @return Resolved if we can leave it, rejected if not. + */ + async canLeave(): Promise { + if (this.forceLeave || !this.questionForm) { + return true; + } + + if (this.question && !this.eolData && !this.processData && this.originalData) { + // Question shown. Check if there is any change. + if (!CoreUtils.instance.basicLeftCompare(this.questionForm.getRawValue(), this.originalData, 3)) { + await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.confirmcanceledit')); + } + } + + CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId()); + + return true; + } + + /** + * Runs when the page is about to leave and no longer be the active page. + */ + ionViewWillLeave(): void { + if (this.menuShown) { + ModalController.instance.dismiss(); + } + } + + /** + * A button was clicked. + * + * @param data Button data. + */ + buttonClicked(data: Record): void { + this.processPage(data); + } + + /** + * Call a function and go offline if allowed and the call fails. + * + * @param func Function to call. + * @param options Options passed to the function. + * @return Promise resolved in success, rejected otherwise. + */ + protected async callFunction(func: () => Promise, options: CommonOptions): Promise { + try { + return await func(); + } catch (error) { + if (this.offline || this.review || !AddonModLesson.instance.isLessonOffline(this.lesson!)) { + // Already offline or not allowed. + throw error; + } + + if (CoreUtils.instance.isWebServiceError(error)) { + // WebService returned an error, cannot perform the action. + throw error; + } + + // Go offline and retry. + this.offline = true; + + // Get the possible jumps now. + this.jumps = await AddonModLesson.instance.getPagesPossibleJumps(this.lesson!.id, { + cmId: this.lesson!.coursemodule, + readingStrategy: CoreSitesReadingStrategy.PreferCache, + }); + + // Call the function again with offline mode and the new jumps. + options.readingStrategy = CoreSitesReadingStrategy.PreferCache; + options.jumps = this.jumps; + options.offline = true; + + return func(); + } + } + + /** + * Change the page from menu or when continuing from a feedback page. + * + * @param pageId Page to load. + * @param ignoreCurrent If true, allow loading current page. + * @return Promise resolved when done. + */ + async changePage(pageId: number, ignoreCurrent?: boolean): Promise { + if (!ignoreCurrent && !this.eolData && this.currentPage == pageId) { + // Page already loaded, stop. + return; + } + + this.loaded = true; + this.messages = []; + + try { + await this.loadPage(pageId); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error loading page'); + } finally { + this.loaded = true; + } + } + + /** + * Get the lesson data and load the page. + * + * @return Promise resolved with true if success, resolved with false otherwise. + */ + protected async fetchLessonData(): Promise { + try { + // Wait for any ongoing sync to finish. We won't sync a lesson while it's being played. + await AddonModLessonSync.instance.waitForSync(this.lessonId); + + this.lesson = await AddonModLesson.instance.getLessonById(this.courseId, this.lessonId); + this.title = this.lesson.name; // Temporary title. + + // If lesson has offline data already, use offline mode. + this.offline = await AddonModLessonOffline.instance.hasOfflineData(this.lessonId); + + if (!this.offline && !CoreApp.instance.isOnline() && AddonModLesson.instance.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; + } + + const options = { + cmId: this.lesson.coursemodule, + readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork, + }; + this.accessInfo = await this.callFunction( + AddonModLesson.instance.getAccessInformation.bind(AddonModLesson.instance, this.lesson.id, options), + options, + ); + + const promises: Promise[] = []; + this.canManage = this.accessInfo.canmanage; + this.retake = this.accessInfo.attemptscount; + this.showRetake = !this.currentPage && this.retake > 0; // Only show it in first page if it isn't the first retake. + + if (this.accessInfo.preventaccessreasons.length) { + // If it's a password protected lesson and we have the password, allow playing it. + const preventReason = AddonModLesson.instance.getPreventAccessReason(this.accessInfo, !!this.password, this.review); + if (preventReason) { + // Lesson cannot be played, show message and go back. + throw new CoreError(preventReason.message); + } + } + + if (this.review && this.retakeToReview != this.accessInfo.attemptscount - 1) { + // Reviewing a retake that isn't the last one. Error. + throw new CoreError(Translate.instance.instant('addon.mod_lesson.errorreviewretakenotlast')); + } + + if (this.password) { + // Lesson uses password, get the whole lesson object. + const options = { + password: this.password, + cmId: this.lesson.coursemodule, + readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork, + }; + promises.push(this.callFunction( + AddonModLesson.instance.getLessonWithPassword.bind(AddonModLesson.instance, this.lesson.id, options), + options, + ).then((lesson) => { + this.lesson = lesson; + + return; + })); + } + + if (this.offline) { + // Offline mode, get the list of possible jumps to allow navigation. + promises.push(AddonModLesson.instance.getPagesPossibleJumps(this.lesson.id, { + cmId: this.lesson.coursemodule, + readingStrategy: CoreSitesReadingStrategy.PreferCache, + }).then((jumpList) => { + this.jumps = jumpList; + + return; + })); + } + + await Promise.all(promises); + + this.mediaFile = this.lesson.mediafiles?.[0]; + this.lessonWidth = this.lesson.slideshow ? CoreDomUtils.instance.formatPixelsSize(this.lesson.mediawidth!) : ''; + this.lessonHeight = this.lesson.slideshow ? CoreDomUtils.instance.formatPixelsSize(this.lesson.mediaheight!) : ''; + + await this.launchRetake(this.currentPage); + + return true; + } catch (error) { + + if (this.review && this.retakeToReview && CoreUtils.instance.isWebServiceError(error)) { + // The user cannot review the retake. Unmark the retake as being finished in sync. + await AddonModLessonSync.instance.deleteRetakeFinishedInSync(this.lessonId); + } + + CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errorgetmodule', true); + this.forceLeave = true; + CoreNavigator.instance.back(); + + return false; + } + } + + /** + * Finish the retake. + * + * @param outOfTime Whether the retake is finished because the user ran out of time. + * @return Promise resolved when done. + */ + protected async finishRetake(outOfTime?: boolean): Promise { + this.messages = []; + + if (this.offline && CoreApp.instance.isOnline()) { + // Offline mode but the app is online. Try to sync the data. + const result = await CoreUtils.instance.ignoreErrors( + AddonModLessonSync.instance.syncLesson(this.lesson!.id, true, true), + ); + + if (result?.warnings?.length) { + // Some data was deleted. Check if the retake has changed. + const info = await AddonModLesson.instance.getAccessInformation(this.lesson!.id, { + cmId: this.lesson!.coursemodule, + }); + + if (info.attemptscount != this.accessInfo!.attemptscount) { + // The retake has changed. Leave the view and show the error. + this.forceLeave = true; + CoreNavigator.instance.back(); + + throw new CoreError(result.warnings[0]); + } + + // Retake hasn't changed, show the warning and finish the retake in offline. + CoreDomUtils.instance.showErrorModal(result.warnings[0]); + } + + this.offline = false; + } + + // Now finish the retake. + const options = { + password: this.password, + outOfTime, + review: this.review, + offline: this.offline, + accessInfo: this.accessInfo, + }; + const data = await this.callFunction( + AddonModLesson.instance.finishRetake.bind(AddonModLesson.instance, this.lesson, this.courseId, options), + options, + ); + + this.title = this.lesson!.name; + this.eolData = data.data; + this.messages = this.messages.concat(data.messages); + this.processData = undefined; + + CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'lesson' }); + + // Format activity link if present. + if (this.eolData.activitylink) { + this.activityLink = AddonModLessonHelper.instance.formatActivityLink( this.eolData.activitylink.value); + } else { + this.activityLink = undefined; + } + + // Format review lesson if present. + if (this.eolData.reviewlesson) { + const params = CoreUrlUtils.instance.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.reviewPageId = Number(params.pageid); + } + } + } + + /** + * Jump to a certain page after performing an action. + * + * @param pageId The page to load. + * @return Promise resolved when done. + */ + protected async 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; + CoreNavigator.instance.back(); + + return; + } 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 pageId The page to load. + * @return Promise resolved when done. + */ + protected async launchRetake(pageId?: number): Promise { + let data: AddonModLessonLaunchAttemptWSResponse | undefined; + + if (this.review) { + // Review mode, no need to launch the retake. + } else if (!this.offline) { + // Not in offline mode, launch the retake. + data = await AddonModLesson.instance.launchRetake(this.lesson!.id, this.password, pageId); + } else { + // Check if there is a finished offline retake. + const finished = await AddonModLessonOffline.instance.hasFinishedRetake(this.lesson!.id); + if (finished) { + // Always show EOL page. + pageId = AddonModLessonProvider.LESSON_EOL; + } + } + + this.currentPage = pageId || this.accessInfo!.firstpageid; + this.messages = data?.messages || []; + + if (this.lesson!.timelimit && !this.accessInfo!.canmanage) { + // Get the last lesson timer. + const timers = await AddonModLesson.instance.getTimers(this.lesson!.id, { + cmId: this.lesson!.coursemodule, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + }); + + this.endTime = timers[timers.length - 1].starttime + this.lesson!.timelimit; + } + + return this.loadPage(this.currentPage); + } + + /** + * Load the lesson menu. + * + * @return Promise resolved when done. + */ + protected async loadMenu(): Promise { + if (this.loadingMenu) { + // Already loading. + return; + } + + try { + this.loadingMenu = true; + const options = { + password: this.password, + cmId: this.lesson!.coursemodule, + readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork, + }; + + const pages = await this.callFunction( + AddonModLesson.instance.getPages.bind(AddonModLesson.instance, this.lessonId, options), + options, + ); + + this.lessonPages = pages.map((entry) => entry.page); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error loading menu.'); + } finally { + this.loadingMenu = false; + } + } + + /** + * Load a certain page. + * + * @param pageId The page to load. + * @return Promise resolved when done. + */ + protected async loadPage(pageId: number): Promise { + if (pageId == AddonModLessonProvider.LESSON_EOL) { + // End of lesson reached. + return this.finishRetake(); + } + + const options = { + password: this.password, + review: this.review, + includeContents: true, + cmId: this.lesson!.coursemodule, + readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork, + accessInfo: this.accessInfo, + jumps: this.jumps, + includeOfflineData: true, + }; + + const data = await this.callFunction( + AddonModLesson.instance.getPageData.bind(AddonModLesson.instance, this.lesson, pageId, options), + options, + ); + + if (data.newpageid == AddonModLessonProvider.LESSON_EOL) { + // End of lesson reached. + return this.finishRetake(); + } + + this.pageData = data; + this.title = data.page!.title; + this.pageContent = AddonModLessonHelper.instance.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 (AddonModLesson.instance.isQuestionPage(data.page!.type)) { + // Create an empty FormGroup without controls, they will be added in getQuestionFromPageData. + this.questionForm = this.formBuilder.group({}); + this.pageButtons = []; + this.question = AddonModLessonHelper.instance.getQuestionFromPageData(this.questionForm, data); + this.originalData = this.questionForm.getRawValue(); // Use getRawValue to include disabled values. + } else { + this.pageButtons = AddonModLessonHelper.instance.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 data The data to send. + * @param formSubmitted Whether a form was submitted. + * @return Promise resolved when done. + */ + protected async processPage(data: Record, formSubmitted?: boolean): Promise { + this.loaded = false; + + const options: AddonModLessonProcessPageOptions = { + password: this.password, + review: this.review, + offline: this.offline, + accessInfo: this.accessInfo, + jumps: this.jumps, + }; + + try { + const result = await this.callFunction( + AddonModLesson.instance.processPage.bind( + AddonModLesson.instance, + this.lesson, + this.courseId, + this.pageData, + data, + options, + ), + options, + ); + + if (formSubmitted) { + CoreDomUtils.instance.triggerFormSubmittedEvent( + this.formElement, + result.sent, + CoreSites.instance.getCurrentSiteId(), + ); + } + + if (!this.offline && !this.review && AddonModLesson.instance.isLessonOffline(this.lesson!)) { + // Lesson allows offline and the user changed some data in server. Update cached data. + const retake = this.accessInfo!.attemptscount; + const options = { + cmId: this.lesson!.coursemodule, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + }; + + // Update in background the list of content pages viewed or question attempts. + if (AddonModLesson.instance.isQuestionPage(this.pageData?.page?.type || -1)) { + AddonModLesson.instance.getQuestionsAttemptsOnline(this.lessonId, retake, options); + } else { + AddonModLesson.instance.getContentPagesViewedOnline(this.lessonId, retake, options); + } + } + + if (result.nodefaultresponse || result.inmediatejump) { + // Don't display feedback or force a redirect to a new page. Load the new page. + return await this.jumpToPage(result.newpageid); + } + + // Not inmediate jump, show the feedback. + result.feedback = AddonModLessonHelper.instance.removeQuestionFromFeedback(result.feedback); + this.messages = result.messages; + this.processData = result; + this.processDataButtons = []; + + if (this.lesson!.review && !result.correctanswer && !result.noanswer && !result.isessayquestion && + !result.maxattemptsreached && !result.reviewmode) { + // User can try again, show button to do so. + this.processDataButtons.push({ + label: 'addon.mod_lesson.reviewquestionback', + pageId: this.currentPage!, + }); + } + + // Button to continue. + if (this.lesson!.review && !result.correctanswer && !result.noanswer && !result.isessayquestion && + !result.maxattemptsreached) { + /* If both the "Yes, I'd like to try again" and "No, I just want to go on to the next question" point to the + same page then don't show the "No, I just want to go on to the next question" button. It's confusing. */ + if (this.pageData!.page!.id != result.newpageid) { + // Button to continue the lesson (the page to go is configured by the teacher). + this.processDataButtons.push({ + label: 'addon.mod_lesson.reviewquestioncontinue', + pageId: result.newpageid, + }); + } + } else { + this.processDataButtons.push({ + label: 'addon.mod_lesson.continue', + pageId: result.newpageid, + }); + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error processing page'); + } finally { + this.loaded = true; + } + } + + /** + * Review the lesson. + * + * @param pageId Page to load. + */ + async reviewLesson(pageId: number): Promise { + this.loaded = false; + this.review = true; + this.offline = false; // Don't allow offline mode in review. + + try { + await this.loadPage(pageId); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error loading page'); + } finally { + this.loaded = true; + } + } + + /** + * Submit a question. + * + * @param e Event. + */ + submitQuestion(e: Event): void { + e.preventDefault(); + e.stopPropagation(); + + this.loaded = false; + + // Use getRawValue to include disabled values. + const data = AddonModLessonHelper.instance.prepareQuestionData(this.question!, this.questionForm!.getRawValue()); + + this.processPage(data, true).finally(() => { + this.loaded = true; + }); + } + + /** + * Time up. + */ + async timeUp(): Promise { + // Time up called, hide the timer. + this.endTime = undefined; + this.loaded = false; + + try { + await this.finishRetake(true); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error finishing attempt'); + } finally { + this.loaded = true; + } + } + + /** + * Show the navigation modal. + * + * @return Promise resolved when done. + */ + async showMenu(): Promise { + this.menuShown = true; + + const menuModal = await ModalController.instance.create({ + component: AddonModLessonMenuModalPage, + componentProps: { + pageInstance: this, + }, + cssClass: 'core-modal-lateral', + showBackdrop: true, + backdropDismiss: true, + // @todo enterAnimation: 'core-modal-lateral-transition', + // leaveAnimation: 'core-modal-lateral-transition', + }); + + await menuModal.present(); + + await menuModal.onWillDismiss(); + + this.menuShown = false; + } + +} + +/** + * Common options for functions called using callFunction. + */ +type CommonOptions = CoreSitesCommonWSOptions & { + jumps?: AddonModLessonPossibleJumps; + offline?: boolean; +}; + +/** + * Button displayed after processing a page. + */ +type ProcessDataButton = { + label: string; + pageId: number; +}; diff --git a/src/addons/mod/lesson/services/lesson-helper.ts b/src/addons/mod/lesson/services/lesson-helper.ts index 738ff63d5..527f34e1d 100644 --- a/src/addons/mod/lesson/services/lesson-helper.ts +++ b/src/addons/mod/lesson/services/lesson-helper.ts @@ -42,7 +42,7 @@ export class AddonModLessonHelperProvider { * @param activityLink HTML of the activity link. * @return Formatted data. */ - formatActivityLink(activityLink: string): {formatted: boolean; label: string; href: string} { + formatActivityLink(activityLink: string): AddonModLessonActivityLink { const element = CoreDomUtils.instance.convertToElement(activityLink); const anchor = element.querySelector('a'); @@ -264,7 +264,7 @@ export class AddonModLessonHelperProvider { value: input.value, checked: !!input.checked, disabled: !!input.disabled, - text: parent?.innerHTML.trim() || '', + text: '', }; if (option.checked || multiChoiceQuestion.multi) { @@ -277,6 +277,7 @@ export class AddonModLessonHelperProvider { // Remove the input and use the rest of the parent contents as the label. input.remove(); + option.text = parent?.innerHTML.trim() || ''; multiChoiceQuestion.options!.push(option); }); @@ -601,7 +602,7 @@ export type AddonModLessonPageButton = { /** * Generic question data. */ -export type AddonModLessonQuestion = { +export type AddonModLessonQuestionBasicData = { template: string; // Name of the template to use. submitLabel: string; // Text to display in submit. }; @@ -609,7 +610,7 @@ export type AddonModLessonQuestion = { /** * Multichoice question data. */ -export type AddonModLessonMultichoiceQuestion = AddonModLessonQuestion & { +export type AddonModLessonMultichoiceQuestion = AddonModLessonQuestionBasicData & { multi: boolean; // Whether it allows multiple answers. options: AddonModLessonMultichoiceOption[]; // Options for multichoice question. controlName?: string; // Name of the form control, for single choice. @@ -618,14 +619,14 @@ export type AddonModLessonMultichoiceQuestion = AddonModLessonQuestion & { /** * Short answer or numeric question data. */ -export type AddonModLessonInputQuestion = AddonModLessonQuestion & { +export type AddonModLessonInputQuestion = AddonModLessonQuestionBasicData & { input?: AddonModLessonQuestionInput; // Text input for text/number questions. }; /** * Essay question data. */ -export type AddonModLessonEssayQuestion = AddonModLessonQuestion & { +export type AddonModLessonEssayQuestion = AddonModLessonQuestionBasicData & { useranswer?: string; // User answer, for reviewing. textarea?: AddonModLessonTextareaData; // Data for the textarea. control?: FormControl; // Form control. @@ -634,7 +635,7 @@ export type AddonModLessonEssayQuestion = AddonModLessonQuestion & { /** * Matching question data. */ -export type AddonModLessonMatchingQuestion = AddonModLessonQuestion & { +export type AddonModLessonMatchingQuestion = AddonModLessonQuestionBasicData & { rows: AddonModLessonMatchingRow[]; }; @@ -721,3 +722,18 @@ export type AddonModLessonSelectAnswerData = { */ export type AddonModLessonAnswerData = AddonModLessonCheckboxAnswerData | AddonModLessonTextAnswerData | AddonModLessonSelectAnswerData | string; + +/** + * Any possible question data. + */ +export type AddonModLessonQuestion = AddonModLessonQuestionBasicData & Partial & +Partial & Partial & Partial; + +/** + * Activity link data. + */ +export type AddonModLessonActivityLink = { + formatted: boolean; + label: string; + href: string; +}; diff --git a/src/addons/mod/lesson/services/lesson.ts b/src/addons/mod/lesson/services/lesson.ts index 2e1a8c2a4..315778b98 100644 --- a/src/addons/mod/lesson/services/lesson.ts +++ b/src/addons/mod/lesson/services/lesson.ts @@ -2342,7 +2342,7 @@ export class AddonModLessonProvider { if (entry.reason == 'lessonopen' || entry.reason == 'lessonclosed') { // Time restrictions are the most prioritary, return it. - return reason; + return entry; } else if (entry.reason == 'passwordprotectedlesson') { if (!ignorePassword) { // Treat password before all other reasons. diff --git a/src/core/classes/tabs.ts b/src/core/classes/tabs.ts index 55836e270..041f192de 100644 --- a/src/core/classes/tabs.ts +++ b/src/core/classes/tabs.ts @@ -313,7 +313,7 @@ export class CoreTabsBaseComponent implements OnInit, Aft this.showNextButton = false; } - const currentIndex = await this.slides!.getActiveIndex(); + const currentIndex = await this.slides?.getActiveIndex(); if (this.shouldSlideToInitial && currentIndex != this.selectedIndex) { // Current tab has changed, don't slide to initial anymore. this.shouldSlideToInitial = false; @@ -331,6 +331,11 @@ export class CoreTabsBaseComponent implements OnInit, Aft this.slideChanged(); this.calculateTabBarHeight(); + + // @todo: This call to update() can trigger JS errors in the console if tabs are re-loaded and there's only 1 tab. + // For some reason, swiper.slides is undefined inside the Slides class, and the swiper is marked as destroyed. + // Changing *ngIf="hideUntil" to [hidden] doesn't solve the issue, and it causes another error to be raised. + // This can be tested in lesson as a student, play a lesson and go back to the entry page. await this.slides!.update(); if (!this.hasSliddenToInitial && this.selectedIndex && this.selectedIndex >= this.slidesOpts.slidesPerView) { diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index b467f6126..d99184c52 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -43,6 +43,7 @@ import { CoreUserAvatarComponent } from './user-avatar/user-avatar'; import { CoreDynamicComponent } from './dynamic-component/dynamic-component'; import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons'; import { CoreSendMessageFormComponent } from './send-message-form/send-message-form'; +import { CoreTimerComponent } from './timer/timer'; import { CoreDirectivesModule } from '@directives/directives.module'; import { CorePipesModule } from '@pipes/pipes.module'; @@ -74,6 +75,7 @@ import { CorePipesModule } from '@pipes/pipes.module'; CoreUserAvatarComponent, CoreDynamicComponent, CoreSendMessageFormComponent, + CoreTimerComponent, ], imports: [ CommonModule, @@ -109,6 +111,7 @@ import { CorePipesModule } from '@pipes/pipes.module'; CoreUserAvatarComponent, CoreDynamicComponent, CoreSendMessageFormComponent, + CoreTimerComponent, ], }) export class CoreComponentsModule {} diff --git a/src/core/components/timer/core-timer.html b/src/core/components/timer/core-timer.html new file mode 100644 index 000000000..d2bf2181f --- /dev/null +++ b/src/core/components/timer/core-timer.html @@ -0,0 +1,11 @@ + + + + {{ timerText }} + {{ timeLeft | coreSecondsToHMS }} + + {{ 'core.timesup' | translate }} + + + diff --git a/src/core/components/timer/timer.scss b/src/core/components/timer/timer.scss new file mode 100644 index 000000000..11da83db4 --- /dev/null +++ b/src/core/components/timer/timer.scss @@ -0,0 +1,29 @@ +$core-timer-warn-color: #cb3d4d !default; +$core-timer-iterations: 15 !default; + +:host { + .core-timer { + --background: transparent !important; + + .core-timer-time-left, .core-timesup { + font-weight: bold; + } + + span { + margin-right: 5px; + } + + // Create the timer warning colors. + @for $i from 0 through $core-timer-iterations { + &.core-timer-timeleft-#{$i} { + background-color: rgba($core-timer-warn-color, 1 - ($i / $core-timer-iterations)) !important; + + @if $i <= $core-timer-iterations / 2 { + label, span, ion-icon { + color: var(--white); + } + } + } + } + } +} diff --git a/src/core/components/timer/timer.ts b/src/core/components/timer/timer.ts new file mode 100644 index 000000000..06bd8c21c --- /dev/null +++ b/src/core/components/timer/timer.ts @@ -0,0 +1,88 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, ElementRef } from '@angular/core'; + +import { CoreTimeUtils } from '@services/utils/time'; + +/** + * This directive shows a timer in format HH:MM:SS. When the countdown reaches 0, a function is called. + * + * Usage: + * + */ +@Component({ + selector: 'core-timer', + templateUrl: 'core-timer.html', + styleUrls: ['timer.scss'], +}) +export class CoreTimerComponent implements OnInit, OnDestroy { + + @Input() endTime?: string | number; // Timestamp (in seconds) when the timer should end. + @Input() timerText?: string; // Text to show next to the timer. If not defined, no text shown. + @Input() timeLeftClass?: string; // Name of the class to apply with each second. By default, 'core-timer-timeleft-'. + @Input() align?: string; // Where to align the time and text. Defaults to 'left'. Other values: 'center', 'right'. + @Output() finished = new EventEmitter(); // Will emit an event when the timer reaches 0. + + timeLeft?: number; // Seconds left to end. + + protected timeInterval?: number; + protected element?: HTMLElement; + + constructor( + protected elementRef: ElementRef, + ) {} + + /** + * Component being initialized. + */ + ngOnInit(): void { + const timeLeftClass = this.timeLeftClass || 'core-timer-timeleft-'; + const endTime = Math.round(Number(this.endTime)); + const container: HTMLElement | undefined = this.elementRef.nativeElement.querySelector('.core-timer'); + + if (!endTime) { + return; + } + + // Check time left every 200ms. + this.timeInterval = window.setInterval(() => { + this.timeLeft = endTime - CoreTimeUtils.instance.timestamp(); + + if (this.timeLeft < 0) { + // Time is up! Stop the timer and call the finish function. + clearInterval(this.timeInterval); + this.finished.emit(); + + return; + } + + // If the time has nearly expired, change the color. + if (this.timeLeft < 100 && container && !container.classList.contains(timeLeftClass + this.timeLeft)) { + // Time left has changed. Remove previous classes and add the new one. + container.classList.remove(timeLeftClass + (this.timeLeft + 1)); + container.classList.remove(timeLeftClass + (this.timeLeft + 2)); + container.classList.add(timeLeftClass + this.timeLeft); + } + }, 200); + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + clearInterval(this.timeInterval); + } + +} diff --git a/src/core/features/login/pages/forgotten-password/forgotten-password.html b/src/core/features/login/pages/forgotten-password/forgotten-password.html index fc2042ba6..2736767f6 100644 --- a/src/core/features/login/pages/forgotten-password/forgotten-password.html +++ b/src/core/features/login/pages/forgotten-password/forgotten-password.html @@ -21,11 +21,11 @@ {{ 'core.login.username' | translate }} - + {{ 'core.user.email' | translate }} - + diff --git a/src/core/guards/can-leave.ts b/src/core/guards/can-leave.ts new file mode 100644 index 000000000..2f81f266c --- /dev/null +++ b/src/core/guards/can-leave.ts @@ -0,0 +1,43 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CanDeactivate } from '@angular/router'; +import { CoreUtils } from '@services/utils/utils'; + +@Injectable({ providedIn: 'root' }) +export class CanLeaveGuard implements CanDeactivate { + + async canDeactivate(component: unknown | null): Promise { + if (!this.isCanLeave(component)) { + return true; + } + + return CoreUtils.instance.ignoreErrors(component.canLeave(), false); + } + + isCanLeave(component: unknown | null): component is CanLeave { + return component !== null && 'canLeave' in component; + } + +} + +export interface CanLeave { + /** + * Check whether the user can leave the current route. + * + * @return Promise resolved with true if can leave, resolved with false or rejected if cannot leave. + */ + canLeave: () => Promise; +} diff --git a/src/theme/theme.base.scss b/src/theme/theme.base.scss index 2daf19de3..7dc22b6cd 100644 --- a/src/theme/theme.base.scss +++ b/src/theme/theme.base.scss @@ -1,3 +1,5 @@ +@import "./globals.mixins.ionic.scss"; + // Common styles. .text-left { text-align: left; } .text-right { text-align: right; } @@ -139,6 +141,25 @@ ion-toolbar { z-index: 100000 !important; } +@media only screen and (min-height: 400px) and (min-width: 300px) { + .core-modal-lateral { + // @todo @include core-split-area-end(); + + .modal-wrapper { + position: absolute; + @include position(0 !important, 0 !important, 0 !important, auto); + display: block; + height: 100% !important; + width: auto; + min-width: 300px; + box-shadow: 0 28px 48px rgba(0, 0, 0, 0.4); + } + ion-backdrop { + visibility: visible; + } + } +} + // Hidden submit button. .core-submit-hidden-enter { position: absolute; diff --git a/src/theme/theme.light.scss b/src/theme/theme.light.scss index 40feb3112..2539c4a8a 100644 --- a/src/theme/theme.light.scss +++ b/src/theme/theme.light.scss @@ -185,6 +185,7 @@ ion-item-divider { --background: var(--gray-lighter); + --color: inherit; } --core-button-select-background: var(--custom-button-select-background, var(--ion-color-primary-contrast)); From e04c19596f11ef35d54bce2114ad012509f0707f Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 3 Feb 2021 15:59:12 +0100 Subject: [PATCH 07/10] MOBILE-3648 lesson: Implement user-retake page --- .../index/addon-mod-lesson-index.html | 3 +- .../mod/lesson/components/index/index.ts | 22 +- src/addons/mod/lesson/lesson-lazy.module.ts | 6 +- src/addons/mod/lesson/pages/player/player.ts | 14 +- .../lesson/pages/user-retake/user-retake.html | 235 +++++++++++++++ .../pages/user-retake/user-retake.module.ts | 46 +++ .../lesson/pages/user-retake/user-retake.scss | 17 ++ .../lesson/pages/user-retake/user-retake.ts | 275 ++++++++++++++++++ .../mod/lesson/services/handlers/module.ts | 2 +- src/addons/mod/lesson/services/lesson.ts | 17 +- src/theme/theme.light.scss | 2 + 11 files changed, 612 insertions(+), 27 deletions(-) create mode 100644 src/addons/mod/lesson/pages/user-retake/user-retake.html create mode 100644 src/addons/mod/lesson/pages/user-retake/user-retake.module.ts create mode 100644 src/addons/mod/lesson/pages/user-retake/user-retake.scss create mode 100644 src/addons/mod/lesson/pages/user-retake/user-retake.ts diff --git a/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html b/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html index 59d07b5a6..ee5cadb64 100644 --- a/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html +++ b/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html @@ -300,7 +300,8 @@ {{ 'addon.mod_lesson.overview' | translate }} - + diff --git a/src/addons/mod/lesson/components/index/index.ts b/src/addons/mod/lesson/components/index/index.ts index b6afb0bb2..5b670b6a0 100644 --- a/src/addons/mod/lesson/components/index/index.ts +++ b/src/addons/mod/lesson/components/index/index.ts @@ -424,10 +424,8 @@ export class AddonModLessonIndexComponent extends CoreCourseModuleMainActivityCo pageId = continueLast ? this.accessInfo.lastpageseen : this.accessInfo.firstpageid; } - CoreNavigator.instance.navigate('../player', { + await CoreNavigator.instance.navigate(`../player/${this.courseId}/${this.lesson.id}`, { params: { - courseId: this.courseId, - lessonId: this.lesson.id, pageId: pageId, password: this.password, }, @@ -474,10 +472,8 @@ export class AddonModLessonIndexComponent extends CoreCourseModuleMainActivityCo return; } - CoreNavigator.instance.navigate('../player', { + CoreNavigator.instance.navigate(`../player/${this.courseId}/${this.lesson.id}`, { params: { - courseId: this.courseId, - lessonId: this.lesson.id, pageId: this.retakeToReview.pageid, password: this.password, review: true, @@ -692,6 +688,20 @@ export class AddonModLessonIndexComponent extends CoreCourseModuleMainActivityCo } } + /** + * Open a certain user retake. + * + * @param userId User ID to view. + * @return Promise resolved when done. + */ + async openRetake(userId: number): Promise { + await CoreNavigator.instance.navigate(`../user-retake/${this.courseId}/${this.lesson!.id}`, { + params: { + userId, + }, + }); + } + /** * Component being destroyed. */ diff --git a/src/addons/mod/lesson/lesson-lazy.module.ts b/src/addons/mod/lesson/lesson-lazy.module.ts index d58df67b4..4b9bb7f6f 100644 --- a/src/addons/mod/lesson/lesson-lazy.module.ts +++ b/src/addons/mod/lesson/lesson-lazy.module.ts @@ -26,9 +26,13 @@ const routes: Routes = [ loadChildren: () => import('./pages/index/index.module').then( m => m.AddonModLessonIndexPageModule), }, { - path: 'player', + path: 'player/:courseId/:lessonId', loadChildren: () => import('./pages/player/player.module').then( m => m.AddonModLessonPlayerPageModule), }, + { + path: 'user-retake/:courseId/:lessonId', + loadChildren: () => import('./pages/user-retake/user-retake.module').then( m => m.AddonModLessonUserRetakePageModule), + }, ]; @NgModule({ diff --git a/src/addons/mod/lesson/pages/player/player.ts b/src/addons/mod/lesson/pages/player/player.ts index 3a256c6de..42d0d7a2a 100644 --- a/src/addons/mod/lesson/pages/player/player.ts +++ b/src/addons/mod/lesson/pages/player/player.ts @@ -118,18 +118,8 @@ export class AddonModLessonPlayerPage implements OnInit, OnDestroy, CanLeave { * Component being initialized. */ async ngOnInit(): Promise { - const lessonId = CoreNavigator.instance.getRouteNumberParam('lessonId'); - const courseId = CoreNavigator.instance.getRouteNumberParam('courseId'); - if (!lessonId || !courseId) { - CoreDomUtils.instance.showErrorModal('No lesson ID or course ID supplied.'); - this.forceLeave = true; - CoreNavigator.instance.back(); - - return; - } - - this.lessonId = lessonId; - this.courseId = courseId; + this.lessonId = CoreNavigator.instance.getRouteNumberParam('lessonId')!; + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!; this.password = CoreNavigator.instance.getRouteParam('password'); this.review = !!CoreNavigator.instance.getRouteBooleanParam('review'); this.currentPage = CoreNavigator.instance.getRouteNumberParam('pageId'); diff --git a/src/addons/mod/lesson/pages/user-retake/user-retake.html b/src/addons/mod/lesson/pages/user-retake/user-retake.html new file mode 100644 index 000000000..9649820af --- /dev/null +++ b/src/addons/mod/lesson/pages/user-retake/user-retake.html @@ -0,0 +1,235 @@ + + + + + + {{ 'addon.mod_lesson.detailedstats' | translate }} + + + + + + + + +
+ + + + + +

{{student.fullname}}

+ +
+
+ + + + {{ 'addon.mod_lesson.attemptheader' | translate }} + + + {{retake.label}} + + + + + + + + + +

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

+

{{ 'core.percentagenumber' | translate:{$a: retake.userstats.grade} }}

+
+ + +

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

+

{{ retake.userstats.gradeinfo.earned }} / {{ retake.userstats.gradeinfo.total }}

+
+
+
+ + +

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

+

{{ timeTakenReadable }}

+
+
+ + +

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

+

{{ retake.userstats.completed * 1000 | coreFormatDate }}

+
+
+
+ + + + {{ 'addon.mod_lesson.notcompleted' | translate }} + + + + + + + + {{page.qtype}}: {{page.title}} + + + +

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

+

+ + +

+
+
+ + +

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

+
+
+ + +

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

+
+
+
+
+ + + + + {{ answer[0].buttonText }} + + +

+
+
+
+ +
+ + + + + +

+ + +

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

{{ answer[0].value }}

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

+ + +

+
+ +

{{answer[0].value}}

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

+ + +

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

+ + +

+ + + + +
+
+
+ + + +

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

+

+ + +

+
+
+ +

{{page.answerdata.score}}

+
+
+
+
+
+
+
diff --git a/src/addons/mod/lesson/pages/user-retake/user-retake.module.ts b/src/addons/mod/lesson/pages/user-retake/user-retake.module.ts new file mode 100644 index 000000000..68e773092 --- /dev/null +++ b/src/addons/mod/lesson/pages/user-retake/user-retake.module.ts @@ -0,0 +1,46 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonModLessonUserRetakePage } from './user-retake'; + +const routes: Routes = [ + { + path: '', + component: AddonModLessonUserRetakePage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + FormsModule, + CoreSharedModule, + ], + declarations: [ + AddonModLessonUserRetakePage, + ], + exports: [RouterModule], +}) +export class AddonModLessonUserRetakePageModule {} diff --git a/src/addons/mod/lesson/pages/user-retake/user-retake.scss b/src/addons/mod/lesson/pages/user-retake/user-retake.scss new file mode 100644 index 000000000..87b03856c --- /dev/null +++ b/src/addons/mod/lesson/pages/user-retake/user-retake.scss @@ -0,0 +1,17 @@ +:host { + .button-disabled { + opacity: 0.4; + } + + .addon-mod_lesson-highlight { + --background: var(--blue-light); + + ion-label, ion-label p { + color: var(--blue-dark); + } + } + + .item-interactive-disabled ion-label { + opacity: 0.5; + } +} diff --git a/src/addons/mod/lesson/pages/user-retake/user-retake.ts b/src/addons/mod/lesson/pages/user-retake/user-retake.ts new file mode 100644 index 000000000..31c736f73 --- /dev/null +++ b/src/addons/mod/lesson/pages/user-retake/user-retake.ts @@ -0,0 +1,275 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit } from '@angular/core'; +import { IonRefresher } from '@ionic/angular'; + +import { CoreError } from '@classes/errors/error'; +import { CoreUser } from '@features/user/services/user'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { Translate } from '@singletons'; +import { + AddonModLesson, + AddonModLessonAttemptsOverviewsAttemptWSData, + AddonModLessonAttemptsOverviewsStudentWSData, + AddonModLessonGetUserAttemptWSResponse, + AddonModLessonLessonWSData, + AddonModLessonProvider, + AddonModLessonUserAttemptAnswerData, + AddonModLessonUserAttemptAnswerPageWSData, +} from '../../services/lesson'; +import { AddonModLessonAnswerData, AddonModLessonHelper } from '../../services/lesson-helper'; +import { CoreTimeUtils } from '@services/utils/time'; + +/** + * Page that displays a retake made by a certain user. + */ +@Component({ + selector: 'page-addon-mod-lesson-user-retake', + templateUrl: 'user-retake.html', + styleUrls: ['user-retake.scss'], +}) +export class AddonModLessonUserRetakePage implements OnInit { + + component = AddonModLessonProvider.COMPONENT; + lesson?: AddonModLessonLessonWSData; // The lesson the retake belongs to. + courseId!: number; // Course ID the lesson belongs to. + selectedRetake?: number; // The retake to see. + student?: StudentData; // Data about the student and his retakes. + retake?: RetakeToDisplay; // Data about the retake. + loaded?: boolean; // Whether the data has been loaded. + timeTakenReadable?: string; // Time taken in a readable format. + + protected lessonId!: number; // The lesson ID the retake belongs to. + protected userId?: number; // User ID to see the retakes. + protected retakeNumber?: number; // Number of the initial retake to see. + protected previousSelectedRetake?: number; // To be able to detect the previous selected retake when it has changed. + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.lessonId = CoreNavigator.instance.getRouteNumberParam('lessonId')!; + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!; + this.userId = CoreNavigator.instance.getRouteNumberParam('userId') || CoreSites.instance.getCurrentSiteUserId(); + this.retakeNumber = CoreNavigator.instance.getRouteNumberParam('retake'); + + // Fetch the data. + this.fetchData().finally(() => { + this.loaded = true; + }); + } + + /** + * Change the retake displayed. + * + * @param retakeNumber The new retake number. + */ + async changeRetake(retakeNumber: number): Promise { + this.loaded = false; + + try { + await this.setRetake(retakeNumber); + } catch (error) { + this.selectedRetake = this.previousSelectedRetake; + CoreDomUtils.instance.showErrorModal(CoreUtils.instance.addDataNotDownloadedError(error, 'Error getting attempt.')); + } finally { + this.loaded = true; + } + } + + /** + * Pull to refresh. + * + * @param refresher Refresher. + */ + doRefresh(refresher: CustomEvent): void { + this.refreshData().finally(() => { + refresher?.detail.complete(); + }); + } + + /** + * Get lesson and retake data. + * + * @return Promise resolved when done. + */ + protected async fetchData(): Promise { + try { + this.lesson = await AddonModLesson.instance.getLessonById(this.courseId, this.lessonId); + + // Get the retakes overview for all participants. + const data = await AddonModLesson.instance.getRetakesOverview(this.lesson.id, { + cmId: this.lesson.coursemodule, + }); + + // Search the student. + const student: StudentData | undefined = data?.students?.find(student => student.id == this.userId); + if (!student) { + // Student not found. + throw new CoreError(Translate.instance.instant('addon.mod_lesson.cannotfinduser')); + } + + if (!student.attempts.length) { + // No retakes. + throw new CoreError(Translate.instance.instant('addon.mod_lesson.cannotfindattempt')); + } + + student.bestgrade = CoreTextUtils.instance.roundToDecimals(student.bestgrade, 2); + student.attempts.forEach((retake) => { + if (!this.selectedRetake && this.retakeNumber == retake.try) { + // The retake specified as parameter exists. Use it. + this.selectedRetake = this.retakeNumber; + } + + retake.label = AddonModLessonHelper.instance.getRetakeLabel(retake); + }); + + if (!this.selectedRetake) { + // Retake number not specified or not valid, use the last retake. + this.selectedRetake = student.attempts[student.attempts.length - 1].try; + } + + // Get the profile image of the user. + const user = await CoreUtils.instance.ignoreErrors(CoreUser.instance.getProfile(student.id, this.courseId, true)); + + this.student = student; + this.student.profileimageurl = user?.profileimageurl; + + await this.setRetake(this.selectedRetake); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting data.', true); + } + } + + /** + * Refreshes data. + * + * @return Promise resolved when done. + */ + protected async refreshData(): Promise { + const promises: Promise[] = []; + + promises.push(AddonModLesson.instance.invalidateLessonData(this.courseId)); + if (this.lesson) { + promises.push(AddonModLesson.instance.invalidateRetakesOverview(this.lesson.id)); + promises.push(AddonModLesson.instance.invalidateUserRetakesForUser(this.lesson.id, this.userId)); + } + + await CoreUtils.instance.ignoreErrors(Promise.all(promises)); + + await this.fetchData(); + } + + /** + * Set the retake to view and load its data. + * + * @param retakeNumber Retake number to set. + * @return Promise resolved when done. + */ + protected async setRetake(retakeNumber: number): Promise { + this.selectedRetake = retakeNumber; + + const retakeData = await AddonModLesson.instance.getUserRetake(this.lessonId, retakeNumber, { + cmId: this.lesson!.coursemodule, + userId: this.userId, + }); + + this.retake = this.formatRetake(retakeData); + this.previousSelectedRetake = this.selectedRetake; + } + + /** + * Format retake data, adding some calculated data. + * + * @param data Retake data. + * @return Formatted data. + */ + protected formatRetake(retakeData: AddonModLessonGetUserAttemptWSResponse): RetakeToDisplay { + const formattedData = retakeData; + + if (formattedData.userstats.gradeinfo) { + // Completed. + formattedData.userstats.grade = CoreTextUtils.instance.roundToDecimals(formattedData.userstats.grade, 2); + this.timeTakenReadable = CoreTimeUtils.instance.formatTime(formattedData.userstats.timetotake); + } + + // Format pages data. + formattedData.answerpages.forEach((page) => { + if (AddonModLesson.instance.answerPageIsContent(page)) { + page.isContent = true; + + if (page.answerdata?.answers) { + page.answerdata.answers.forEach((answer) => { + // Content pages only have 1 valid field in the answer array. + answer[0] = AddonModLessonHelper.instance.getContentPageAnswerDataFromHtml(answer[0]); + }); + } + } else if (AddonModLesson.instance.answerPageIsQuestion(page)) { + page.isQuestion = true; + + if (page.answerdata?.answers) { + page.answerdata.answers.forEach((answer) => { + // Only the first field of the answer array requires to be parsed. + answer[0] = AddonModLessonHelper.instance.getQuestionPageAnswerDataFromHtml(answer[0]); + }); + } + } + }); + + return formattedData; + } + +} + +/** + * Student data with some calculated data. + */ +type StudentData = Omit & { + profileimageurl?: string; + attempts: AttemptWithLabel[]; +}; + +/** + * Student attempt with a calculated label. + */ +type AttemptWithLabel = AddonModLessonAttemptsOverviewsAttemptWSData & { + label?: string; +}; +/** + * Retake with calculated data. + */ +type RetakeToDisplay = Omit & { + answerpages: AnswerPage[]; +}; + +/** + * Answer page with calculated data. + */ +type AnswerPage = Omit & { + isContent?: boolean; + isQuestion?: boolean; + answerdata?: AnswerData; +}; + +/** + * Answer data with calculated data. + */ +type AnswerData = Omit & { + answers?: (string[] | AddonModLessonAnswerData)[]; // User answers. +}; diff --git a/src/addons/mod/lesson/services/handlers/module.ts b/src/addons/mod/lesson/services/handlers/module.ts index e32e1af54..008da58cf 100644 --- a/src/addons/mod/lesson/services/handlers/module.ts +++ b/src/addons/mod/lesson/services/handlers/module.ts @@ -30,7 +30,7 @@ import { makeSingleton } from '@singletons'; @Injectable({ providedIn: 'root' }) export class AddonModLessonModuleHandlerService implements CoreCourseModuleHandler { - static readonly PAGE_NAME = 'lesson'; + static readonly PAGE_NAME = 'mod_lesson'; name = 'AddonModLesson'; modName = 'lesson'; diff --git a/src/addons/mod/lesson/services/lesson.ts b/src/addons/mod/lesson/services/lesson.ts index 315778b98..d6d787921 100644 --- a/src/addons/mod/lesson/services/lesson.ts +++ b/src/addons/mod/lesson/services/lesson.ts @@ -4070,12 +4070,17 @@ export type AddonModLessonUserAttemptAnswerPageWSData = { contents: string; // Page contents. qtype: string; // Identifies the page type of this page. grayout: number; // If is required to apply a grayout. - answerdata?: { - score: string; // The score (text version). - response: string; // The response text. - responseformat: number; // Response. format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). - answers?: string[][]; // User answers. - }; // Answer data (empty in content pages created in Moodle 1.x). + answerdata?: AddonModLessonUserAttemptAnswerData; // Answer data (empty in content pages created in Moodle 1.x). +}; + +/** + * Answer data of a user attempt answer page. + */ +export type AddonModLessonUserAttemptAnswerData = { + score: string; // The score (text version). + response: string; // The response text. + responseformat: number; // Response. format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + answers?: string[][]; // User answers. }; /** diff --git a/src/theme/theme.light.scss b/src/theme/theme.light.scss index 2539c4a8a..770cc7f96 100644 --- a/src/theme/theme.light.scss +++ b/src/theme/theme.light.scss @@ -17,6 +17,8 @@ --white: #{$white}; --blue: #{$blue}; + --blue-dark: #{$blue-dark}; + --blue-light: #{$blue-light}; --turquoise: #{$turquoise}; --green: #{$green}; --red: #{$red}; From feb0319d3f642131e462584db591ff8c57c93214 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 4 Feb 2021 09:22:35 +0100 Subject: [PATCH 08/10] MOBILE-3648 lesson: Implement link and push handlers --- .../badges/services/handlers/badge-link.ts | 4 +- .../badges/services/handlers/mybadges-link.ts | 2 +- .../calendar/services/handlers/view-link.ts | 13 +- .../services/handlers/discussion-link.ts | 10 +- src/addons/mod/lesson/lesson.module.ts | 16 +- .../lesson/services/handlers/grade-link.ts | 102 +++++++++++ .../lesson/services/handlers/index-link.ts | 121 +++++++++++++ .../mod/lesson/services/handlers/list-link.ts | 45 +++++ .../lesson/services/handlers/push-click.ts | 70 ++++++++ .../lesson/services/handlers/report-link.ts | 165 ++++++++++++++++++ .../contentlinks/classes/base-handler.ts | 5 +- .../classes/module-grade-handler.ts | 20 ++- .../classes/module-index-handler.ts | 44 +++-- .../classes/module-list-handler.ts | 8 +- .../services/contentlinks-delegate.ts | 16 +- .../features/course/services/course-helper.ts | 116 +++++++++++- .../services/handlers/dashboard-link.ts | 2 +- .../grades/services/handlers/overview-link.ts | 2 +- .../grades/services/handlers/user-link.ts | 12 +- .../pushnotifications.module.ts | 7 + .../sitehome/services/handlers/index-link.ts | 4 +- .../tag/services/handlers/index-link.ts | 6 +- .../tag/services/handlers/search-link.ts | 10 +- .../user/services/handlers/profile-link.ts | 5 +- 24 files changed, 732 insertions(+), 73 deletions(-) create mode 100644 src/addons/mod/lesson/services/handlers/grade-link.ts create mode 100644 src/addons/mod/lesson/services/handlers/index-link.ts create mode 100644 src/addons/mod/lesson/services/handlers/list-link.ts create mode 100644 src/addons/mod/lesson/services/handlers/push-click.ts create mode 100644 src/addons/mod/lesson/services/handlers/report-link.ts diff --git a/src/addons/badges/services/handlers/badge-link.ts b/src/addons/badges/services/handlers/badge-link.ts index 7b09881ef..617bddc50 100644 --- a/src/addons/badges/services/handlers/badge-link.ts +++ b/src/addons/badges/services/handlers/badge-link.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { Params } from '@angular/router'; + import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; import { CoreNavigator } from '@services/navigator'; @@ -39,7 +39,7 @@ export class AddonBadgesBadgeLinkHandlerService extends CoreContentLinksHandlerB * @param courseId Course ID related to the URL. Optional but recommended. * @return List of (or promise resolved with list of) actions. */ - getActions(siteIds: string[], url: string, params: Params): CoreContentLinksAction[] { + getActions(siteIds: string[], url: string, params: Record): CoreContentLinksAction[] { return [{ action: (siteId: string): void => { diff --git a/src/addons/badges/services/handlers/mybadges-link.ts b/src/addons/badges/services/handlers/mybadges-link.ts index ee7b19db5..519b8990a 100644 --- a/src/addons/badges/services/handlers/mybadges-link.ts +++ b/src/addons/badges/services/handlers/mybadges-link.ts @@ -49,7 +49,7 @@ export class AddonBadgesMyBadgesLinkHandlerService extends CoreContentLinksHandl * @param siteId The site ID. * @return Whether the handler is enabled for the URL and site. */ - isEnabled(siteId: string): boolean | Promise { + async isEnabled(siteId: string): Promise { return AddonBadges.instance.isPluginEnabled(siteId); } diff --git a/src/addons/calendar/services/handlers/view-link.ts b/src/addons/calendar/services/handlers/view-link.ts index 1aa0bb8bd..1bd87d619 100644 --- a/src/addons/calendar/services/handlers/view-link.ts +++ b/src/addons/calendar/services/handlers/view-link.ts @@ -14,6 +14,7 @@ import { Injectable } from '@angular/core'; import { Params } from '@angular/router'; + import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; import { CoreNavigator } from '@services/navigator'; @@ -39,7 +40,11 @@ export class AddonCalendarViewLinkHandlerService extends CoreContentLinksHandler * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} * @return List of (or promise resolved with list of) actions. */ - getActions(siteIds: string[], url: string, params: Params): CoreContentLinksAction[] | Promise { + getActions( + siteIds: string[], + url: string, + params: Record, + ): CoreContentLinksAction[] | Promise { return [{ action: (siteId?: string): void => { if (!params.view || params.view == 'month' || params.view == 'mini' || params.view == 'minithree') { @@ -47,7 +52,7 @@ export class AddonCalendarViewLinkHandlerService extends CoreContentLinksHandler const stateParams: Params = { courseId: params.course, }; - const timestamp = params.time ? params.time * 1000 : Date.now(); + const timestamp = params.time ? Number(params.time) * 1000 : Date.now(); const date = new Date(timestamp); stateParams.year = date.getFullYear(); @@ -61,7 +66,7 @@ export class AddonCalendarViewLinkHandlerService extends CoreContentLinksHandler const stateParams: Params = { courseId: params.course, }; - const timestamp = params.time ? params.time * 1000 : Date.now(); + const timestamp = params.time ? Number(params.time) * 1000 : Date.now(); const date = new Date(timestamp); stateParams.year = date.getFullYear(); @@ -94,7 +99,7 @@ export class AddonCalendarViewLinkHandlerService extends CoreContentLinksHandler * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} * @return Whether the handler is enabled for the URL and site. */ - isEnabled(siteId: string, url: string, params: Params): boolean | Promise { + async isEnabled(siteId: string, url: string, params: Record): Promise { if (params.view && SUPPORTED_VIEWS.indexOf(params.view) == -1) { // This type of view isn't supported in the app. return false; diff --git a/src/addons/messages/services/handlers/discussion-link.ts b/src/addons/messages/services/handlers/discussion-link.ts index 48d3853d8..25f488ec3 100644 --- a/src/addons/messages/services/handlers/discussion-link.ts +++ b/src/addons/messages/services/handlers/discussion-link.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { Params } from '@angular/router'; + import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; import { CoreNavigator } from '@services/navigator'; @@ -39,7 +39,11 @@ export class AddonMessagesDiscussionLinkHandlerService extends CoreContentLinksH * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} * @return List of (or promise resolved with list of) actions. */ - getActions(siteIds: string[], url: string, params: Params): CoreContentLinksAction[] | Promise { + getActions( + siteIds: string[], + url: string, + params: Record, + ): CoreContentLinksAction[] | Promise { return [{ action: (siteId): void => { const stateParams = { @@ -59,7 +63,7 @@ export class AddonMessagesDiscussionLinkHandlerService extends CoreContentLinksH * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} * @return Whether the handler is enabled for the URL and site. */ - async isEnabled(siteId: string, url: string, params: Params): Promise { + async isEnabled(siteId: string, url: string, params: Record): Promise { const enabled = await AddonMessages.instance.isPluginEnabled(siteId); if (!enabled) { return false; diff --git a/src/addons/mod/lesson/lesson.module.ts b/src/addons/mod/lesson/lesson.module.ts index 39a7ca269..0d53428a7 100644 --- a/src/addons/mod/lesson/lesson.module.ts +++ b/src/addons/mod/lesson/lesson.module.ts @@ -14,16 +14,23 @@ import { APP_INITIALIZER, NgModule } from '@angular/core'; import { Routes } from '@angular/router'; +import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate'; import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate'; import { CoreCronDelegate } from '@services/cron'; import { CORE_SITE_SCHEMAS } from '@services/sites'; import { AddonModLessonComponentsModule } from './components/components.module'; -import { SITE_SCHEMA, OFFLINE_SITE_SCHEMA } from './services/database/lesson'; +import { SITE_SCHEMA, OFFLINE_SITE_SCHEMA, SYNC_SITE_SCHEMA } from './services/database/lesson'; +import { AddonModLessonGradeLinkHandler } from './services/handlers/grade-link'; +import { AddonModLessonIndexLinkHandler } from './services/handlers/index-link'; +import { AddonModLessonListLinkHandler } from './services/handlers/list-link'; import { AddonModLessonModuleHandler, AddonModLessonModuleHandlerService } from './services/handlers/module'; import { AddonModLessonPrefetchHandler } from './services/handlers/prefetch'; +import { AddonModLessonPushClickHandler } from './services/handlers/push-click'; +import { AddonModLessonReportLinkHandler } from './services/handlers/report-link'; import { AddonModLessonSyncCronHandler } from './services/handlers/sync-cron'; const routes: Routes = [ @@ -41,7 +48,7 @@ const routes: Routes = [ providers: [ { provide: CORE_SITE_SCHEMAS, - useValue: [SITE_SCHEMA, OFFLINE_SITE_SCHEMA], + useValue: [SITE_SCHEMA, OFFLINE_SITE_SCHEMA, SYNC_SITE_SCHEMA], multi: true, }, { @@ -52,6 +59,11 @@ const routes: Routes = [ CoreCourseModuleDelegate.instance.registerHandler(AddonModLessonModuleHandler.instance); CoreCourseModulePrefetchDelegate.instance.registerHandler(AddonModLessonPrefetchHandler.instance); CoreCronDelegate.instance.register(AddonModLessonSyncCronHandler.instance); + CoreContentLinksDelegate.instance.registerHandler(AddonModLessonGradeLinkHandler.instance); + CoreContentLinksDelegate.instance.registerHandler(AddonModLessonIndexLinkHandler.instance); + CoreContentLinksDelegate.instance.registerHandler(AddonModLessonListLinkHandler.instance); + CoreContentLinksDelegate.instance.registerHandler(AddonModLessonReportLinkHandler.instance); + CorePushNotificationsDelegate.instance.registerClickHandler(AddonModLessonPushClickHandler.instance); }, }, ], diff --git a/src/addons/mod/lesson/services/handlers/grade-link.ts b/src/addons/mod/lesson/services/handlers/grade-link.ts new file mode 100644 index 000000000..49df7a159 --- /dev/null +++ b/src/addons/mod/lesson/services/handlers/grade-link.ts @@ -0,0 +1,102 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; + +import { CoreContentLinksModuleGradeHandler } from '@features/contentlinks/classes/module-grade-handler'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreCourseHelper } from '@features/course/services/course-helper'; +import { CoreNavigator } from '@services/navigator'; +import { CoreDomUtils } from '@services/utils/dom'; +import { makeSingleton } from '@singletons'; +import { AddonModLesson } from '../lesson'; +import { AddonModLessonModuleHandlerService } from './module'; + +/** + * Handler to treat links to lesson grade. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModLessonGradeLinkHandlerService extends CoreContentLinksModuleGradeHandler { + + name = 'AddonModLessonGradeLinkHandler'; + canReview = true; + + constructor() { + super('AddonModLesson', 'lesson'); + } + + /** + * Go to the page to review. + * + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. + * @param siteId Site to use. + * @return Promise resolved when done. + */ + protected async goToReview( + url: string, + params: Record, + courseId: number, + siteId: string, + ): Promise { + const moduleId = Number(params.id); + + const modal = await CoreDomUtils.instance.showModalLoading(); + + try { + const module = await CoreCourse.instance.getModuleBasicInfo(moduleId, siteId); + courseId = Number(module.course || courseId || params.courseid || params.cid); + + // Check if the user can see the user reports in the lesson. + const accessInfo = await AddonModLesson.instance.getAccessInformation(module.instance, { cmId: module.id, siteId }); + + if (accessInfo.canviewreports) { + // User can view reports, go to view the report. + CoreNavigator.instance.navigateToSitePath( + AddonModLessonModuleHandlerService.PAGE_NAME + `/user-retake/${courseId}/${module.instance}`, + { + params: { userId: Number(params.userid) }, + siteId, + }, + ); + } else { + // User cannot view the report, go to lesson index. + CoreCourseHelper.instance.navigateToModule(moduleId, siteId, courseId, module.section); + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errorgetmodule', true); + } finally { + modal.dismiss(); + } + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param siteId The site ID. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @return Whether the handler is enabled for the URL and site. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async isEnabled(siteId: string, url: string, params: Record, courseId?: number): Promise { + return AddonModLesson.instance.isPluginEnabled(siteId); + } + +} + +export class AddonModLessonGradeLinkHandler extends makeSingleton(AddonModLessonGradeLinkHandlerService) {} diff --git a/src/addons/mod/lesson/services/handlers/index-link.ts b/src/addons/mod/lesson/services/handlers/index-link.ts new file mode 100644 index 000000000..f49ea7c07 --- /dev/null +++ b/src/addons/mod/lesson/services/handlers/index-link.ts @@ -0,0 +1,121 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; + +import { CoreContentLinksModuleIndexHandler } from '@features/contentlinks/classes/module-index-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreCourseHelper } from '@features/course/services/course-helper'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { AddonModLesson } from '../lesson'; + +/** + * Handler to treat links to lesson index. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModLessonIndexLinkHandlerService extends CoreContentLinksModuleIndexHandler { + + name = 'AddonModLessonIndexLinkHandler'; + + constructor() { + super('AddonModLesson', 'lesson'); + } + + /** + * Get the list of actions for a link (url). + * + * @param siteIds List of sites the URL belongs to. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @return List of (or promise resolved with list of) actions. + */ + getActions( + siteIds: string[], + url: string, + params: Record, + courseId?: number, + ): CoreContentLinksAction[] | Promise { + + courseId = Number(courseId || params.courseid || params.cid); + + return [{ + action: (siteId): void => { + /* Ignore the pageid param. If we open the lesson player with a certain page and the user hasn't started + the lesson, an error is thrown: could not find lesson_timer records. */ + if (params.userpassword) { + this.navigateToModuleWithPassword(parseInt(params.id, 10), courseId!, params.userpassword, siteId); + } else { + CoreCourseHelper.instance.navigateToModule(parseInt(params.id, 10), siteId, courseId); + } + }, + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param siteId The site ID. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @return Whether the handler is enabled for the URL and site. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isEnabled(siteId: string, url: string, params: Record, courseId?: number): Promise { + return AddonModLesson.instance.isPluginEnabled(siteId); + } + + /** + * Navigate to a lesson module (index page) with a fixed password. + * + * @param moduleId Module ID. + * @param courseId Course ID. + * @param password Password. + * @param siteId Site ID. + * @return Promise resolved when navigated. + */ + protected async navigateToModuleWithPassword( + moduleId: number, + courseId: number, + password: string, + siteId: string, + ): Promise { + const modal = await CoreDomUtils.instance.showModalLoading(); + + try { + // Get the module. + const module = await CoreCourse.instance.getModuleBasicInfo(moduleId, siteId); + + courseId = courseId || module.course; + + // Store the password so it's automatically used. + await CoreUtils.instance.ignoreErrors(AddonModLesson.instance.storePassword(module.instance, password, siteId)); + + await CoreCourseHelper.instance.navigateToModule(moduleId, siteId, courseId, module.section); + } catch { + // Error, go to index page. + await CoreCourseHelper.instance.navigateToModule(moduleId, siteId, courseId); + } finally { + modal.dismiss(); + } + } + +} + +export class AddonModLessonIndexLinkHandler extends makeSingleton(AddonModLessonIndexLinkHandlerService) {} diff --git a/src/addons/mod/lesson/services/handlers/list-link.ts b/src/addons/mod/lesson/services/handlers/list-link.ts new file mode 100644 index 000000000..ac466dfa1 --- /dev/null +++ b/src/addons/mod/lesson/services/handlers/list-link.ts @@ -0,0 +1,45 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; + +import { CoreContentLinksModuleListHandler } from '@features/contentlinks/classes/module-list-handler'; +import { makeSingleton } from '@singletons'; +import { AddonModLesson } from '../lesson'; + +/** + * Handler to treat links to lesson list page. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModLessonListLinkHandlerService extends CoreContentLinksModuleListHandler { + + name = 'AddonModLessonListLinkHandler'; + + constructor() { + super('AddonModLesson', 'lesson'); + } + + /** + * Check if the handler is enabled on a site level. + * + * @return Promise resolved with boolean: whether or not the handler is enabled on a site level. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isEnabled(siteId: string, url: string, params: Record, courseId?: number): Promise { + return AddonModLesson.instance.isPluginEnabled(siteId); + } + +} + +export class AddonModLessonListLinkHandler extends makeSingleton(AddonModLessonListLinkHandlerService) {} diff --git a/src/addons/mod/lesson/services/handlers/push-click.ts b/src/addons/mod/lesson/services/handlers/push-click.ts new file mode 100644 index 000000000..82aa5f647 --- /dev/null +++ b/src/addons/mod/lesson/services/handlers/push-click.ts @@ -0,0 +1,70 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; + +import { CoreGrades } from '@features/grades/services/grades'; +import { CoreGradesHelper } from '@features/grades/services/grades-helper'; +import { CorePushNotificationsClickHandler } from '@features/pushnotifications/services/push-delegate'; +import { CorePushNotificationsNotificationBasicData } from '@features/pushnotifications/services/pushnotifications'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; + +/** + * Handler for lesson push notifications clicks. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModLessonPushClickHandlerService implements CorePushNotificationsClickHandler { + + name = 'AddonModLessonPushClickHandler'; + priority = 200; + featureName = 'CoreCourseModuleDelegate_AddonModLesson'; + + /** + * Check if a notification click is handled by this handler. + * + * @param notification The notification to check. + * @return Whether the notification click is handled by this handler. + */ + async handles(notification: NotificationData): Promise { + if (CoreUtils.instance.isTrueOrOne(notification.notif) && notification.moodlecomponent == 'mod_lesson' && + notification.name == 'graded_essay') { + + return CoreGrades.instance.isPluginEnabledForCourse(Number(notification.courseid), notification.site); + } + + return false; + } + + /** + * Handle the notification click. + * + * @param notification The notification to check. + * @return Promise resolved when done. + */ + handleClick(notification: NotificationData): Promise { + const data = notification.customdata || {}; + const courseId = Number(notification.courseid); + const moduleId = Number(data.cmid); + + return CoreGradesHelper.instance.goToGrades(courseId, undefined, moduleId, notification.site); + } + +} + +export class AddonModLessonPushClickHandler extends makeSingleton(AddonModLessonPushClickHandlerService) {} + +type NotificationData = CorePushNotificationsNotificationBasicData & { + courseid: number; +}; diff --git a/src/addons/mod/lesson/services/handlers/report-link.ts b/src/addons/mod/lesson/services/handlers/report-link.ts new file mode 100644 index 000000000..eee37ed45 --- /dev/null +++ b/src/addons/mod/lesson/services/handlers/report-link.ts @@ -0,0 +1,165 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; + +import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreNavigator } from '@services/navigator'; +import { CoreDomUtils } from '@services/utils/dom'; +import { makeSingleton } from '@singletons'; +import { AddonModLesson } from '../lesson'; +import { AddonModLessonModuleHandlerService } from './module'; + +/** + * Handler to treat links to lesson report. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModLessonReportLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonModLessonReportLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModLesson'; + pattern = /\/mod\/lesson\/report\.php.*([&?]id=\d+)/; + + /** + * Get the list of actions for a link (url). + * + * @param siteIds List of sites the URL belongs to. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @param data Extra data to handle the URL. + * @return List of (or promise resolved with list of) actions. + */ + getActions( + siteIds: string[], + url: string, + params: Record, + courseId?: number, + data?: unknown, // eslint-disable-line @typescript-eslint/no-unused-vars + ): CoreContentLinksAction[] | Promise { + courseId = Number(courseId || params.courseid || params.cid); + + return [{ + action: (siteId) => { + if (!params.action || params.action == 'reportoverview') { + // Go to overview. + this.openReportOverview(Number(params.id), courseId, Number(params.group), siteId); + } else if (params.action == 'reportdetail') { + this.openUserRetake(Number(params.id), Number(params.userid), Number(params.try), siteId, courseId); + } + }, + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param siteId The site ID. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @return Whether the handler is enabled for the URL and site. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async isEnabled(siteId: string, url: string, params: Record, courseId?: number): Promise { + if (params.action == 'reportdetail' && !params.userid) { + // Individual details are only available if the teacher is seeing a certain user. + return false; + } + + return AddonModLesson.instance.isPluginEnabled(siteId); + } + + /** + * Open report overview. + * + * @param moduleId Module ID. + * @param courseId Course ID. + * @param groupId Group ID. + * @param siteId Site ID. + * @param navCtrl The NavController to use to navigate. + * @return Promise resolved when done. + */ + protected async openReportOverview(moduleId: number, courseId?: number, groupId?: number, siteId?: string): Promise { + + const modal = await CoreDomUtils.instance.showModalLoading(); + + try { + // Get the module object. + const module = await CoreCourse.instance.getModuleBasicInfo(moduleId, siteId); + + const params = { + module: module, + courseId: courseId || module.course, + action: 'report', + group: groupId === undefined || isNaN(groupId) ? null : groupId, + }; + + CoreNavigator.instance.navigateToSitePath(AddonModLessonModuleHandlerService.PAGE_NAME, { params, siteId }); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error processing link.'); + } finally { + modal.dismiss(); + } + } + + /** + * Open a user's retake. + * + * @param moduleId Module ID. + * @param userId User ID. + * @param courseId Course ID. + * @param retake Retake to open. + * @param groupId Group ID. + * @param siteId Site ID. + * @param navCtrl The NavController to use to navigate. + * @return Promise resolved when done. + */ + protected async openUserRetake( + moduleId: number, + userId: number, + retake: number, + siteId: string, + courseId?: number, + ): Promise { + + const modal = await CoreDomUtils.instance.showModalLoading(); + + try { + // Get the module object. + const module = await CoreCourse.instance.getModuleBasicInfo(moduleId, siteId); + + courseId = courseId || module.course; + const params = { + userId: userId, + retake: retake || 0, + }; + + CoreNavigator.instance.navigateToSitePath( + AddonModLessonModuleHandlerService.PAGE_NAME + `/user-retake/${courseId}/${module.instance}`, + { params, siteId }, + ); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error processing link.'); + } finally { + modal.dismiss(); + } + } + +} + +export class AddonModLessonReportLinkHandler extends makeSingleton(AddonModLessonReportLinkHandlerService) {} diff --git a/src/core/features/contentlinks/classes/base-handler.ts b/src/core/features/contentlinks/classes/base-handler.ts index 0e0e43d3a..a2809c552 100644 --- a/src/core/features/contentlinks/classes/base-handler.ts +++ b/src/core/features/contentlinks/classes/base-handler.ts @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Params } from '@angular/router'; import { CoreContentLinksHandler, CoreContentLinksAction } from '../services/contentlinks-delegate'; /** @@ -67,7 +66,7 @@ export class CoreContentLinksHandlerBase implements CoreContentLinksHandler { // eslint-disable-next-line @typescript-eslint/no-unused-vars url: string, // eslint-disable-next-line @typescript-eslint/no-unused-vars - params: Params, + params: Record, // eslint-disable-next-line @typescript-eslint/no-unused-vars courseId?: number, // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -112,7 +111,7 @@ export class CoreContentLinksHandlerBase implements CoreContentLinksHandler { * @return Whether the handler is enabled for the URL and site. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - isEnabled(siteId: string, url: string, params: Params, courseId?: number): boolean | Promise { + async isEnabled(siteId: string, url: string, params: Record, courseId?: number): Promise { return true; } diff --git a/src/core/features/contentlinks/classes/module-grade-handler.ts b/src/core/features/contentlinks/classes/module-grade-handler.ts index 704d152c0..6ef3e76c5 100644 --- a/src/core/features/contentlinks/classes/module-grade-handler.ts +++ b/src/core/features/contentlinks/classes/module-grade-handler.ts @@ -16,8 +16,7 @@ import { CoreContentLinksAction } from '../services/contentlinks-delegate'; import { CoreContentLinksHandlerBase } from './base-handler'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; -// import { CoreCourseHelper } from '@features/course/services/helper'; -import { Params } from '@angular/router'; +import { CoreCourseHelper } from '@features/course/services/course-helper'; /** * Handler to handle URLs pointing to the grade of a module. @@ -64,21 +63,26 @@ export class CoreContentLinksModuleGradeHandler extends CoreContentLinksHandlerB getActions( siteIds: string[], url: string, - params: Params, + params: Record, courseId?: number, ): CoreContentLinksAction[] | Promise { - courseId = courseId || params.courseid || params.cid; + courseId = Number(courseId || params.courseid || params.cid); return [{ action: async (siteId): Promise => { // Check if userid is the site's current user. const modal = await CoreDomUtils.instance.showModalLoading(); const site = await CoreSites.instance.getSite(siteId); - if (!params.userid || params.userid == site.getUserId()) { + if (!params.userid || Number(params.userid) == site.getUserId()) { // No user specified or current user. Navigate to module. - // @todo CoreCourseHelper.instance.navigateToModule(parseInt(params.id, 10), siteId, courseId, undefined, - // this.useModNameToGetModule ? this.modName : undefined, undefined, navCtrl); + CoreCourseHelper.instance.navigateToModule( + Number(params.id), + siteId, + courseId, + undefined, + this.useModNameToGetModule ? this.modName : undefined, + ); } else if (this.canReview) { // Use the goToReview function. this.goToReview(url, params, courseId!, siteId); @@ -103,7 +107,7 @@ export class CoreContentLinksModuleGradeHandler extends CoreContentLinksHandlerB */ protected async goToReview( url: string, // eslint-disable-line @typescript-eslint/no-unused-vars - params: Params, // eslint-disable-line @typescript-eslint/no-unused-vars + params: Record, // eslint-disable-line @typescript-eslint/no-unused-vars courseId: number, // eslint-disable-line @typescript-eslint/no-unused-vars siteId: string, // eslint-disable-line @typescript-eslint/no-unused-vars ): Promise { diff --git a/src/core/features/contentlinks/classes/module-index-handler.ts b/src/core/features/contentlinks/classes/module-index-handler.ts index 786e44dc1..e0a3175eb 100644 --- a/src/core/features/contentlinks/classes/module-index-handler.ts +++ b/src/core/features/contentlinks/classes/module-index-handler.ts @@ -15,6 +15,7 @@ import { CoreContentLinksHandlerBase } from './base-handler'; import { Params } from '@angular/router'; import { CoreContentLinksAction } from '../services/contentlinks-delegate'; +import { CoreCourseHelper } from '@features/course/services/course-helper'; /** * Handler to handle URLs pointing to the index of a module. @@ -59,8 +60,8 @@ export class CoreContentLinksModuleIndexHandler extends CoreContentLinksHandlerB * @return List of params to pass to navigateToModule / navigateToModuleByInstance. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - getPageParams(url: string, params: Params, courseId?: number): Params { - return []; + getPageParams(url: string, params: Record, courseId?: number): Params { + return {}; } /** @@ -73,34 +74,45 @@ export class CoreContentLinksModuleIndexHandler extends CoreContentLinksHandlerB * @return List of (or promise resolved with list of) actions. */ getActions( - siteIds: string[], // eslint-disable-line @typescript-eslint/no-unused-vars - url: string, // eslint-disable-line @typescript-eslint/no-unused-vars - params: Params, // eslint-disable-line @typescript-eslint/no-unused-vars - courseId?: number, // eslint-disable-line @typescript-eslint/no-unused-vars + siteIds: string[], + url: string, + params: Record, + courseId?: number, ): CoreContentLinksAction[] | Promise { - return []; - /* - courseId = courseId || params.courseid || params.cid; + + courseId = Number(courseId || params.courseid || params.cid); const pageParams = this.getPageParams(url, params, courseId); if (this.instanceIdParam && typeof params[this.instanceIdParam] != 'undefined') { const instanceId = parseInt(params[this.instanceIdParam], 10); return [{ - action: (siteId): void => { - this.courseHelper.navigateToModuleByInstance(instanceId, this.modName, siteId, courseId, undefined, - this.useModNameToGetModule, pageParams); + action: (siteId) => { + CoreCourseHelper.instance.navigateToModuleByInstance( + instanceId, + this.modName, + siteId, + courseId, + undefined, + this.useModNameToGetModule, + pageParams, + ); }, }]; } return [{ - action: (siteId): void => { - this.courseHelper.navigateToModule(parseInt(params.id, 10), siteId, courseId, undefined, - this.useModNameToGetModule ? this.modName : undefined, pageParams); + action: (siteId) => { + CoreCourseHelper.instance.navigateToModule( + parseInt(params.id, 10), + siteId, + courseId, + undefined, + this.useModNameToGetModule ? this.modName : undefined, + pageParams, + ); }, }]; - */ } } diff --git a/src/core/features/contentlinks/classes/module-list-handler.ts b/src/core/features/contentlinks/classes/module-list-handler.ts index 0a05df9e1..17cbf75af 100644 --- a/src/core/features/contentlinks/classes/module-list-handler.ts +++ b/src/core/features/contentlinks/classes/module-list-handler.ts @@ -14,7 +14,7 @@ import { CoreContentLinksHandlerBase } from './base-handler'; import { Translate } from '@singletons'; -import { Params } from '@angular/router'; + import { CoreContentLinksAction } from '../services/contentlinks-delegate'; import { CoreNavigator } from '@services/navigator'; @@ -53,7 +53,11 @@ export class CoreContentLinksModuleListHandler extends CoreContentLinksHandlerBa * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} * @return List of (or promise resolved with list of) actions. */ - getActions(siteIds: string[], url: string, params: Params): CoreContentLinksAction[] | Promise { + getActions( + siteIds: string[], + url: string, + params: Record, + ): CoreContentLinksAction[] | Promise { return [{ action: (siteId): void => { diff --git a/src/core/features/contentlinks/services/contentlinks-delegate.ts b/src/core/features/contentlinks/services/contentlinks-delegate.ts index 7328626f0..b295bc2ae 100644 --- a/src/core/features/contentlinks/services/contentlinks-delegate.ts +++ b/src/core/features/contentlinks/services/contentlinks-delegate.ts @@ -17,7 +17,6 @@ import { CoreLogger } from '@singletons/logger'; import { CoreSites } from '@services/sites'; import { CoreUrlUtils } from '@services/utils/url'; import { CoreUtils } from '@services/utils/utils'; -import { Params } from '@angular/router'; import { makeSingleton } from '@singletons'; /** @@ -56,8 +55,13 @@ export interface CoreContentLinksHandler { * @param data Extra data to handle the URL. * @return List of (or promise resolved with list of) actions. */ - getActions(siteIds: string[], url: string, params: Params, courseId?: number, data?: unknown): - CoreContentLinksAction[] | Promise; + getActions( + siteIds: string[], + url: string, + params: Record, + courseId?: number, + data?: unknown, + ): CoreContentLinksAction[] | Promise; /** * Check if a URL is handled by this handler. @@ -85,7 +89,7 @@ export interface CoreContentLinksHandler { * @param courseId Course ID related to the URL. Optional but recommended. * @return Whether the handler is enabled for the URL and site. */ - isEnabled?(siteId: string, url: string, params: Params, courseId?: number): boolean | Promise; + isEnabled?(siteId: string, url: string, params: Record, courseId?: number): Promise; } /** @@ -244,7 +248,7 @@ export class CoreContentLinksDelegateService { protected async isHandlerEnabled( handler: CoreContentLinksHandler, url: string, - params: Params, + params: Record, courseId: number, siteId: string, ): Promise { @@ -264,7 +268,7 @@ export class CoreContentLinksDelegateService { return true; } - return handler.isEnabled(siteId, url, params, courseId); + return await handler.isEnabled(siteId, url, params, courseId); } /** diff --git a/src/core/features/course/services/course-helper.ts b/src/core/features/course/services/course-helper.ts index 703405cba..ee3d56cf7 100644 --- a/src/core/features/course/services/course-helper.ts +++ b/src/core/features/course/services/course-helper.ts @@ -64,6 +64,9 @@ import { CoreTimeUtils } from '@services/utils/time'; import { CoreEventObserver, CoreEventPackageStatusChanged, CoreEvents } from '@singletons/events'; import { CoreFilterHelper } from '@features/filter/services/filter-helper'; import { CoreNetworkError } from '@classes/errors/network-error'; +import { CoreSiteHome } from '@features/sitehome/services/sitehome'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSiteHomeHomeHandlerService } from '@features/sitehome/services/handlers/sitehome-home'; /** * Prefetch info of a module. @@ -1392,8 +1395,35 @@ export class CoreCourseHelperProvider { * @param modParams Params to pass to the module * @return Promise resolved when done. */ - navigateToModuleByInstance(): void { - // @todo params and logic + async navigateToModuleByInstance( + instanceId: number, + modName: string, + siteId?: string, + courseId?: number, + sectionId?: number, + useModNameToGetModule: boolean = false, + modParams?: Params, + ): Promise { + + const modal = await CoreDomUtils.instance.showModalLoading(); + + try { + const module = await CoreCourse.instance.getModuleBasicInfoByInstance(instanceId, modName, siteId); + + this.navigateToModule( + module.id, + siteId, + module.course, + sectionId, + useModNameToGetModule ? modName : undefined, + modParams, + ); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errorgetmodule', true); + } finally { + // Just in case. In fact we need to dismiss the modal before showing a toast or error message. + modal.dismiss(); + } } /** @@ -1408,8 +1438,82 @@ export class CoreCourseHelperProvider { * @param modParams Params to pass to the module * @return Promise resolved when done. */ - navigateToModule(): void { - // @todo params and logic + async navigateToModule( + moduleId: number, + siteId?: string, + courseId?: number, + sectionId?: number, + modName?: string, + modParams?: Params, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const modal = await CoreDomUtils.instance.showModalLoading(); + + try { + if (!courseId) { + // We don't have courseId. + const module = await CoreCourse.instance.getModuleBasicInfo(moduleId, siteId); + + courseId = module.course; + sectionId = module.section; + } else if (!sectionId) { + // We don't have sectionId but we have courseId. + sectionId = await CoreCourse.instance.getModuleSectionId(moduleId, siteId); + } + + // Get the site. + const site = await CoreSites.instance.getSite(siteId); + + // Get the module. + const module = + await CoreCourse.instance.getModule(moduleId, courseId, sectionId, false, false, siteId, modName); + + if (CoreSites.instance.getCurrentSiteId() == site.getId()) { + // Try to use the module's handler to navigate cleanly. + module.handlerData = CoreCourseModuleDelegate.instance.getModuleDataFor( + module.modname, + module, + courseId, + sectionId, + false, + ); + + if (module.handlerData?.action) { + modal.dismiss(); + + return module.handlerData.action(new Event('click'), module, courseId, { params: modParams }); + } + } + + this.logger.warn('navCtrl was not passed to navigateToModule by the link handler for ' + module.modname); + + const params = { + course: { id: courseId }, + module: module, + sectionId: sectionId, + modParams: modParams, + }; + + if (courseId == site.getSiteHomeId()) { + // Check if site home is available. + const isAvailable = await CoreSiteHome.instance.isAvailable(); + + if (isAvailable) { + await CoreNavigator.instance.navigateToSitePath(CoreSiteHomeHomeHandlerService.PAGE_NAME, { params, siteId }); + + return; + } + } + + modal.dismiss(); + + await this.getAndOpenCourse(courseId, params, siteId); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errorgetmodule', true); + } finally { + modal.dismiss(); + } } /** @@ -1744,9 +1848,7 @@ export class CoreCourseHelperProvider { params = params || {}; Object.assign(params, { course: course }); - // @todo implement open course. - // await CoreNavigator.instance.navigateToSitePath('/course/.../...', { siteId, queryParams: params }); - // return CoreNavigator.instance.openInSiteMainMenu(CoreNavigatorService.OPEN_COURSE, params, siteId); + await CoreNavigator.instance.navigateToSitePath('course', { siteId, params }); } } diff --git a/src/core/features/courses/services/handlers/dashboard-link.ts b/src/core/features/courses/services/handlers/dashboard-link.ts index 31c643165..571de7db8 100644 --- a/src/core/features/courses/services/handlers/dashboard-link.ts +++ b/src/core/features/courses/services/handlers/dashboard-link.ts @@ -48,7 +48,7 @@ export class CoreCoursesDashboardLinkHandlerService extends CoreContentLinksHand * @param siteId The site ID. * @return Whether the handler is enabled for the URL and site. */ - isEnabled(siteId: string): boolean | Promise { + async isEnabled(siteId: string): Promise { return CoreDashboardHomeHandler.instance.isEnabledForSite(siteId); } diff --git a/src/core/features/grades/services/handlers/overview-link.ts b/src/core/features/grades/services/handlers/overview-link.ts index 325dff4a9..a13399431 100644 --- a/src/core/features/grades/services/handlers/overview-link.ts +++ b/src/core/features/grades/services/handlers/overview-link.ts @@ -48,7 +48,7 @@ export class CoreGradesOverviewLinkHandlerService extends CoreContentLinksHandle * @param siteId The site ID. * @return Whether the handler is enabled for the URL and site. */ - isEnabled(siteId: string): boolean | Promise { + async isEnabled(siteId: string): Promise { return CoreGrades.instance.isCourseGradesEnabled(siteId); } diff --git a/src/core/features/grades/services/handlers/user-link.ts b/src/core/features/grades/services/handlers/user-link.ts index 96ddd29e8..a70b6b7bd 100644 --- a/src/core/features/grades/services/handlers/user-link.ts +++ b/src/core/features/grades/services/handlers/user-link.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { Params } from '@angular/router'; + import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; import { CoreGrades } from '@features/grades/services/grades'; @@ -42,16 +42,16 @@ export class CoreGradesUserLinkHandlerService extends CoreContentLinksHandlerBas getActions( siteIds: string[], url: string, - params: Params, + params: Record, courseId?: number, data?: { cmid?: string }, ): CoreContentLinksAction[] | Promise { - courseId = courseId || params.id; + courseId = courseId || Number(params.id); data = data || {}; return [{ action: (siteId): void => { - const userId = params.userid && parseInt(params.userid, 10); + const userId = params.userid ? parseInt(params.userid, 10) : undefined; const moduleId = data?.cmid && parseInt(data.cmid, 10) || undefined; CoreGradesHelper.instance.goToGrades(courseId!, userId, moduleId, siteId); @@ -69,12 +69,12 @@ export class CoreGradesUserLinkHandlerService extends CoreContentLinksHandlerBas * @param courseId Course ID related to the URL. Optional but recommended. * @return Whether the handler is enabled for the URL and site. */ - isEnabled(siteId: string, url: string, params: Params, courseId?: number): boolean | Promise { + async isEnabled(siteId: string, url: string, params: Record, courseId?: number): Promise { if (!courseId && !params.id) { return false; } - return CoreGrades.instance.isPluginEnabledForCourse(courseId || params.id, siteId); + return CoreGrades.instance.isPluginEnabledForCourse(courseId || Number(params.id), siteId); } } diff --git a/src/core/features/pushnotifications/pushnotifications.module.ts b/src/core/features/pushnotifications/pushnotifications.module.ts index 9f83001aa..8ba1ad90a 100644 --- a/src/core/features/pushnotifications/pushnotifications.module.ts +++ b/src/core/features/pushnotifications/pushnotifications.module.ts @@ -15,6 +15,8 @@ import { APP_INITIALIZER, NgModule } from '@angular/core'; import { CoreCronDelegate } from '@services/cron'; +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { SITE_SCHEMA } from './services/database/pushnotifications'; import { CorePushNotificationsRegisterCronHandler } from './services/handlers/register-cron'; import { CorePushNotificationsUnregisterCronHandler } from './services/handlers/unregister-cron'; import { CorePushNotifications } from './services/pushnotifications'; @@ -25,6 +27,11 @@ import { CorePushNotifications } from './services/pushnotifications'; imports: [ ], providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [SITE_SCHEMA], + multi: true, + }, { provide: APP_INITIALIZER, multi: true, diff --git a/src/core/features/sitehome/services/handlers/index-link.ts b/src/core/features/sitehome/services/handlers/index-link.ts index 505fa2716..1bde3303e 100644 --- a/src/core/features/sitehome/services/handlers/index-link.ts +++ b/src/core/features/sitehome/services/handlers/index-link.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { Params } from '@angular/router'; + import { CoreSites } from '@services/sites'; import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; @@ -59,7 +59,7 @@ export class CoreSiteHomeIndexLinkHandlerService extends CoreContentLinksHandler * @param courseId Course ID related to the URL. Optional but recommended. * @return Whether the handler is enabled for the URL and site. */ - async isEnabled(siteId: string, url: string, params: Params, courseId?: number): Promise { + async isEnabled(siteId: string, url: string, params: Record, courseId?: number): Promise { courseId = parseInt(params.id, 10); if (!courseId) { return false; diff --git a/src/core/features/tag/services/handlers/index-link.ts b/src/core/features/tag/services/handlers/index-link.ts index 0e2103d61..01c858693 100644 --- a/src/core/features/tag/services/handlers/index-link.ts +++ b/src/core/features/tag/services/handlers/index-link.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { Params } from '@angular/router'; + import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; import { CoreNavigator } from '@services/navigator'; @@ -42,7 +42,7 @@ export class CoreTagIndexLinkHandlerService extends CoreContentLinksHandlerBase getActions( siteIds: string[], url: string, - params: Params, + params: Record, ): CoreContentLinksAction[] | Promise { return [{ action: (siteId): void => { @@ -77,7 +77,7 @@ export class CoreTagIndexLinkHandlerService extends CoreContentLinksHandlerBase * @param courseId Course ID related to the URL. Optional but recommended. * @return Whether the handler is enabled for the URL and site. */ - isEnabled(siteId: string): boolean | Promise { + async isEnabled(siteId: string): Promise { return CoreTag.instance.areTagsAvailable(siteId); } diff --git a/src/core/features/tag/services/handlers/search-link.ts b/src/core/features/tag/services/handlers/search-link.ts index cb025d449..fcd491852 100644 --- a/src/core/features/tag/services/handlers/search-link.ts +++ b/src/core/features/tag/services/handlers/search-link.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { Params } from '@angular/router'; + import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; import { CoreNavigator } from '@services/navigator'; @@ -39,7 +39,11 @@ export class CoreTagSearchLinkHandlerService extends CoreContentLinksHandlerBase * @param data Extra data to handle the URL. * @return List of (or promise resolved with list of) actions. */ - getActions(siteIds: string[], url: string, params: Params): CoreContentLinksAction[] | Promise { + getActions( + siteIds: string[], + url: string, + params: Record, + ): CoreContentLinksAction[] | Promise { return [{ action: (siteId): void => { const pageParams = { @@ -59,7 +63,7 @@ export class CoreTagSearchLinkHandlerService extends CoreContentLinksHandlerBase * @param siteId The site ID. * @return Whether the handler is enabled for the URL and site. */ - isEnabled(siteId: string): boolean | Promise { + async isEnabled(siteId: string): Promise { return CoreTag.instance.areTagsAvailable(siteId); } diff --git a/src/core/features/user/services/handlers/profile-link.ts b/src/core/features/user/services/handlers/profile-link.ts index d9a98b174..805705dfe 100644 --- a/src/core/features/user/services/handlers/profile-link.ts +++ b/src/core/features/user/services/handlers/profile-link.ts @@ -13,7 +13,6 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { Params } from '@angular/router'; import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; @@ -43,7 +42,7 @@ export class CoreUserProfileLinkHandlerService extends CoreContentLinksHandlerBa getActions( siteIds: string[], url: string, - params: Params, + params: Record, courseId?: number, // eslint-disable-line @typescript-eslint/no-unused-vars data?: unknown, // eslint-disable-line @typescript-eslint/no-unused-vars ): CoreContentLinksAction[] | Promise { @@ -70,7 +69,7 @@ export class CoreUserProfileLinkHandlerService extends CoreContentLinksHandlerBa * @return Whether the handler is enabled for the URL and site. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - isEnabled(siteId: string, url: string, params: Params, courseId?: number): boolean | Promise { + async isEnabled(siteId: string, url: string, params: Record, courseId?: number): Promise { return url.indexOf('/grade/report/') == -1; } From f296d2923c4ea392a7d6ac7606bc6c7f5e3786e0 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 4 Feb 2021 12:56:20 +0100 Subject: [PATCH 09/10] MOBILE-3648 messages: Apply core-tabs in contacts page --- .../messages/pages/contacts/contacts.html | 135 +++++++++--------- 1 file changed, 66 insertions(+), 69 deletions(-) diff --git a/src/addons/messages/pages/contacts/contacts.html b/src/addons/messages/pages/contacts/contacts.html index 0beaf5f76..628c289c7 100644 --- a/src/addons/messages/pages/contacts/contacts.html +++ b/src/addons/messages/pages/contacts/contacts.html @@ -16,77 +16,74 @@ - - - - {{ 'addon.messages.contacts' | translate}} - - - - {{ 'addon.messages.requests' | translate}} - {{ requestsBadge }} - - - - -
- - - - - - - - -

- - - - -

-
-
-
+ - - + + + + + + + + + + + + +

+ + + + +

+
+
+
- - -
-
-
- - - - - - - - - - -

- {{ 'addon.messages.wouldliketocontactyou' | translate }} -

-
-
-
- - - - -
-
+ + + + + + + + + + + + + + + + + + + + + +

+ {{ 'addon.messages.wouldliketocontactyou' | translate }} +

+
+
+
+ + + + +
+
+
+
From 42034843442c4b2ab71402f6936f5a69a0cf65d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Fri, 5 Feb 2021 15:08:21 +0100 Subject: [PATCH 10/10] MOBILE-3648 lesson: Fix styles --- .../index/addon-mod-lesson-index.html | 197 ++++++++---------- .../mod/lesson/pages/player/player.html | 48 ++--- .../mod/lesson/pages/player/player.scss | 34 ++- .../lesson/pages/user-retake/user-retake.html | 120 ++++++----- .../pages/choose-site/choose-site.html | 2 +- .../components/format/core-course-format.html | 2 +- .../pages/email-signup/email-signup.html | 4 +- src/theme/theme.base.scss | 14 ++ 8 files changed, 211 insertions(+), 210 deletions(-) diff --git a/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html b/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html index ee5cadb64..f2b99aa64 100644 --- a/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html +++ b/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html @@ -176,128 +176,115 @@ - {{ 'addon.mod_lesson.lessonstats' | translate }} + {{ 'addon.mod_lesson.lessonstats' | translate }} -
- - - -

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

-

- {{ 'core.percentagenumber' | translate:{$a: overview.avescore} }} -

-

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

-
+ + + +

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

+

+ {{ 'core.percentagenumber' | translate:{$a: overview.avescore} }} +

+

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

+
- -

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

-

- {{ 'core.percentagenumber' | translate:{$a: overview.highscore} }} -

-

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

-
+ +

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

+

+ {{ 'core.percentagenumber' | translate:{$a: overview.highscore} }} +

+

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

+
- -

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

-

- {{ 'core.percentagenumber' | translate:{$a: overview.lowscore} }} -

-

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

-
-
-
+ +

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

+

+ {{ 'core.percentagenumber' | translate:{$a: overview.lowscore} }} +

+

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

+
+
+ + +

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

+

{{ avetimeReadable }}

+

+ {{ 'addon.mod_lesson.notcompleted' | translate }} +

+
- - - -

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

-

{{ avetimeReadable }}

-

- {{ 'addon.mod_lesson.notcompleted' | translate }} -

-
+ +

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

+

{{ hightimeReadable }}

+

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

+
- -

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

-

{{ hightimeReadable }}

-

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

-
- - -

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

-

{{ lowtimeReadable }}

-

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

-
-
-
-
+ +

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

+

{{ lowtimeReadable }}

+

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

+
+ + -
- - - -

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

-

- {{ 'core.percentagenumber' | translate:{$a: overview.avescore} }} -

-

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

-
+ + + +

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

+

+ {{ 'core.percentagenumber' | translate:{$a: overview.avescore} }} +

+

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

+
- -

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

-

{{ avetimeReadable }}

-

- {{ 'addon.mod_lesson.notcompleted' | translate }} -

-
-
-
+ +

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

+

{{ avetimeReadable }}

+

+ {{ 'addon.mod_lesson.notcompleted' | translate }} +

+
+
+ + +

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

+

+ {{ 'core.percentagenumber' | translate:{$a: overview.highscore} }} +

+

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

+
- - - -

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

-

- {{ 'core.percentagenumber' | translate:{$a: overview.highscore} }} -

-

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

-
+ +

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

+

{{ hightimeReadable }}

+

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

+
+
+ + +

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

+

+ {{ 'core.percentagenumber' | translate:{$a: overview.lowscore} }} +

+

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

+
- -

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

-

{{ hightimeReadable }}

-

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

-
-
-
- - - - -

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

-

- {{ 'core.percentagenumber' | translate:{$a: overview.lowscore} }} -

-

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

-
- - -

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

-

{{ lowtimeReadable }}

-

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

-
-
-
-
+ +

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

+

{{ lowtimeReadable }}

+

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

+
+ +
- {{ 'addon.mod_lesson.overview' | translate }} + {{ 'addon.mod_lesson.overview' | translate }} -

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

-

- - -

+ +

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

+

+ + +

+
@@ -126,24 +128,18 @@ - - - -

-

-
- - - - {{option.label}} - - - -
-
+ +

+

+
+ + + {{option.label}} + +
@@ -192,7 +188,7 @@ - {{ 'addon.mod_lesson.congratulations' | translate }} + {{ 'addon.mod_lesson.congratulations' | translate }} {{ eolData.notenoughtimespent.message }} diff --git a/src/addons/mod/lesson/pages/player/player.scss b/src/addons/mod/lesson/pages/player/player.scss index 79a950420..8733de161 100644 --- a/src/addons/mod/lesson/pages/player/player.scss +++ b/src/addons/mod/lesson/pages/player/player.scss @@ -1,3 +1,11 @@ +:host { + --background-odd: var(--gray-lighter); +} + +:host-context(body.dark) { + --background-odd: var(--gray-darker); +} + :host ::ng-deep { .addon-mod_lesson-slideshow { max-width: 100%; @@ -5,15 +13,16 @@ margin: 0 auto; } + .studentanswer { + padding-inline-start: 8px; + } + table { width: 100%; margin-top: 1.5rem; tr:nth-child(odd) { - background-color: var(--gray-lighter); - // @include darkmode() { - // background-color: $core-dark-item-divider-bg-color; - // } + background-color: var(--background-odd); } tr:last-child td { @@ -26,21 +35,4 @@ border-bottom: 1px solid var(--gray); } } - - // @todo - // .item-ios table { - // @extend .card-ios; - // @include darkmode() { - // color: $white; - // background-color: $core-dark-item-bg-color; - // } - // } - - // .item-md table { - // @extend .card-md; - // @include darkmode() { - // color: $white; - // background-color: $core-dark-item-bg-color; - // } - // } } diff --git a/src/addons/mod/lesson/pages/user-retake/user-retake.html b/src/addons/mod/lesson/pages/user-retake/user-retake.html index 9649820af..a6fa046c7 100644 --- a/src/addons/mod/lesson/pages/user-retake/user-retake.html +++ b/src/addons/mod/lesson/pages/user-retake/user-retake.html @@ -36,28 +36,32 @@ - - - -

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

-

{{ 'core.percentagenumber' | translate:{$a: retake.userstats.grade} }}

-
+ + + + + +

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

+

{{ 'core.percentagenumber' | translate:{$a: retake.userstats.grade} }}

+
- -

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

-

{{ retake.userstats.gradeinfo.earned }} / {{ retake.userstats.gradeinfo.total }}

-
-
-
+ +

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

+

{{ retake.userstats.gradeinfo.earned }} / {{ retake.userstats.gradeinfo.total }}

+
+
+
+
+
-

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

+

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

{{ timeTakenReadable }}

-

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

+

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

{{ retake.userstats.completed * 1000 | coreFormatDate }}

@@ -74,11 +78,11 @@ - {{page.qtype}}: {{page.title}} + {{page.qtype}}: {{page.title}} -

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

+

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

-

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

+

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

-
- - - - - {{ answer[0].buttonText }} - - -

-
-
-
+ + + + + + + + {{ answer[0].buttonText }} + + +

+
+
+
+
+
-
+ @@ -152,27 +160,31 @@ - - - -

- - -

-
- -

{{answer[0].value}}

- - - - -
-
-
+ + + + + +

+ + +

+
+ +

{{answer[0].value}}

+ + + + +
+
+
+
+
-
+
@@ -211,11 +223,11 @@ -
+ -

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

+

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

-

{{ 'core.contentlinks.chooseaccounttoopenlink' | translate }}

+

{{ 'core.contentlinks.chooseaccounttoopenlink' | translate }}

{{ url }}

diff --git a/src/core/features/course/components/format/core-course-format.html b/src/core/features/course/components/format/core-course-format.html index 74738d626..bb9043094 100644 --- a/src/core/features/course/components/format/core-course-format.html +++ b/src/core/features/course/components/format/core-course-format.html @@ -95,7 +95,7 @@ - -

{{ 'core.whyisthisrequired' | translate }}

+

{{ 'core.whyisthisrequired' | translate }}

{{ 'core.explanationdigitalminor' | translate }}

@@ -225,7 +225,7 @@ -

{{ 'core.considereddigitalminor' | translate }}

+

{{ 'core.considereddigitalminor' | translate }}

{{ 'core.digitalminor_desc' | translate }}

{{ supportName }}

{{ supportEmail }}

diff --git a/src/theme/theme.base.scss b/src/theme/theme.base.scss index 7dc22b6cd..be6ee775b 100644 --- a/src/theme/theme.base.scss +++ b/src/theme/theme.base.scss @@ -33,6 +33,16 @@ ion-item.ion-text-wrap ion-label { white-space: normal !important; } +// It fixes the click on links where ion-ripple-effect is present. +.ion-activatable ion-label, +.item-multiple-items ion-label { + z-index: 3; + pointer-events: none; + ion-anchor, ion-button, a, button { + pointer-events: visible; + } +} + // Ionic toolbar. ion-toolbar ion-back-button, @@ -372,6 +382,10 @@ ion-toolbar ion-title .core-bar-button-image img { } // Select. +ion-select::part(text) { + white-space: normal; +} + ion-select.core-button-select, .core-button-select { --background: var(--core-button-select-background);