From 90b3add5dfbbd7a8d2048f229c32a044f71f9384 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 3 Feb 2021 10:55:01 +0100 Subject: [PATCH] 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));