MOBILE-3648 lesson: Implement lesson player
parent
71bcb07c74
commit
90b3add5df
|
@ -1,20 +1,18 @@
|
|||
<ion-list>
|
||||
<ion-radio-group>
|
||||
<ion-item *ngFor="let type of types" class="addon-calendar-event" [ngClass]="['addon-calendar-eventtype-'+type]">
|
||||
<ion-icon [name]="typeIcons[type]" slot="start"></ion-icon>
|
||||
<ion-label>{{ 'addon.calendar.' + type + 'events' | translate}}</ion-label>
|
||||
<ion-toggle [(ngModel)]="filter[type]" (ionChange)="onChange()" slot="end"></ion-toggle>
|
||||
</ion-item>
|
||||
<ion-item-divider *ngIf="filter.course || filter.category || filter.group">
|
||||
<ion-label></ion-label>
|
||||
</ion-item-divider>
|
||||
<ion-list *ngIf="filter.course || filter.category || filter.group">
|
||||
<ion-radio-group [(ngModel)]="courseId" (ionChange)="onChange()">
|
||||
<ion-item class="ion-text-wrap" *ngFor="let course of courses">
|
||||
<ion-label><core-format-text [text]="course.fullname"></core-format-text></ion-label>
|
||||
<ion-radio slot="start" value="{{course.id}}"></ion-radio>
|
||||
</ion-item>
|
||||
</ion-radio-group>
|
||||
</ion-list>
|
||||
</ion-radio-group>
|
||||
<ion-item *ngFor="let type of types" class="addon-calendar-event" [ngClass]="['addon-calendar-eventtype-'+type]">
|
||||
<ion-icon [name]="typeIcons[type]" slot="start"></ion-icon>
|
||||
<ion-label>{{ 'addon.calendar.' + type + 'events' | translate}}</ion-label>
|
||||
<ion-toggle [(ngModel)]="filter[type]" (ionChange)="onChange()" slot="end"></ion-toggle>
|
||||
</ion-item>
|
||||
<ion-item-divider *ngIf="filter.course || filter.category || filter.group">
|
||||
<ion-label></ion-label>
|
||||
</ion-item-divider>
|
||||
<ng-container *ngIf="filter.course || filter.category || filter.group">
|
||||
<ion-radio-group [(ngModel)]="courseId" (ionChange)="onChange()">
|
||||
<ion-item class="ion-text-wrap" *ngFor="let course of courses">
|
||||
<ion-label><core-format-text [text]="course.fullname"></core-format-text></ion-label>
|
||||
<ion-radio slot="end" value="{{course.id}}"></ion-radio>
|
||||
</ion-item>
|
||||
</ion-radio-group>
|
||||
</ng-container>
|
||||
</ion-list>
|
||||
|
|
|
@ -157,18 +157,18 @@
|
|||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item>
|
||||
<ion-radio slot="start" value="0"></ion-radio>
|
||||
<ion-radio slot="end" value="0"></ion-radio>
|
||||
<ion-label>{{ 'addon.calendar.durationnone' | translate }}</ion-label>
|
||||
</ion-item>
|
||||
<ion-item (click)="selectDuration('1')">
|
||||
<ion-radio slot="start" value="1"></ion-radio>
|
||||
<ion-radio slot="end" value="1"></ion-radio>
|
||||
<ion-label>{{ 'addon.calendar.durationuntil' | translate }}</ion-label>
|
||||
<ion-datetime formControlName="timedurationuntil"
|
||||
[placeholder]="'addon.calendar.durationuntil' | translate"
|
||||
[displayFormat]="dateFormat" [disabled]="form.controls.duration.value != 1"></ion-datetime>
|
||||
</ion-item>
|
||||
<ion-item (click)="selectDuration('2')">
|
||||
<ion-radio slot="start" value="2"></ion-radio>
|
||||
<ion-radio slot="end" value="2"></ion-radio>
|
||||
<ion-label>{{ 'addon.calendar.durationminutes' | translate }}</ion-label>
|
||||
<ion-input type="number" name="timedurationminutes" slot="end"
|
||||
[placeholder]="'addon.calendar.durationminutes' | translate"
|
||||
|
@ -203,11 +203,11 @@
|
|||
</ion-item>
|
||||
<ion-item>
|
||||
<ion-label>{{ 'addon.calendar.repeateditall' | translate:{$a: otherEventsCount} }}</ion-label>
|
||||
<ion-radio slot="start" [value]="1"></ion-radio>
|
||||
<ion-radio slot="end" [value]="1"></ion-radio>
|
||||
</ion-item>
|
||||
<ion-item>
|
||||
<ion-label>{{ 'addon.calendar.repeateditthis' | translate }}</ion-label>
|
||||
<ion-radio slot="start" [value]="0"></ion-radio>
|
||||
<ion-radio slot="end" [value]="0"></ion-radio>
|
||||
</ion-item>
|
||||
</ion-radio-group>
|
||||
</div>
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
})
|
||||
|
|
|
@ -64,7 +64,7 @@
|
|||
</ion-item>
|
||||
<ion-button expand="block" type="submit">
|
||||
{{ 'addon.mod_lesson.continue' | translate }}
|
||||
<core-icon slot="end" name="fas-arrow-right"></core-icon>
|
||||
<core-icon slot="end" name="fas-chevron-right"></core-icon>
|
||||
</ion-button>
|
||||
<!-- Remove this once Ionic fixes this bug: https://github.com/ionic-team/ionic-framework/issues/19368 -->
|
||||
<input type="submit" class="core-submit-hidden-enter" />
|
||||
|
@ -73,13 +73,17 @@
|
|||
|
||||
<core-loading [hideUntil]="!showSpinner">
|
||||
<ion-list *ngIf="(lesson && !preventReasons.length) || retakeToReview">
|
||||
<ion-item class="ion-text-wrap" *ngIf="retakeToReview">
|
||||
<ng-container *ngIf="retakeToReview">
|
||||
<!-- A retake was finished in a synchronization, allow reviewing it. -->
|
||||
<ion-label class="ion-padding-bottom">
|
||||
{{ 'addon.mod_lesson.retakefinishedinsync' | translate }}
|
||||
</ion-label>
|
||||
<ion-button expand="block" (click)="review()">{{ 'addon.mod_lesson.review' | translate }}</ion-button>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" lines="none">
|
||||
<ion-label class="ion-padding-bottom">
|
||||
{{ 'addon.mod_lesson.retakefinishedinsync' | translate }}
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-button class="ion-text-wrap ion-margin" expand="block" (click)="review()">
|
||||
{{ 'addon.mod_lesson.review' | translate }}
|
||||
</ion-button>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="lesson && !preventReasons.length">
|
||||
<ion-item class="ion-text-wrap" *ngIf="leftDuringTimed && !lesson.timelimit && !finishedOffline">
|
||||
|
@ -103,15 +107,16 @@
|
|||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item class="ion-text-wrap" *ngIf="leftDuringTimed && lesson.timelimit && lesson.retake &&
|
||||
!finishedOffline">
|
||||
<!-- User left during the session with time limit and retakes allowed, ask to continue. -->
|
||||
<ion-label [innerHTML]="'addon.mod_lesson.leftduringtimed' | translate"></ion-label>
|
||||
<ion-button expand="block" (click)="start(false)">
|
||||
<ng-container *ngIf="leftDuringTimed && lesson.timelimit && lesson.retake && !finishedOffline">
|
||||
<ion-item class="ion-text-wrap">
|
||||
<!-- User left during the session with time limit and retakes allowed, ask to continue. -->
|
||||
<ion-label [innerHTML]="'addon.mod_lesson.leftduringtimed' | translate"></ion-label>
|
||||
</ion-item>
|
||||
<ion-button class="ion-text-wrap ion-margin" expand="block" (click)="start(false)">
|
||||
{{ 'addon.mod_lesson.continue' | translate }}
|
||||
<ion-icon name="fas-arrow-right" slot="end"></ion-icon>
|
||||
<ion-icon name="fas-chevron-right" slot="end"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
|
||||
<ion-item class="ion-text-wrap" *ngIf="leftDuringTimed && lesson.timelimit && !lesson.retake">
|
||||
<!-- User left during the session with time limit and retakes not allowed.
|
||||
|
@ -119,22 +124,24 @@
|
|||
<ion-label [innerHTML]="'addon.mod_lesson.leftduringtimednoretake' | translate"></ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item class="ion-text-wrap" *ngIf="!leftDuringTimed && !finishedOffline">
|
||||
<ng-container *ngIf="!leftDuringTimed && !finishedOffline">
|
||||
<!-- User hasn't left during the session, show a start button. -->
|
||||
<ion-button expand="block" *ngIf="!canManage" (click)="start(false)">
|
||||
<ion-button class="ion-text-wrap ion-margin" expand="block" *ngIf="!canManage"
|
||||
(click)="start(false)">
|
||||
{{ 'core.start' | translate }}
|
||||
<ion-icon name="fas-arrow-right" slot="end"></ion-icon>
|
||||
<ion-icon name="fas-chevron-right" slot="end"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button expand="block" *ngIf="canManage" (click)="start(false)">
|
||||
<ion-button class="ion-text-wrap ion-margin" expand="block" *ngIf="canManage"
|
||||
(click)="start(false)">
|
||||
{{ 'addon.mod_lesson.preview' | translate }}
|
||||
<ion-icon name="fas-search" slot="end"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
|
||||
<ion-button class="ion-text-wrap" *ngIf="finishedOffline" expand="block" (click)="start(true)">
|
||||
<!-- There's an attempt finished in offline. Let the user continue, showing the end of lesson. -->
|
||||
{{ 'addon.mod_lesson.continue' | translate }}
|
||||
<ion-icon name="fas-arrow-right" slot="end"></ion-icon>
|
||||
<ion-icon name="fas-chevron-right" slot="end"></ion-icon>
|
||||
</ion-button>
|
||||
</ng-container>
|
||||
</ion-list>
|
||||
|
@ -306,4 +313,4 @@
|
|||
</ng-template>
|
||||
</core-tab>
|
||||
</core-tabs>
|
||||
</core-loading>
|
||||
</core-loading>
|
||||
|
|
|
@ -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<AddonModLessonDataSentData>(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<void> {
|
||||
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<AddonModLessonDataSentData>(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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>{{ pageInstance?.lesson?.name }}</ion-title>
|
||||
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
|
||||
<core-icon slot="icon-only" name="fas-times"></core-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content class="addon-mod_lesson-menu-modal">
|
||||
<nav>
|
||||
<ion-list *ngIf="pageInstance">
|
||||
<!-- Media file. -->
|
||||
<ng-container *ngIf="pageInstance.mediaFile">
|
||||
<ion-item-divider>
|
||||
<ion-label><h2>{{ 'addon.mod_lesson.linkedmedia' | translate }}</h2></ion-label>
|
||||
</ion-item-divider>
|
||||
<core-file [file]="pageInstance.mediaFile" [component]="pageInstance.component"
|
||||
[componentId]="pageInstance.lesson?.coursemodule">
|
||||
</core-file>
|
||||
</ng-container>
|
||||
|
||||
<!-- Lesson menu. -->
|
||||
<ng-container *ngIf="pageInstance.displayMenu">
|
||||
<ion-item-divider>
|
||||
<ion-label><h2>{{ 'addon.mod_lesson.lessonmenu' | translate }}</h2></ion-label>
|
||||
</ion-item-divider>
|
||||
<ion-item class="ion-text-center" *ngIf="pageInstance.loadingMenu">
|
||||
<ion-label><ion-spinner></ion-spinner></ion-label>
|
||||
</ion-item>
|
||||
<div *ngIf="!pageInstance.loadingMenu">
|
||||
<ng-container *ngFor="let page of pageInstance.lessonPages">
|
||||
<ion-item class="ion-text-wrap" *ngIf="page.display && page.displayinmenublock" (click)="loadPage(page.id)"
|
||||
[ngClass]='{"core-selected-item": !pageInstance.eolData && pageInstance.currentPage == page.id}'
|
||||
button detail="true">
|
||||
<ion-label>
|
||||
<core-format-text [text]="page.title" contextLevel="module" [courseId]="pageInstance.courseId"
|
||||
[contextInstanceId]="pageInstance.lesson?.coursemodule">
|
||||
</core-format-text>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ion-list>
|
||||
</nav>
|
||||
</ion-content>
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
|
@ -20,7 +20,7 @@
|
|||
</ion-item>
|
||||
<ion-button expand="block" type="submit">
|
||||
{{ 'addon.mod_lesson.continue' | translate }}
|
||||
<core-icon slot="end" name="fas-arrow-right"></core-icon>
|
||||
<core-icon slot="end" name="fas-chevron-right"></core-icon>
|
||||
</ion-button>
|
||||
<!-- Remove this once Ionic fixes this bug: https://github.com/ionic-team/ionic-framework/issues/19368 -->
|
||||
<input type="submit" class="core-submit-hidden-enter" />
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -0,0 +1,292 @@
|
|||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>
|
||||
<core-format-text [text]="title" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
|
||||
[courseId]="courseId">
|
||||
</core-format-text>
|
||||
</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button *ngIf="displayMenu || mediaFile" [attr.aria-label]="'addon.mod_lesson.lessonmenu' | translate"
|
||||
(click)="showMenu()">
|
||||
<ion-icon name="bookmark" slot="icon-only"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<core-loading [hideUntil]="loaded">
|
||||
<!-- Info messages. Only show the first one. -->
|
||||
<ion-card class="core-info-card" *ngIf="lesson && messages?.length">
|
||||
<ion-item>
|
||||
<ion-icon name="fas-info-circle" slot="start"></ion-icon>
|
||||
<ion-label>{{ messages[0].message }}</ion-label>
|
||||
</ion-item>
|
||||
</ion-card>
|
||||
|
||||
<div *ngIf="lesson" [ngClass]='{"addon-mod_lesson-slideshow": lesson.slideshow}'
|
||||
[ngStyle]="{'width': lessonWidth, 'height': lessonHeight}">
|
||||
|
||||
<core-timer *ngIf="endTime" [endTime]="endTime" (finished)="timeUp()"
|
||||
[timerText]="'addon.mod_lesson.timeremaining' | translate">
|
||||
</core-timer>
|
||||
|
||||
<!-- Retake and ongoing score. -->
|
||||
<ion-item class="ion-text-wrap" *ngIf="showRetake && !eolData && !processData">
|
||||
<p>{{ 'addon.mod_lesson.attempt' | translate:{$a: retake} }}</p>
|
||||
</ion-item>
|
||||
<ion-item *ngIf="pageData && pageData.ongoingscore && !eolData && !processData"
|
||||
class="addon-mod_lesson-ongoingscore ion-text-wrap">
|
||||
{{ pageData.ongoingscore }}
|
||||
</ion-item>
|
||||
|
||||
<!-- Page content. -->
|
||||
<ion-card *ngIf="!eolData && !processData">
|
||||
<!-- Content page. -->
|
||||
<ion-item class="ion-text-wrap" *ngIf="!question && pageContent">
|
||||
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="pageContent"
|
||||
contextLevel="module" [contextInstanceId]="lesson.coursemodule" [courseId]="courseId">
|
||||
</core-format-text>
|
||||
</ion-item>
|
||||
|
||||
<!-- Question page. -->
|
||||
<!-- We need to set ngIf loaded to make formGroup directive restart every time a page changes, see MOBILE-2540. -->
|
||||
<form *ngIf="question && loaded" ion-list [formGroup]="questionForm" #questionFormEl
|
||||
(ngSubmit)="submitQuestion($event)">
|
||||
|
||||
<ion-item-divider class="ion-text-wrap" *ngIf="pageContent">
|
||||
<core-format-text [component]="component" [componentId]="lesson?.coursemodule" [text]="pageContent"
|
||||
contextLevel="module" [contextInstanceId]="lesson.coursemodule" [courseId]="courseId">
|
||||
</core-format-text>
|
||||
</ion-item-divider>
|
||||
|
||||
<!-- Render a different input depending on the type of the question. -->
|
||||
<ng-container [ngSwitch]="question.template">
|
||||
|
||||
<!-- Short answer. -->
|
||||
<ion-item class="ion-text-wrap" *ngSwitchCase="'shortanswer'">
|
||||
<ion-input [type]="question.input!.type" placeholder="{{ 'addon.mod_lesson.youranswer' | translate }}"
|
||||
[id]="question.input!.id" [formControlName]="question.input!.name" autocorrect="off"
|
||||
[maxlength]="question.input!.maxlength">
|
||||
</ion-input>
|
||||
</ion-item>
|
||||
|
||||
<!-- Essay. -->
|
||||
<ng-container *ngSwitchCase="'essay'">
|
||||
<ion-item *ngIf="question.textarea">
|
||||
<core-rich-text-editor placeholder="{{ 'addon.mod_lesson.youranswer' | translate }}"
|
||||
[control]="question.control" [component]="component" [componentId]="lesson?.coursemodule"
|
||||
[autoSave]="true" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
|
||||
elementId="answer_editor">
|
||||
</core-rich-text-editor>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" *ngIf="!question.textarea && question.useranswer">
|
||||
<p class="item-heading">{{ 'addon.mod_lesson.youranswer' | translate }}</p>
|
||||
<p>
|
||||
<core-format-text [component]="component" [componentId]="lesson?.coursemodule"
|
||||
[text]="question.useranswer" contextLevel="module"
|
||||
[contextInstanceId]="lesson?.coursemodule" [courseId]="courseId">
|
||||
</core-format-text>
|
||||
</p>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
|
||||
<!-- Multichoice. -->
|
||||
<ng-container *ngSwitchCase="'multichoice'">
|
||||
<!-- Single choice. -->
|
||||
<ion-radio-group *ngIf="!question.multi" [formControlName]="question.controlName">
|
||||
<ion-item class="ion-text-wrap" *ngFor="let option of question.options" lines="none">
|
||||
<ion-label>
|
||||
<core-format-text [component]="component" [componentId]="lesson.coursemodule"
|
||||
[text]="option.text" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
|
||||
[courseId]="courseId">
|
||||
</core-format-text>
|
||||
</ion-label>
|
||||
<ion-radio slot="end" [id]="option.id" [value]="option.value" [disabled]="option.disabled">
|
||||
</ion-radio>
|
||||
</ion-item>
|
||||
</ion-radio-group>
|
||||
|
||||
<!-- Multiple choice. -->
|
||||
<ng-container *ngIf="question.multi">
|
||||
<ion-item class="ion-text-wrap" *ngFor="let option of question.options" lines="none">
|
||||
<ion-label>
|
||||
<core-format-text [component]="component" [componentId]="lesson?.coursemodule"
|
||||
[text]="option.text" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
|
||||
[courseId]="courseId">
|
||||
</core-format-text>
|
||||
</ion-label>
|
||||
<ion-checkbox [id]="option.id" [formControlName]="option.name" slot="end"></ion-checkbox>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<!-- Matching. -->
|
||||
<ng-container *ngSwitchCase="'matching'">
|
||||
<ion-item class="ion-text-wrap" *ngFor="let row of question.rows">
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col>
|
||||
<p><core-format-text id="addon-mod_lesson-matching-{{row.id}}" [component]="component"
|
||||
[componentId]="lesson?.coursemodule" [text]="row.text" contextLevel="module"
|
||||
[contextInstanceId]="lesson?.coursemodule" [courseId]="courseId">
|
||||
</core-format-text></p>
|
||||
</ion-col>
|
||||
<ion-col>
|
||||
<ion-select [id]="row.id" [formControlName]="row.name" interface="action-sheet"
|
||||
[attr.aria-labelledby]="'addon-mod_lesson-matching-' + row.id">
|
||||
<ion-select-option *ngFor="let option of row.options" [value]="option.value">
|
||||
{{option.label}}
|
||||
</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ion-button expand="block" type="submit" class="ion-text-wrap ion-margin button-no-uppercase">
|
||||
{{ question.submitLabel }}
|
||||
</ion-button>
|
||||
<!-- Remove this once Ionic fixes this bug: https://github.com/ionic-team/ionic-framework/issues/19368 -->
|
||||
<input type="submit" class="core-submit-hidden-enter" />
|
||||
</form>
|
||||
</ion-card>
|
||||
|
||||
<!-- Page buttons and progress. -->
|
||||
<ion-list *ngIf="!eolData && !processData">
|
||||
<ion-grid *ngIf="pageButtons?.length" class="ion-text-wrap addon-mod_lesson-pagebuttons">
|
||||
<ion-row class="ion-align-items-center">
|
||||
<ion-col *ngFor="let button of pageButtons" size="12" size-md="6" size-lg="3" col-xl>
|
||||
<ion-button expand="block" fill="outline" [id]="button.id"
|
||||
(click)="buttonClicked(button.data)" class="ion-text-wrap button-no-uppercase">
|
||||
{{ button.content }}
|
||||
</ion-button>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
<ion-item class="ion-text-wrap" *ngIf="lesson?.progressbar && !canManage && pageData">
|
||||
<ion-label>
|
||||
{{ 'addon.mod_lesson.progresscompleted' | translate:{$a: pageData.progress} }}
|
||||
<core-progress-bar [progress]="pageData.progress"></core-progress-bar>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<div class="core-info-card" *ngIf="lesson?.progressbar && canManage">
|
||||
<ion-item>
|
||||
<ion-icon name="fas-info-circle" slot="start"></ion-icon>
|
||||
<ion-label>{{ 'addon.mod_lesson.progressbarteacherwarning2' | translate }}</ion-label>
|
||||
</ion-item>
|
||||
</div>
|
||||
</ion-list>
|
||||
|
||||
<!-- End of lesson reached. -->
|
||||
<ion-card *ngIf="eolData && !processData">
|
||||
<div class="core-warning-card" *ngIf="eolData.offline?.value">
|
||||
<ion-item>
|
||||
<ion-icon name="fas-exclamation-triangle" slot="start"></ion-icon>
|
||||
<ion-label>{{ 'addon.mod_lesson.finishretakeoffline' | translate }}</ion-label>
|
||||
</ion-item>
|
||||
</div>
|
||||
|
||||
<ion-card-header class="ion-text-wrap" *ngIf="eolData.gradelesson">
|
||||
<ion-card-subtitle>{{ 'addon.mod_lesson.congratulations' | translate }}</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
<ion-item class="ion-text-wrap" *ngIf="eolData.notenoughtimespent" lines="none">
|
||||
<ion-label>{{ eolData.notenoughtimespent.message }}</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" *ngIf="eolData.numberofpagesviewed" lines="none">
|
||||
<ion-label>{{ eolData.numberofpagesviewed.message }}</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" *ngIf="eolData.youshouldview" lines="none">
|
||||
<ion-label>{{ eolData.youshouldview.message }}</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" *ngIf="eolData.numberofcorrectanswers" lines="none">
|
||||
<ion-label>{{ eolData.numberofcorrectanswers.message }}</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" *ngIf="eolData.displayscorewithessays" lines="none">
|
||||
<ion-label [innerHTML]="eolData.displayscorewithessays.message"></ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" *ngIf="!eolData.displayscorewithessays && eolData.displayscorewithoutessays"
|
||||
lines="none">
|
||||
<ion-label>{{ eolData.displayscorewithoutessays.message }}</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" *ngIf="eolData.yourcurrentgradeisoutof" lines="none">
|
||||
<ion-label>{{ eolData.yourcurrentgradeisoutof.message }}</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" *ngIf="eolData.eolstudentoutoftimenoanswers" lines="none">
|
||||
<ion-label>{{ eolData.eolstudentoutoftimenoanswers.message }}</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" *ngIf="eolData.welldone" lines="none">
|
||||
<ion-label>{{ eolData.welldone.message }}</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" *ngIf="lesson.progressbar && eolData.progresscompleted" lines="none">
|
||||
<ion-label>
|
||||
{{ 'addon.mod_lesson.progresscompleted' | translate:{$a: eolData.progresscompleted.value} }}
|
||||
<core-progress-bar [progress]="eolData.progresscompleted.value"></core-progress-bar>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" *ngIf="eolData.displayofgrade" lines="none">
|
||||
<ion-label>{{ eolData.displayofgrade.message }}</ion-label>
|
||||
</ion-item>
|
||||
<ion-button *ngIf="eolData.reviewlesson" expand="block" class="ion-text-wrap ion-margin button-no-uppercase"
|
||||
(click)="reviewLesson(reviewPageId!)">
|
||||
{{ 'addon.mod_lesson.reviewlesson' | translate }}
|
||||
</ion-button>
|
||||
<ion-item class="ion-text-wrap" *ngIf="eolData.modattemptsnoteacher" lines="none">
|
||||
<ion-label>{{ eolData.modattemptsnoteacher.message }}</ion-label>
|
||||
</ion-item>
|
||||
<!-- If activity link was successfully formatted, render the button. -->
|
||||
<ion-button *ngIf="activityLink && activityLink.formatted"
|
||||
expand="block" color="light" [href]="activityLink.href" core-link [capture]="true"
|
||||
class="ion-text-wrap ion-margin button-no-uppercase">
|
||||
<core-format-text [text]="activityLink.label" contextLevel="module"
|
||||
[contextInstanceId]="lesson?.coursemodule" [courseId]="courseId">
|
||||
</core-format-text>
|
||||
</ion-button>
|
||||
<ion-item class="ion-text-wrap" *ngIf="activityLink && !activityLink.formatted"
|
||||
lines="none">
|
||||
<!-- Activity link wasn't formatted, render the original link. -->
|
||||
<ion-label>
|
||||
<core-format-text [text]="activityLink.label" contextLevel="module"
|
||||
[contextInstanceId]="lesson?.coursemodule" [courseId]="courseId">
|
||||
</core-format-text>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-card>
|
||||
|
||||
<!-- Feedback returned when processing an action. -->
|
||||
<ion-list *ngIf="processData">
|
||||
<ion-item class="ion-text-wrap" *ngIf="processData.ongoingscore && !processData.reviewmode" >
|
||||
<ion-label>{{ processData.ongoingscore }}</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" *ngIf="!processData.reviewmode || review">
|
||||
<ion-label>
|
||||
<div *ngIf="!processData.reviewmode">
|
||||
<core-format-text [component]="component" [componentId]="lesson?.coursemodule"
|
||||
[text]="processData.feedback" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
|
||||
[courseId]="courseId">
|
||||
</core-format-text>
|
||||
</div>
|
||||
<div *ngIf="review">
|
||||
<p>{{ 'addon.mod_lesson.gotoendoflesson' | translate }}</p>
|
||||
<p>{{ 'addon.mod_lesson.or' | translate }}</p>
|
||||
<p>{{ 'addon.mod_lesson.continuetonextpage' | translate }}</p>
|
||||
</div>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-button expand="block" class="ion-text-wrap ion-margin" color="light" *ngIf="review"
|
||||
(click)="changePage(LESSON_EOL)">
|
||||
{{ 'addon.mod_lesson.finish' | translate }}
|
||||
</ion-button>
|
||||
<ion-button expand="block" class="ion-text-wrap ion-margin" color="light" *ngFor="let button of processDataButtons"
|
||||
(click)="changePage(button.pageId, true)">
|
||||
{{ button.label | translate }}
|
||||
</ion-button>
|
||||
</ion-list>
|
||||
</div>
|
||||
</core-loading>
|
||||
</ion-content>
|
|
@ -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 {}
|
|
@ -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;
|
||||
// }
|
||||
// }
|
||||
}
|
|
@ -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<string, AddonModLessonEOLPageDataEntry>; // 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<string, unknown>; // 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<void> {
|
||||
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<boolean> {
|
||||
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<string, string>): 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<T>(func: () => Promise<T>, options: CommonOptions): Promise<T> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<AddonModLessonGetAccessInformationWSResponse>(
|
||||
AddonModLesson.instance.getAccessInformation.bind(AddonModLesson.instance, this.lesson.id, options),
|
||||
options,
|
||||
);
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
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<AddonModLessonLessonWSData>(
|
||||
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<void> {
|
||||
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<AddonModLessonFinishRetakeResponse>(
|
||||
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(<string> this.eolData.activitylink.value);
|
||||
} else {
|
||||
this.activityLink = undefined;
|
||||
}
|
||||
|
||||
// Format review lesson if present.
|
||||
if (this.eolData.reviewlesson) {
|
||||
const params = CoreUrlUtils.instance.extractUrlParams(<string> 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<AddonModLessonGetPagesPageWSData[]>(
|
||||
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<void> {
|
||||
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<AddonModLessonGetPageDataWSResponse>(
|
||||
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<string, unknown>, formSubmitted?: boolean): Promise<void> {
|
||||
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<AddonModLessonProcessPageResponse>(
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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;
|
||||
};
|
|
@ -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<AddonModLessonMultichoiceQuestion> &
|
||||
Partial<AddonModLessonInputQuestion> & Partial<AddonModLessonEssayQuestion> & Partial<AddonModLessonMatchingQuestion>;
|
||||
|
||||
/**
|
||||
* Activity link data.
|
||||
*/
|
||||
export type AddonModLessonActivityLink = {
|
||||
formatted: boolean;
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -313,7 +313,7 @@ export class CoreTabsBaseComponent<T extends CoreTabBase> 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<T extends CoreTabBase> 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) {
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
<ion-item lines="none" class="core-timer" role="timer"
|
||||
[ngClass]="{'ion-text-center': align == 'center', 'ion-text-end': align == 'right'}">
|
||||
<ion-icon name="fas-clock" slot="start" role="presentation"></ion-icon>
|
||||
<ion-label>
|
||||
<span *ngIf="timeLeft && timeLeft > 0 && timerText" class="core-timer-text">{{ timerText }}</span>
|
||||
<span *ngIf="timeLeft && timeLeft > 0" class="core-timer-time-left">{{ timeLeft | coreSecondsToHMS }}</span>
|
||||
<span class="core-timesup" *ngIf="timeLeft !== undefined && timeLeft <= 0">
|
||||
{{ 'core.timesup' | translate }}
|
||||
</span>
|
||||
</ion-label>
|
||||
</ion-item>
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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:
|
||||
* <core-timer [endTime]="endTime" (finished)="timeUp()" [timerText]="'addon.mod_quiz.timeleft' | translate"></core-timer>
|
||||
*/
|
||||
@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<void>(); // 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);
|
||||
}
|
||||
|
||||
}
|
|
@ -21,11 +21,11 @@
|
|||
<ion-radio-group formControlName="field">
|
||||
<ion-item>
|
||||
<ion-label>{{ 'core.login.username' | translate }}</ion-label>
|
||||
<ion-radio slot="start" value="username"></ion-radio>
|
||||
<ion-radio slot="end" value="username"></ion-radio>
|
||||
</ion-item>
|
||||
<ion-item>
|
||||
<ion-label>{{ 'core.user.email' | translate }}</ion-label>
|
||||
<ion-radio slot="start" value="email"></ion-radio>
|
||||
<ion-radio slot="end" value="email"></ion-radio>
|
||||
</ion-item>
|
||||
</ion-radio-group>
|
||||
<ion-item>
|
||||
|
|
|
@ -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<unknown> {
|
||||
|
||||
async canDeactivate(component: unknown | null): Promise<boolean> {
|
||||
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 <CanLeave> 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<boolean>;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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));
|
||||
|
|
Loading…
Reference in New Issue