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,