Merge pull request #2672 from dpalou/MOBILE-3648

Mobile 3648
main
Dani Palou 2021-02-08 09:28:06 +01:00 committed by GitHub
commit 6f54e0eb06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
85 changed files with 12606 additions and 807 deletions

View File

@ -23,6 +23,7 @@ import { AddonCalendarModule } from './calendar/calendar.module';
import { AddonNotificationsModule } from './notifications/notifications.module';
import { AddonMessageOutputModule } from './messageoutput/messageoutput.module';
import { AddonMessagesModule } from './messages/messages.module';
import { AddonModModule } from './mod/mod.module';
@NgModule({
imports: [
@ -35,6 +36,7 @@ import { AddonMessagesModule } from './messages/messages.module';
AddonUserProfileFieldModule,
AddonNotificationsModule,
AddonMessageOutputModule,
AddonModModule,
],
})
export class AddonsModule {}

View File

@ -13,7 +13,7 @@
// limitations under the License.
import { Injectable } from '@angular/core';
import { Params } from '@angular/router';
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
import { CoreNavigator } from '@services/navigator';
@ -39,7 +39,7 @@ export class AddonBadgesBadgeLinkHandlerService extends CoreContentLinksHandlerB
* @param courseId Course ID related to the URL. Optional but recommended.
* @return List of (or promise resolved with list of) actions.
*/
getActions(siteIds: string[], url: string, params: Params): CoreContentLinksAction[] {
getActions(siteIds: string[], url: string, params: Record<string, string>): CoreContentLinksAction[] {
return [{
action: (siteId: string): void => {

View File

@ -49,7 +49,7 @@ export class AddonBadgesMyBadgesLinkHandlerService extends CoreContentLinksHandl
* @param siteId The site ID.
* @return Whether the handler is enabled for the URL and site.
*/
isEnabled(siteId: string): boolean | Promise<boolean> {
async isEnabled(siteId: string): Promise<boolean> {
return AddonBadges.instance.isPluginEnabled(siteId);
}

View File

@ -1,5 +1,4 @@
<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>
@ -8,13 +7,12 @@
<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">
<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="start" value="{{course.id}}"></ion-radio>
<ion-radio slot="end" value="{{course.id}}"></ion-radio>
</ion-item>
</ion-radio-group>
</ion-list>
</ion-radio-group>
</ng-container>
</ion-list>

View File

@ -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>

View File

@ -14,6 +14,7 @@
import { Injectable } from '@angular/core';
import { Params } from '@angular/router';
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
import { CoreNavigator } from '@services/navigator';
@ -39,7 +40,11 @@ export class AddonCalendarViewLinkHandlerService extends CoreContentLinksHandler
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @return List of (or promise resolved with list of) actions.
*/
getActions(siteIds: string[], url: string, params: Params): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
getActions(
siteIds: string[],
url: string,
params: Record<string, string>,
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
return [{
action: (siteId?: string): void => {
if (!params.view || params.view == 'month' || params.view == 'mini' || params.view == 'minithree') {
@ -47,7 +52,7 @@ export class AddonCalendarViewLinkHandlerService extends CoreContentLinksHandler
const stateParams: Params = {
courseId: params.course,
};
const timestamp = params.time ? params.time * 1000 : Date.now();
const timestamp = params.time ? Number(params.time) * 1000 : Date.now();
const date = new Date(timestamp);
stateParams.year = date.getFullYear();
@ -61,7 +66,7 @@ export class AddonCalendarViewLinkHandlerService extends CoreContentLinksHandler
const stateParams: Params = {
courseId: params.course,
};
const timestamp = params.time ? params.time * 1000 : Date.now();
const timestamp = params.time ? Number(params.time) * 1000 : Date.now();
const date = new Date(timestamp);
stateParams.year = date.getFullYear();
@ -94,7 +99,7 @@ export class AddonCalendarViewLinkHandlerService extends CoreContentLinksHandler
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @return Whether the handler is enabled for the URL and site.
*/
isEnabled(siteId: string, url: string, params: Params): boolean | Promise<boolean> {
async isEnabled(siteId: string, url: string, params: Record<string, string>): Promise<boolean> {
if (params.view && SUPPORTED_VIEWS.indexOf(params.view) == -1) {
// This type of view isn't supported in the app.
return false;

View File

@ -16,30 +16,22 @@
</ion-header>
<ion-content>
<core-split-view>
<ion-tab-bar class="core-tabs-bar">
<ion-row>
<ion-col class="tab-slide" [attr.aria-selected]="selected == 'confirmed'" (click)="selectTab('confirmed')">
<ion-label>{{ 'addon.messages.contacts' | translate}}</ion-label>
</ion-col>
<ion-col class="tab-slide" [attr.aria-selected]="selected != 'confirmed'" (click)="selectTab('requests')">
<ion-label>
{{ 'addon.messages.requests' | translate}}
<ion-badge *ngIf="requestsBadge">{{ requestsBadge }}</ion-badge>
</ion-label>
</ion-col>
</ion-row>
</ion-tab-bar>
<div *ngIf="selected == 'confirmed'">
<core-tabs [hideUntil]="true">
<!-- Contacts tab. -->
<core-tab [title]="'addon.messages.contacts' | translate" (ionSelect)="selectTab('confirmed')">
<ng-template>
<ion-refresher slot="fixed" [disabled]="!confirmedLoaded" (ionRefresh)="refreshData($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="confirmedLoaded" class="core-loading-center">
<ion-list class="ion-no-margin">
<ion-item class="ion-text-wrap addon-messages-conversation-item" *ngFor="let contact of confirmedContacts"
[title]="contact.fullname" (click)="selectUser(contact.id)" detail
[class.core-selected-item]="contact.id == selectedUserId">
<core-user-avatar slot="start" core-user-avatar [user]="contact" [checkOnline]="contact.showonlinestatus"
[linkProfile]="false"></core-user-avatar>
<ion-item class="ion-text-wrap addon-messages-conversation-item"
*ngFor="let contact of confirmedContacts" [title]="contact.fullname" detail
(click)="selectUser(contact.id)" [class.core-selected-item]="contact.id == selectedUserId">
<core-user-avatar slot="start" core-user-avatar [user]="contact"
[checkOnline]="contact.showonlinestatus" [linkProfile]="false">
</core-user-avatar>
<ion-label>
<h2>
<core-format-text [text]="contact.fullname" contextLevel="system" [contextInstanceId]="0">
@ -55,12 +47,16 @@
[message]="'addon.messages.nocontactsgetstarted' | translate">
</core-empty-box>
<core-infinite-loading [enabled]="confirmedCanLoadMore" (action)="loadMore($event)" [error]="confirmedLoadMoreError"
position="bottom">
<core-infinite-loading [enabled]="confirmedCanLoadMore" (action)="loadMore($event)"
[error]="confirmedLoadMoreError" position="bottom">
</core-infinite-loading>
</core-loading>
</div>
<div *ngIf="selected != 'confirmed'">
</ng-template>
</core-tab>
<!-- Requests tab. -->
<core-tab [title]="'addon.messages.requests' | translate" (ionSelect)="selectTab('requests')" [badge]="requestsBadge">
<ng-template>
<ion-refresher slot="fixed" [disabled]="!requestsLoaded" (ionRefresh)="refreshData($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
@ -82,11 +78,12 @@
<core-empty-box *ngIf="!requests.length" icon="far-address-book"
[message]="'addon.messages.nocontactrequests' | translate">
</core-empty-box>
<core-infinite-loading [enabled]="requestsCanLoadMore" (action)="loadMore($event)" [error]="requestsLoadMoreError"
position="bottom">
<core-infinite-loading [enabled]="requestsCanLoadMore" (action)="loadMore($event)"
[error]="requestsLoadMoreError" position="bottom">
</core-infinite-loading>
</core-loading>
</div>
</ng-template>
</core-tab>
</core-tabs>
</core-split-view>
</ion-content>

View File

@ -13,7 +13,7 @@
// limitations under the License.
import { Injectable } from '@angular/core';
import { Params } from '@angular/router';
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
import { CoreNavigator } from '@services/navigator';
@ -39,7 +39,11 @@ export class AddonMessagesDiscussionLinkHandlerService extends CoreContentLinksH
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @return List of (or promise resolved with list of) actions.
*/
getActions(siteIds: string[], url: string, params: Params): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
getActions(
siteIds: string[],
url: string,
params: Record<string, string>,
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
return [{
action: (siteId): void => {
const stateParams = {
@ -59,7 +63,7 @@ export class AddonMessagesDiscussionLinkHandlerService extends CoreContentLinksH
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @return Whether the handler is enabled for the URL and site.
*/
async isEnabled(siteId: string, url: string, params: Params): Promise<boolean> {
async isEnabled(siteId: string, url: string, params: Record<string, string>): Promise<boolean> {
const enabled = await AddonMessages.instance.isPluginEnabled(siteId);
if (!enabled) {
return false;

View File

@ -0,0 +1,49 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { 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 { AddonModLessonMenuModalPage } from './menu-modal/menu-modal';
import { AddonModLessonPasswordModalComponent } from './password-modal/password-modal';
@NgModule({
declarations: [
AddonModLessonIndexComponent,
AddonModLessonMenuModalPage,
AddonModLessonPasswordModalComponent,
],
imports: [
CommonModule,
IonicModule,
TranslateModule.forChild(),
FormsModule,
CoreSharedModule,
CoreCourseComponentsModule,
],
providers: [
],
exports: [
AddonModLessonIndexComponent,
AddonModLessonMenuModalPage,
AddonModLessonPasswordModalComponent,
],
})
export class AddonModLessonComponentsModule {}

View File

@ -0,0 +1,304 @@
<!-- Buttons to add to the header. -->
<core-navbar-buttons slot="end">
<core-context-menu>
<core-context-menu-item *ngIf="externalUrl" [priority]="900" [content]="'core.openinbrowser' | translate"
[href]="externalUrl" iconAction="fas-external-link-alt">
</core-context-menu-item>
<core-context-menu-item *ngIf="description" [priority]="800" [content]="'core.moduleintro' | translate"
(action)="expandDescription()" iconAction="fas-arrow-right">
</core-context-menu-item>
<core-context-menu-item *ngIf="blog" [priority]="750" content="{{'addon.blog.blog' | translate}}"
[iconAction]="'far-newspaper'" (action)="gotoBlog()">
</core-context-menu-item>
<core-context-menu-item *ngIf="loaded && !hasOffline && isOnline" [priority]="700" [content]="'core.refresh' | translate"
(action)="doRefresh(null, $event)" [iconAction]="refreshIcon" [closeOnClick]="false">
</core-context-menu-item>
<core-context-menu-item *ngIf="loaded && hasOffline && isOnline" [priority]="600" (action)="doRefresh(null, $event, true)"
[content]="'core.settings.synchronizenow' | translate" [iconAction]="syncIcon" [closeOnClick]="false">
</core-context-menu-item>
<core-context-menu-item *ngIf="prefetchStatusIcon" [priority]="500" [content]="prefetchText" (action)="prefetch($event)"
[iconAction]="prefetchStatusIcon" [closeOnClick]="false">
</core-context-menu-item>
<core-context-menu-item *ngIf="size" [priority]="400" [content]="'core.clearstoreddata' | translate:{$a: size}"
iconDescription="fas-cube" (action)="removeFiles($event)" iconAction="fas-trash" [closeOnClick]="false">
</core-context-menu-item>
</core-context-menu>
</core-navbar-buttons>
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="core-loading-center">
<core-tabs [hideUntil]="loaded" [selectedIndex]="selectedTab">
<!-- Index/Preview tab. -->
<core-tab [title]="'addon.mod_lesson.preview' | translate" (ionSelect)="indexSelected()">
<ng-template>
<core-course-module-description [description]="description" [component]="component" [componentId]="componentId"
contextLevel="module" [contextInstanceId]="module?.id" [courseId]="courseId">
</core-course-module-description>
<!-- Prevent access messages. Only show the first one. -->
<ion-card class="core-info-card" *ngIf="lesson && preventReasons.length">
<ion-item>
<ion-icon name="fas-info-circle" slot="start"></ion-icon>
<ion-label [innerHTML]="preventReasons[0].message"></ion-label>
</ion-item>
</ion-card>
<!-- Lesson has data to be synchronized -->
<ion-card class="core-warning-card" *ngIf="hasOffline">
<ion-item>
<ion-icon name="fas-exclamation-triangle" slot="start"></ion-icon>
<ion-label>{{ 'core.hasdatatosync' | translate: {$a: moduleName} }}</ion-label>
</ion-item>
</ion-card>
<!-- Input password for protected lessons. -->
<ion-card *ngIf="askPassword">
<form ion-list (ngSubmit)="submitPassword($event, passwordinput)" #passwordForm>
<ion-item class="ion-text-wrap">
<ion-label position="stacked">{{ 'addon.mod_lesson.enterpassword' | translate }}</ion-label>
<core-show-password name="password">
<ion-input name="password" type="password" placeholder="{{ 'core.login.password' | translate }}"
[core-auto-focus] #passwordinput [clearOnEdit]="false">
</ion-input>
</core-show-password>
</ion-item>
<ion-button expand="block" type="submit">
{{ 'addon.mod_lesson.continue' | translate }}
<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" />
</form>
</ion-card>
<core-loading [hideUntil]="!showSpinner">
<ion-list *ngIf="(lesson && !preventReasons.length) || retakeToReview">
<ng-container *ngIf="retakeToReview">
<!-- A retake was finished in a synchronization, allow reviewing it. -->
<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">
<!-- User left during the session and there is no time limit, ask to continue. -->
<ion-label>
<p [innerHTML]="'addon.mod_lesson.youhaveseen' | translate"></p>
<ion-grid>
<ion-row>
<ion-col>
<ion-button expand="block" color="light" (click)="start(false)">
{{ 'core.no' | translate }}
</ion-button>
</ion-col>
<ion-col>
<ion-button expand="block" (click)="start(true)">
{{ 'core.yes' | translate }}
</ion-button>
</ion-col>
</ion-row>
</ion-grid>
</ion-label>
</ion-item>
<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-chevron-right" slot="end"></ion-icon>
</ion-button>
</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.
This should be handled by preventMessages. -->
<ion-label [innerHTML]="'addon.mod_lesson.leftduringtimednoretake' | translate"></ion-label>
</ion-item>
<ng-container *ngIf="!leftDuringTimed && !finishedOffline">
<!-- User hasn't left during the session, show a start button. -->
<ion-button class="ion-text-wrap ion-margin" expand="block" *ngIf="!canManage"
(click)="start(false)">
{{ 'core.start' | translate }}
<ion-icon name="fas-chevron-right" slot="end"></ion-icon>
</ion-button>
<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>
</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-chevron-right" slot="end"></ion-icon>
</ion-button>
</ng-container>
</ion-list>
</core-loading>
</ng-template>
</core-tab>
<!-- Reports tab. -->
<core-tab *ngIf="canViewReports" [title]="'addon.mod_lesson.reports' | translate" (ionSelect)="reportsSelected()">
<ng-template>
<core-loading [hideUntil]="reportLoaded">
<!-- Group selector if the activity uses groups. -->
<ion-item class="ion-text-wrap" *ngIf="groupInfo && (groupInfo.separateGroups || groupInfo.visibleGroups)">
<ion-label id="addon-mod_lesson-groupslabel">
<span *ngIf="groupInfo.separateGroups">{{ 'core.groupsseparate' | translate }}</span>
<span *ngIf="groupInfo.visibleGroups">{{ 'core.groupsvisible' | translate }}</span>
</ion-label>
<ion-select [(ngModel)]="group" (ionChange)="setGroup(group)" aria-labelledby="addon-mod_lesson-groupslabel"
interface="action-sheet">
<ion-select-option *ngFor="let groupOpt of groupInfo.groups" [value]="groupOpt.id">{{groupOpt.name}}</ion-select-option>
</ion-select>
</ion-item>
<!-- No lesson retakes. -->
<core-empty-box *ngIf="!overview && selectedGroupName" icon="stats-chart"
[message]="'addon.mod_lesson.nolessonattemptsgroup' | translate:{$a: selectedGroupName}">
</core-empty-box>
<core-empty-box *ngIf="!overview && !selectedGroupName" icon="stats-chart"
[message]="'addon.mod_lesson.nolessonattempts' | translate">
</core-empty-box>
<!-- General statistics for the current group. -->
<ion-card class="addon-mod_lesson-lessonstats" *ngIf="overview">
<ion-card-header class="ion-text-wrap">
<ion-card-title>{{ 'addon.mod_lesson.lessonstats' | translate }}</ion-card-title>
</ion-card-header>
<!-- In tablet, max 2 rows with 3 columns. -->
<ion-grid class="ion-text-wrap ion-hide-md-down">
<ion-row *ngIf="overview.lessonscored">
<ion-col class="ion-text-center">
<h3 class="item-heading">{{ 'addon.mod_lesson.averagescore' | translate }}</h3>
<p *ngIf="overview.numofattempts > 0">
{{ 'core.percentagenumber' | translate:{$a: overview.avescore} }}
</p>
<p *ngIf="overview.numofattempts <= 0">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col>
<ion-col class="ion-text-center">
<h3 class="item-heading">{{ 'addon.mod_lesson.highscore' | translate }}</h3>
<p *ngIf="overview.highscore != null">
{{ 'core.percentagenumber' | translate:{$a: overview.highscore} }}
</p>
<p *ngIf="overview.highscore == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col>
<ion-col class="ion-text-center">
<h3 class="item-heading">{{ 'addon.mod_lesson.lowscore' | translate }}</h3>
<p *ngIf="overview.lowscore != null">
{{ 'core.percentagenumber' | translate:{$a: overview.lowscore} }}
</p>
<p *ngIf="overview.lowscore == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col>
</ion-row>
<ion-row>
<ion-col class="ion-text-center">
<h3 class="item-heading">{{ 'addon.mod_lesson.averagetime' | translate }}</h3>
<p *ngIf="overview.avetime != null && overview.numofattempts">{{ avetimeReadable }}</p>
<p *ngIf="overview.avetime == null || !overview.numofattempts">
{{ 'addon.mod_lesson.notcompleted' | translate }}
</p>
</ion-col>
<ion-col class="ion-text-center">
<h3 class="item-heading">{{ 'addon.mod_lesson.hightime' | translate }}</h3>
<p *ngIf="overview.hightime != null">{{ hightimeReadable }}</p>
<p *ngIf="overview.hightime == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col>
<ion-col class="ion-text-center">
<h3 class="item-heading">{{ 'addon.mod_lesson.lowtime' | translate }}</h3>
<p *ngIf="overview.lowtime != null">{{ lowtimeReadable }}</p>
<p *ngIf="overview.lowtime == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col>
</ion-row>
</ion-grid>
<!-- In phone, 3 rows with 1 or 2 columns. -->
<ion-grid class="ion-text-wrap ion-hide-md-up">
<ion-row>
<ion-col class="ion-text-center" *ngIf="overview.lessonscored">
<h3 class="item-heading">{{ 'addon.mod_lesson.averagescore' | translate }}</h3>
<p *ngIf="overview.numofattempts > 0">
{{ 'core.percentagenumber' | translate:{$a: overview.avescore} }}
</p>
<p *ngIf="overview.numofattempts <= 0">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col>
<ion-col [ngClass]="{'ion-text-center': overview.lessonscored}">
<h3 class="item-heading">{{ 'addon.mod_lesson.averagetime' | translate }}</h3>
<p *ngIf="overview.avetime != null && overview.numofattempts">{{ avetimeReadable }}</p>
<p *ngIf="overview.avetime == null || !overview.numofattempts">
{{ 'addon.mod_lesson.notcompleted' | translate }}
</p>
</ion-col>
</ion-row>
<ion-row>
<ion-col class="ion-text-center" *ngIf="overview.lessonscored">
<h3 class="item-heading">{{ 'addon.mod_lesson.highscore' | translate }}</h3>
<p *ngIf="overview.highscore != null">
{{ 'core.percentagenumber' | translate:{$a: overview.highscore} }}
</p>
<p *ngIf="overview.highscore == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col>
<ion-col [ngClass]="{'ion-text-center': overview.lessonscored}">
<h3 class="item-heading">{{ 'addon.mod_lesson.hightime' | translate }}</h3>
<p *ngIf="overview.hightime != null">{{ hightimeReadable }}</p>
<p *ngIf="overview.hightime == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col>
</ion-row>
<ion-row>
<ion-col class="ion-text-center" *ngIf="overview.lessonscored">
<h3 class="item-heading">{{ 'addon.mod_lesson.lowscore' | translate }}</h3>
<p *ngIf="overview.lowscore != null">
{{ 'core.percentagenumber' | translate:{$a: overview.lowscore} }}
</p>
<p *ngIf="overview.lowscore == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col>
<ion-col [ngClass]="{'ion-text-center': overview.lessonscored}">
<h3 class="item-heading">{{ 'addon.mod_lesson.lowtime' | translate }}</h3>
<p *ngIf="overview.lowtime != null">{{ lowtimeReadable }}</p>
<p *ngIf="overview.lowtime == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col>
</ion-row>
</ion-grid>
</ion-card>
<!-- List of students that have retakes. -->
<ion-card *ngIf="overview">
<ion-card-header class="ion-text-wrap">
<ion-card-title>{{ 'addon.mod_lesson.overview' | translate }}</ion-card-title>
</ion-card-header>
<ion-item class="ion-text-wrap" *ngFor="let student of overview.students" button
(click)="openRetake(student.id)">
<core-user-avatar [user]="student" slot="start" [userId]="student.id" [courseId]="courseId">
</core-user-avatar>
<ion-label>
<h2>{{ student.fullname }}</h2>
<core-progress-bar [progress]="student.bestgrade"></core-progress-bar>
</ion-label>
</ion-item>
</ion-card>
</core-loading>
</ng-template>
</core-tab>
</core-tabs>
</core-loading>

View File

@ -0,0 +1,728 @@
// (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 { CoreNavigator } from '@services/navigator';
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<void> {
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<void> {
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<void> {
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<void>[] = [];
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<void> {
if (!this.lesson || !this.accessInfo) {
return;
}
const promises: Promise<unknown>[] = [];
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<void> {
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();
}
/**
* Perform the invalidate content function.
*
* @return Promise resolved when done.
*/
protected async invalidateContent(): Promise<void> {
const promises: Promise<unknown>[] = [];
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<void> {
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.
*/
protected async playLesson(continueLast?: boolean): Promise<void> {
if (!this.lesson || !this.accessInfo) {
return;
}
// 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;
}
await CoreNavigator.instance.navigate(`../player/${this.courseId}/${this.lesson.id}`, {
params: {
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);
}
/**
* 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 || !this.lesson) {
// No retake to review, stop.
return;
}
CoreNavigator.instance.navigate(`../player/${this.courseId}/${this.lesson.id}`, {
params: {
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<void> {
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 = <AttemptsOverview> 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<void> {
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<void> {
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(<string> 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<AddonModLessonSyncResult> {
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<void> {
try {
this.lesson = await AddonModLesson.instance.getLessonWithPassword(this.lesson!.id, { password, cmId: this.module!.id });
this.password = password;
} catch (error) {
this.password = '';
throw error;
}
}
/**
* Open a certain user retake.
*
* @param userId User ID to view.
* @return Promise resolved when done.
*/
async openRetake(userId: number): Promise<void> {
await CoreNavigator.instance.navigate(`../user-retake/${this.courseId}/${this.lesson!.id}`, {
params: {
userId,
},
});
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
super.ngOnDestroy();
this.dataSentObserver?.off();
}
}
/**
* Overview data including user avatars, calculated in this component.
*/
type AttemptsOverview = Omit<AddonModLessonAttemptsOverviewWSData, 'students'> & {
students?: StudentWithImage[];
};
/**
* Overview student data with the avatar, calculated in this component.
*/
type StudentWithImage = AddonModLessonAttemptsOverviewsStudentWSData & {
profileimageurl?: string;
};

View File

@ -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>

View File

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

View File

@ -0,0 +1,28 @@
<ion-header>
<ion-toolbar>
<ion-title>{{ 'core.login.password' | translate }}</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="ion-padding addon-mod_lesson-password-modal">
<form (ngSubmit)="submitPassword($event, passwordinput)" #passwordForm>
<ion-item>
<ion-label>{{ 'addon.mod_lesson.enterpassword' | translate }}</ion-label>
<core-show-password name="password">
<ion-input name="password" type="password" placeholder="{{ 'core.login.password' | translate }}"
[core-auto-focus] #passwordinput [clearOnEdit]="false"></ion-input>
</core-show-password>
</ion-item>
<ion-button expand="block" type="submit">
{{ 'addon.mod_lesson.continue' | translate }}
<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" />
</form>
</ion-content>

View File

@ -0,0 +1,58 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, ViewChild, ElementRef } from '@angular/core';
import { IonInput } from '@ionic/angular';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { ModalController } from '@singletons';
/**
* Modal that asks the password for a lesson.
*/
@Component({
selector: 'page-addon-mod-lesson-password-modal',
templateUrl: 'password-modal.html',
})
export class AddonModLessonPasswordModalComponent {
@ViewChild('passwordForm') formElement?: ElementRef;
/**
* Send the password back.
*
* @param e Event.
* @param password The input element.
*/
submitPassword(e: Event, password: IonInput): void {
e.preventDefault();
e.stopPropagation();
CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, false, CoreSites.instance.getCurrentSiteId());
ModalController.instance.dismiss(password.value);
}
/**
* Close modal.
*/
closeModal(): void {
CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId());
ModalController.instance.dismiss();
}
}

View File

@ -0,0 +1,86 @@
{
"answer": "Answer",
"attempt": "Attempt: {{$a}}",
"attemptheader": "Attempt",
"attemptsremaining": "You have {{$a}} attempt(s) remaining",
"averagescore": "Average score",
"averagetime": "Average time",
"branchtable": "Content",
"cannotfindattempt": "Error: could not find attempt",
"cannotfinduser": "Error: could not find users",
"clusterjump": "Unseen question within a cluster",
"completed": "Completed",
"congratulations": "Congratulations - end of lesson reached",
"continue": "Continue",
"continuetonextpage": "Continue to next page.",
"defaultessayresponse": "Your essay will be graded by your teacher.",
"detailedstats": "Detailed statistics",
"didnotanswerquestion": "Did not answer this question.",
"displayofgrade": "Display of grade (for students only)",
"displayscorewithessays": "<p>You earned {{$a.score}} out of {{$a.tempmaxgrade}} for the automatically graded questions.</p>\n<p>Your {{$a.essayquestions}} essay question(s) will be graded and added into your final score at a later date.</p>\n<p>Your current grade without the essay question(s) is {{$a.score}} out of {{$a.grade}}.</p>",
"displayscorewithoutessays": "Your score is {{$a.score}} (out of {{$a.grade}}).",
"emptypassword": "Password cannot be empty",
"enterpassword": "Please enter the password:",
"eolstudentoutoftimenoanswers": "You did not answer any questions. You have received a 0 for this lesson.",
"errorprefetchrandombranch": "This lesson contains a jump to a random content page. It can't be attempted in the app until it has been started in a web browser.",
"errorreviewretakenotlast": "This attempt can no longer be reviewed because another attempt has been finished.",
"finish": "Finish",
"finishretakeoffline": "This attempt was finished offline.",
"firstwrong": "You have answered incorrectly. Would you like to attempt the question again? (If you now answer the question correctly, it will not count towards your final score.)",
"gotoendoflesson": "Go to the end of the lesson",
"grade": "Grade",
"highscore": "High score",
"hightime": "High time",
"leftduringtimed": "You have left during a timed lesson.<br />Please click on Continue to restart the lesson.",
"leftduringtimednoretake": "You have left during a timed lesson and you are<br />not allowed to retake or continue the lesson.",
"lessonmenu": "Lesson menu",
"lessonstats": "Lesson statistics",
"linkedmedia": "Linked media",
"loginfail": "Login failed, please try again...",
"lowscore": "Low score",
"lowtime": "Low time",
"maximumnumberofattemptsreached": "Maximum number of attempts reached - Moving to next page",
"modattemptsnoteacher": "Student review only works for students.",
"modulenameplural": "Lessons",
"noanswer": "One or more questions have no answer given. Please go back and submit an answer.",
"nolessonattempts": "No attempts have been made on this lesson.",
"nolessonattemptsgroup": "No attempts have been made by {{$a}} group members on this lesson.",
"notcompleted": "Not completed",
"numberofcorrectanswers": "Number of correct answers: {{$a}}",
"numberofpagesviewed": "Number of questions answered: {{$a}}",
"numberofpagesviewednotice": "Number of questions answered: {{$a.nquestions}} (You should answer at least {{$a.minquestions}})",
"ongoingcustom": "You have earned {{$a.score}} point(s) out of {{$a.currenthigh}} point(s) thus far.",
"ongoingnormal": "You have answered {{$a.correct}} correctly out of {{$a.viewed}} attempts.",
"or": "OR",
"overview": "Overview",
"preview": "Preview",
"progressbarteacherwarning2": "You will not see the progress bar because you can edit this lesson",
"progresscompleted": "You have completed {{$a}}% of the lesson",
"question": "Question",
"rawgrade": "Raw grade",
"reports": "Reports",
"response": "Response",
"retakefinishedinsync": "An offline attempt was synchronised. Do you want to review it?",
"retakelabelfull": "{{retake}}: {{grade}} {{timestart}} ({{duration}})",
"retakelabelshort": "{{retake}}: {{grade}} {{timestart}}",
"review": "Review",
"reviewlesson": "Review lesson",
"reviewquestionback": "Yes, I'd like to try again",
"reviewquestioncontinue": "No, I just want to go on to the next question",
"secondpluswrong": "Not quite. Would you like to try again?",
"submit": "Submit",
"teacherjumpwarning": "A {{$a.cluster}} jump or an {{$a.unseen}} jump is being used in this lesson. The next page jump will be used instead. Log in as a student to test these jumps.",
"teacherongoingwarning": "The ongoing score is only displayed for the student. Log in as a student to test the ongoing score.",
"teachertimerwarning": "Timer only works for students. Test the timer by logging in as a student.",
"thatsthecorrectanswer": "That's the correct answer",
"thatsthewronganswer": "That's the wrong answer",
"timeremaining": "Time remaining",
"timetaken": "Time taken",
"unseenpageinbranch": "Unseen question within a content page",
"warningretakefinished": "The attempt was finished on the site.",
"welldone": "Well done!",
"youhaveseen": "You have seen more than one page of this lesson already.<br />Do you want to start at the last page you saw?",
"youranswer": "Your answer",
"yourcurrentgradeisoutof": "Your current grade is {{$a.grade}} out of {{$a.total}}",
"youshouldview": "You should answer at least: {{$a}}"
}

View File

@ -0,0 +1,41 @@
// (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),
},
{
path: 'player/:courseId/:lessonId',
loadChildren: () => import('./pages/player/player.module').then( m => m.AddonModLessonPlayerPageModule),
},
{
path: 'user-retake/:courseId/:lessonId',
loadChildren: () => import('./pages/user-retake/user-retake.module').then( m => m.AddonModLessonUserRetakePageModule),
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
})
export class AddonModLessonLazyModule {}

View File

@ -0,0 +1,71 @@
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { Routes } from '@angular/router';
import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate';
import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate';
import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module';
import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate';
import { CoreCronDelegate } from '@services/cron';
import { CORE_SITE_SCHEMAS } from '@services/sites';
import { AddonModLessonComponentsModule } from './components/components.module';
import { SITE_SCHEMA, OFFLINE_SITE_SCHEMA, SYNC_SITE_SCHEMA } from './services/database/lesson';
import { AddonModLessonGradeLinkHandler } from './services/handlers/grade-link';
import { AddonModLessonIndexLinkHandler } from './services/handlers/index-link';
import { AddonModLessonListLinkHandler } from './services/handlers/list-link';
import { AddonModLessonModuleHandler, AddonModLessonModuleHandlerService } from './services/handlers/module';
import { AddonModLessonPrefetchHandler } from './services/handlers/prefetch';
import { AddonModLessonPushClickHandler } from './services/handlers/push-click';
import { AddonModLessonReportLinkHandler } from './services/handlers/report-link';
import { AddonModLessonSyncCronHandler } from './services/handlers/sync-cron';
const routes: Routes = [
{
path: AddonModLessonModuleHandlerService.PAGE_NAME,
loadChildren: () => import('./lesson-lazy.module').then(m => m.AddonModLessonLazyModule),
},
];
@NgModule({
imports: [
CoreMainMenuTabRoutingModule.forChild(routes),
AddonModLessonComponentsModule,
],
providers: [
{
provide: CORE_SITE_SCHEMAS,
useValue: [SITE_SCHEMA, OFFLINE_SITE_SCHEMA, SYNC_SITE_SCHEMA],
multi: true,
},
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreCourseModuleDelegate.instance.registerHandler(AddonModLessonModuleHandler.instance);
CoreCourseModulePrefetchDelegate.instance.registerHandler(AddonModLessonPrefetchHandler.instance);
CoreCronDelegate.instance.register(AddonModLessonSyncCronHandler.instance);
CoreContentLinksDelegate.instance.registerHandler(AddonModLessonGradeLinkHandler.instance);
CoreContentLinksDelegate.instance.registerHandler(AddonModLessonIndexLinkHandler.instance);
CoreContentLinksDelegate.instance.registerHandler(AddonModLessonListLinkHandler.instance);
CoreContentLinksDelegate.instance.registerHandler(AddonModLessonReportLinkHandler.instance);
CorePushNotificationsDelegate.instance.registerClickHandler(AddonModLessonPushClickHandler.instance);
},
},
],
})
export class AddonModLessonModule {}

View File

@ -0,0 +1,23 @@
<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]="module?.id" [courseId]="courseId">
</core-format-text>
</ion-title>
<ion-buttons slot="end">
<!-- The buttons defined by the component will be added in here. -->
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!lessonComponent?.loaded" (ionRefresh)="lessonComponent?.doRefresh($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<addon-mod-lesson-index [module]="module" [courseId]="courseId" [group]="group" [action]="action"
(dataRetrieved)="updateData($event)">
</addon-mod-lesson-index>
</ion-content>

View File

@ -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 {}

View File

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

View File

@ -0,0 +1,288 @@
<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">
<ion-label>
<h3 class="item-heading">{{ 'addon.mod_lesson.youranswer' | translate }}</h3>
<p>
<core-format-text [component]="component" [componentId]="lesson?.coursemodule"
[text]="question.useranswer" contextLevel="module"
[contextInstanceId]="lesson?.coursemodule" [courseId]="courseId">
</core-format-text>
</p>
</ion-label>
</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-label>
<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-label>
<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-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-title>{{ 'addon.mod_lesson.congratulations' | translate }}</ion-card-title>
</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>

View File

@ -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 {}

View File

@ -0,0 +1,38 @@
:host {
--background-odd: var(--gray-lighter);
}
:host-context(body.dark) {
--background-odd: var(--gray-darker);
}
:host ::ng-deep {
.addon-mod_lesson-slideshow {
max-width: 100%;
max-height: 100%;
margin: 0 auto;
}
.studentanswer {
padding-inline-start: 8px;
}
table {
width: 100%;
margin-top: 1.5rem;
tr:nth-child(odd) {
background-color: var(--background-odd);
}
tr:last-child td {
border-bottom: 0;
}
td {
padding: 5px;
line-height: 1.5;
border-bottom: 1px solid var(--gray);
}
}
}

View File

@ -0,0 +1,796 @@
// (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> {
this.lessonId = CoreNavigator.instance.getRouteNumberParam('lessonId')!;
this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!;
this.password = CoreNavigator.instance.getRouteParam('password');
this.review = !!CoreNavigator.instance.getRouteBooleanParam('review');
this.currentPage = CoreNavigator.instance.getRouteNumberParam('pageId');
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;
};

View File

@ -0,0 +1,247 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>{{ 'addon.mod_lesson.detailedstats' | translate }}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="doRefresh($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="loaded">
<div *ngIf="student">
<!-- Student data. -->
<ion-item class="ion-text-wrap" core-user-link [userId]="student.id" [courseId]="courseId" [title]="student.fullname">
<core-user-avatar [user]="student" slot="start" [userId]="student.id" [courseId]="courseId">
</core-user-avatar>
<ion-label>
<h2>{{student.fullname}}</h2>
<core-progress-bar [progress]="student.bestgrade"></core-progress-bar>
</ion-label>
</ion-item>
<!-- Retake selector if there is more than one retake. -->
<ion-item class="ion-text-wrap" *ngIf="student.attempts && student.attempts.length > 1">
<ion-label id="addon-mod_lesson-retakeslabel">{{ 'addon.mod_lesson.attemptheader' | translate }}</ion-label>
<ion-select [(ngModel)]="selectedRetake" (ionChange)="changeRetake(selectedRetake!)"
aria-labelledby="addon-mod_lesson-retakeslabel" interface="action-sheet">
<ion-select-option *ngFor="let retake of student.attempts" [value]="retake.try">
{{retake.label}}
</ion-select-option>
</ion-select>
</ion-item>
<!-- Retake stats. -->
<ion-list *ngIf="retake && retake.userstats && retake.userstats.gradeinfo" class="addon-mod_lesson-userstats">
<ion-item>
<ion-label class="ion-text-wrap">
<ion-grid class="ion-no-padding">
<ion-row>
<ion-col>
<h3 class="item-heading">{{ 'addon.mod_lesson.grade' | translate }}</h3>
<p>{{ 'core.percentagenumber' | translate:{$a: retake.userstats.grade} }}</p>
</ion-col>
<ion-col>
<h3 class="item-heading">{{ 'addon.mod_lesson.rawgrade' | translate }}</h3>
<p>{{ retake.userstats.gradeinfo.earned }} / {{ retake.userstats.gradeinfo.total }}</p>
</ion-col>
</ion-row>
</ion-grid>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<h3 class="item-heading">{{ 'addon.mod_lesson.timetaken' | translate }}</h3>
<p>{{ timeTakenReadable }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<h3 class="item-heading">{{ 'addon.mod_lesson.completed' | translate }}</h3>
<p>{{ retake.userstats.completed * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
</ion-list>
<!-- Not completed, no stats. -->
<ion-item class="ion-text-wrap" *ngIf="retake && (!retake.userstats || !retake.userstats.gradeinfo)">
<ion-label>{{ 'addon.mod_lesson.notcompleted' | translate }}</ion-label>
</ion-item>
<!-- Pages. -->
<ng-container *ngIf="retake">
<!-- The "text-dimmed" class does nothing, but the same goes for the "dimmed" class in Moodle. -->
<ion-card *ngFor="let page of retake.answerpages" class="addon-mod_lesson-answerpage"
[ngClass]="{'text-dimmed': page.grayout}">
<ion-card-header class="ion-text-wrap">
<ion-card-title>{{page.qtype}}: {{page.title}}</ion-card-title>
</ion-card-header>
<ion-item class="ion-text-wrap" lines="none">
<ion-label>
<h3 class="item-heading">{{ 'addon.mod_lesson.question' | translate }}</h3>
<p>
<core-format-text [component]="component" [componentId]="lesson?.coursemodule" [maxHeight]="50"
[text]="page.contents" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
[courseId]="courseId">
</core-format-text>
</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" lines="none">
<ion-label>
<h3 class="item-heading">{{ 'addon.mod_lesson.answer' | translate }}</h3>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" lines="none"
*ngIf="!page.answerdata || !page.answerdata.answers || !page.answerdata.answers.length">
<ion-label>
<p>{{ 'addon.mod_lesson.didnotanswerquestion' | translate }}</p>
</ion-label>
</ion-item>
<div *ngIf="page.answerdata && page.answerdata.answers && page.answerdata.answers.length"
class="addon-mod_lesson-answer">
<ng-container *ngFor="let answer of page.answerdata.answers">
<ion-item lines="none" *ngIf="page.isContent">
<ion-label class="ion-text-wrap">
<ion-grid class="ion-no-padding">
<!-- Content page, display a button and the content. -->
<ion-row>
<ion-col>
<ion-button expand="block" class="ion-text-wrap" color="light" [disabled]="true">{{ answer[0].buttonText }}</ion-button>
</ion-col>
<ion-col>
<p [innerHTML]="answer[0].content"></p>
</ion-col>
</ion-row>
</ion-grid>
</ion-label>
</ion-item>
<ng-container *ngIf="page.isQuestion">
<!-- Question page, show the right input for the answer. -->
<!-- Truefalse or matching. -->
<ion-item class="ion-text-wrap" *ngIf="answer[0].isCheckbox"
[ngClass]="{'addon-mod_lesson-highlight': answer[0].highlight}">
<ion-label>
<p>
<core-format-text [component]="component" [componentId]="lesson?.coursemodule"
[text]="answer[0].content" contextLevel="module"
[contextInstanceId]="lesson?.coursemodule" [courseId]="courseId">
</core-format-text>
</p>
<ion-badge *ngIf="answer[1]" color="dark">
<core-format-text [component]="component" [componentId]="lesson?.coursemodule"
[text]="answer[1]" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
[courseId]="courseId">
</core-format-text>
</ion-badge>
</ion-label>
<ion-checkbox [attr.name]="answer[0].name" [ngModel]="answer[0].checked" [disabled]="true"
slot="end">
</ion-checkbox>
</ion-item>
<!-- Short answer or numeric. -->
<ion-item class="ion-text-wrap" *ngIf="answer[0].isText" lines="none">
<ion-label>
<p>{{ answer[0].value }}</p>
<ion-badge *ngIf="answer[1]" color="dark">
<core-format-text [component]="component" [componentId]="lesson?.coursemodule"
[text]="answer[1]" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
[courseId]="courseId">
</core-format-text>
</ion-badge>
</ion-label>
</ion-item>
<!-- Matching. -->
<ion-item lines="none" *ngIf="answer[0].isSelect">
<ion-label class="ion-text-wrap">
<ion-grid class="ion-no-padding">
<ion-row>
<ion-col>
<p>
<core-format-text [component]="component" [componentId]="lesson?.coursemodule"
[text]=" answer[0].content" contextLevel="module"
[contextInstanceId]="lesson?.coursemodule" [courseId]="courseId">
</core-format-text>
</p>
</ion-col>
<ion-col>
<p>{{answer[0].value}}</p>
<ion-badge *ngIf="answer[1]" color="dark">
<core-format-text [component]="component" [componentId]="lesson?.coursemodule"
[text]="answer[1]" contextLevel="module"
[contextInstanceId]="lesson?.coursemodule" [courseId]="courseId">
</core-format-text>
</ion-badge>
</ion-col>
</ion-row>
</ion-grid>
</ion-label>
</ion-item>
<!-- Essay or couldn't determine. -->
<ion-item class="ion-text-wrap" lines="none"
*ngIf="!answer[0].isCheckbox && !answer[0].isText && !answer[0].isSelect">
<ion-label>
<p>
<core-format-text [component]="component" [componentId]="lesson?.coursemodule"
[text]="answer[0]" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
[courseId]="courseId">
</core-format-text>
</p>
<ion-badge *ngIf="answer[1]" color="dark">
<core-format-text [component]="component" [componentId]="lesson?.coursemodule"
[text]="answer[1]" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
[courseId]="courseId">
</core-format-text>
</ion-badge>
</ion-label>
</ion-item>
</ng-container>
<ion-item class="ion-text-wrap" *ngIf="!page.isContent && !page.isQuestion" lines="none">
<!-- Another page (end of branch, ...). -->
<ion-label>
<p>
<core-format-text [component]="component" [componentId]="lesson?.coursemodule"
[text]="answer[0]" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
[courseId]="courseId">
</core-format-text>
</p>
<ion-badge *ngIf="answer[1]" color="dark">
<core-format-text [component]="component" [componentId]="lesson?.coursemodule"
[text]="answer[1]" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
[courseId]="courseId">
</core-format-text>
</ion-badge>
</ion-label>
</ion-item>
</ng-container>
<ion-item class="ion-text-wrap" *ngIf="page.answerdata.response" lines="none">
<ion-label>
<h3 class="item-heading">{{ 'addon.mod_lesson.response' | translate }}</h3>
<p>
<core-format-text [component]="component" [componentId]="lesson?.coursemodule"
[text]="page.answerdata.response" contextLevel="module"
[contextInstanceId]="lesson?.coursemodule" [courseId]="courseId">
</core-format-text>
</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="page.answerdata.score">
<ion-label><p>{{page.answerdata.score}}</p></ion-label>
</ion-item>
</div>
</ion-card>
</ng-container>
</div>
</core-loading>
</ion-content>

View File

@ -0,0 +1,46 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreSharedModule } from '@/core/shared.module';
import { AddonModLessonUserRetakePage } from './user-retake';
const routes: Routes = [
{
path: '',
component: AddonModLessonUserRetakePage,
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
CommonModule,
IonicModule,
TranslateModule.forChild(),
FormsModule,
CoreSharedModule,
],
declarations: [
AddonModLessonUserRetakePage,
],
exports: [RouterModule],
})
export class AddonModLessonUserRetakePageModule {}

View File

@ -0,0 +1,17 @@
:host {
.button-disabled {
opacity: 0.4;
}
.addon-mod_lesson-highlight {
--background: var(--blue-light);
ion-label, ion-label p {
color: var(--blue-dark);
}
}
.item-interactive-disabled ion-label {
opacity: 0.5;
}
}

View File

@ -0,0 +1,275 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit } from '@angular/core';
import { IonRefresher } from '@ionic/angular';
import { CoreError } from '@classes/errors/error';
import { CoreUser } from '@features/user/services/user';
import { CoreNavigator } from '@services/navigator';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text';
import { CoreUtils } from '@services/utils/utils';
import { Translate } from '@singletons';
import {
AddonModLesson,
AddonModLessonAttemptsOverviewsAttemptWSData,
AddonModLessonAttemptsOverviewsStudentWSData,
AddonModLessonGetUserAttemptWSResponse,
AddonModLessonLessonWSData,
AddonModLessonProvider,
AddonModLessonUserAttemptAnswerData,
AddonModLessonUserAttemptAnswerPageWSData,
} from '../../services/lesson';
import { AddonModLessonAnswerData, AddonModLessonHelper } from '../../services/lesson-helper';
import { CoreTimeUtils } from '@services/utils/time';
/**
* Page that displays a retake made by a certain user.
*/
@Component({
selector: 'page-addon-mod-lesson-user-retake',
templateUrl: 'user-retake.html',
styleUrls: ['user-retake.scss'],
})
export class AddonModLessonUserRetakePage implements OnInit {
component = AddonModLessonProvider.COMPONENT;
lesson?: AddonModLessonLessonWSData; // The lesson the retake belongs to.
courseId!: number; // Course ID the lesson belongs to.
selectedRetake?: number; // The retake to see.
student?: StudentData; // Data about the student and his retakes.
retake?: RetakeToDisplay; // Data about the retake.
loaded?: boolean; // Whether the data has been loaded.
timeTakenReadable?: string; // Time taken in a readable format.
protected lessonId!: number; // The lesson ID the retake belongs to.
protected userId?: number; // User ID to see the retakes.
protected retakeNumber?: number; // Number of the initial retake to see.
protected previousSelectedRetake?: number; // To be able to detect the previous selected retake when it has changed.
/**
* Component being initialized.
*/
ngOnInit(): void {
this.lessonId = CoreNavigator.instance.getRouteNumberParam('lessonId')!;
this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!;
this.userId = CoreNavigator.instance.getRouteNumberParam('userId') || CoreSites.instance.getCurrentSiteUserId();
this.retakeNumber = CoreNavigator.instance.getRouteNumberParam('retake');
// Fetch the data.
this.fetchData().finally(() => {
this.loaded = true;
});
}
/**
* Change the retake displayed.
*
* @param retakeNumber The new retake number.
*/
async changeRetake(retakeNumber: number): Promise<void> {
this.loaded = false;
try {
await this.setRetake(retakeNumber);
} catch (error) {
this.selectedRetake = this.previousSelectedRetake;
CoreDomUtils.instance.showErrorModal(CoreUtils.instance.addDataNotDownloadedError(error, 'Error getting attempt.'));
} finally {
this.loaded = true;
}
}
/**
* Pull to refresh.
*
* @param refresher Refresher.
*/
doRefresh(refresher: CustomEvent<IonRefresher>): void {
this.refreshData().finally(() => {
refresher?.detail.complete();
});
}
/**
* Get lesson and retake data.
*
* @return Promise resolved when done.
*/
protected async fetchData(): Promise<void> {
try {
this.lesson = await AddonModLesson.instance.getLessonById(this.courseId, this.lessonId);
// Get the retakes overview for all participants.
const data = await AddonModLesson.instance.getRetakesOverview(this.lesson.id, {
cmId: this.lesson.coursemodule,
});
// Search the student.
const student: StudentData | undefined = data?.students?.find(student => student.id == this.userId);
if (!student) {
// Student not found.
throw new CoreError(Translate.instance.instant('addon.mod_lesson.cannotfinduser'));
}
if (!student.attempts.length) {
// No retakes.
throw new CoreError(Translate.instance.instant('addon.mod_lesson.cannotfindattempt'));
}
student.bestgrade = CoreTextUtils.instance.roundToDecimals(student.bestgrade, 2);
student.attempts.forEach((retake) => {
if (!this.selectedRetake && this.retakeNumber == retake.try) {
// The retake specified as parameter exists. Use it.
this.selectedRetake = this.retakeNumber;
}
retake.label = AddonModLessonHelper.instance.getRetakeLabel(retake);
});
if (!this.selectedRetake) {
// Retake number not specified or not valid, use the last retake.
this.selectedRetake = student.attempts[student.attempts.length - 1].try;
}
// Get the profile image of the user.
const user = await CoreUtils.instance.ignoreErrors(CoreUser.instance.getProfile(student.id, this.courseId, true));
this.student = student;
this.student.profileimageurl = user?.profileimageurl;
await this.setRetake(this.selectedRetake);
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting data.', true);
}
}
/**
* Refreshes data.
*
* @return Promise resolved when done.
*/
protected async refreshData(): Promise<void> {
const promises: Promise<void>[] = [];
promises.push(AddonModLesson.instance.invalidateLessonData(this.courseId));
if (this.lesson) {
promises.push(AddonModLesson.instance.invalidateRetakesOverview(this.lesson.id));
promises.push(AddonModLesson.instance.invalidateUserRetakesForUser(this.lesson.id, this.userId));
}
await CoreUtils.instance.ignoreErrors(Promise.all(promises));
await this.fetchData();
}
/**
* Set the retake to view and load its data.
*
* @param retakeNumber Retake number to set.
* @return Promise resolved when done.
*/
protected async setRetake(retakeNumber: number): Promise<void> {
this.selectedRetake = retakeNumber;
const retakeData = await AddonModLesson.instance.getUserRetake(this.lessonId, retakeNumber, {
cmId: this.lesson!.coursemodule,
userId: this.userId,
});
this.retake = this.formatRetake(retakeData);
this.previousSelectedRetake = this.selectedRetake;
}
/**
* Format retake data, adding some calculated data.
*
* @param data Retake data.
* @return Formatted data.
*/
protected formatRetake(retakeData: AddonModLessonGetUserAttemptWSResponse): RetakeToDisplay {
const formattedData = <RetakeToDisplay> retakeData;
if (formattedData.userstats.gradeinfo) {
// Completed.
formattedData.userstats.grade = CoreTextUtils.instance.roundToDecimals(formattedData.userstats.grade, 2);
this.timeTakenReadable = CoreTimeUtils.instance.formatTime(formattedData.userstats.timetotake);
}
// Format pages data.
formattedData.answerpages.forEach((page) => {
if (AddonModLesson.instance.answerPageIsContent(page)) {
page.isContent = true;
if (page.answerdata?.answers) {
page.answerdata.answers.forEach((answer) => {
// Content pages only have 1 valid field in the answer array.
answer[0] = AddonModLessonHelper.instance.getContentPageAnswerDataFromHtml(answer[0]);
});
}
} else if (AddonModLesson.instance.answerPageIsQuestion(page)) {
page.isQuestion = true;
if (page.answerdata?.answers) {
page.answerdata.answers.forEach((answer) => {
// Only the first field of the answer array requires to be parsed.
answer[0] = AddonModLessonHelper.instance.getQuestionPageAnswerDataFromHtml(answer[0]);
});
}
}
});
return formattedData;
}
}
/**
* Student data with some calculated data.
*/
type StudentData = Omit<AddonModLessonAttemptsOverviewsStudentWSData, 'attempts'> & {
profileimageurl?: string;
attempts: AttemptWithLabel[];
};
/**
* Student attempt with a calculated label.
*/
type AttemptWithLabel = AddonModLessonAttemptsOverviewsAttemptWSData & {
label?: string;
};
/**
* Retake with calculated data.
*/
type RetakeToDisplay = Omit<AddonModLessonGetUserAttemptWSResponse, 'answerpages'> & {
answerpages: AnswerPage[];
};
/**
* Answer page with calculated data.
*/
type AnswerPage = Omit<AddonModLessonUserAttemptAnswerPageWSData, 'answerdata'> & {
isContent?: boolean;
isQuestion?: boolean;
answerdata?: AnswerData;
};
/**
* Answer data with calculated data.
*/
type AnswerData = Omit<AddonModLessonUserAttemptAnswerData, 'answers'> & {
answers?: (string[] | AddonModLessonAnswerData)[]; // User answers.
};

View File

@ -0,0 +1,228 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { CoreSiteSchema } from '@services/sites';
/**
* Database variables for AddonModLessonProvider.
*/
export const PASSWORD_TABLE_NAME = 'addon_mod_lesson_password';
export const SITE_SCHEMA: CoreSiteSchema = {
name: 'AddonModLessonProvider',
version: 1,
tables: [
{
name: PASSWORD_TABLE_NAME,
columns: [
{
name: 'lessonid',
type: 'INTEGER',
primaryKey: true,
},
{
name: 'password',
type: 'TEXT',
},
{
name: 'timemodified',
type: 'INTEGER',
},
],
},
],
};
/**
* Database variables for AddonModLessonOfflineProvider.
*/
export const RETAKES_TABLE_NAME = 'addon_mod_lesson_retakes';
export const PAGE_ATTEMPTS_TABLE_NAME = 'addon_mod_lesson_page_attempts';
export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = {
name: 'AddonModLessonOfflineProvider',
version: 1,
tables: [
{
name: RETAKES_TABLE_NAME,
columns: [
{
name: 'lessonid',
type: 'INTEGER',
primaryKey: true, // Only 1 offline retake per lesson.
},
{
name: 'retake', // Retake number.
type: 'INTEGER',
notNull: true,
},
{
name: 'courseid',
type: 'INTEGER',
},
{
name: 'finished',
type: 'INTEGER',
},
{
name: 'outoftime',
type: 'INTEGER',
},
{
name: 'timemodified',
type: 'INTEGER',
},
{
name: 'lastquestionpage',
type: 'INTEGER',
},
],
},
{
name: PAGE_ATTEMPTS_TABLE_NAME,
columns: [
{
name: 'lessonid',
type: 'INTEGER',
notNull: true,
},
{
name: 'retake', // Retake number.
type: 'INTEGER',
notNull: true,
},
{
name: 'pageid',
type: 'INTEGER',
notNull: true,
},
{
name: 'timemodified',
type: 'INTEGER',
notNull: true,
},
{
name: 'courseid',
type: 'INTEGER',
},
{
name: 'data',
type: 'TEXT',
},
{
name: 'type',
type: 'INTEGER',
},
{
name: 'newpageid',
type: 'INTEGER',
},
{
name: 'correct',
type: 'INTEGER',
},
{
name: 'answerid',
type: 'INTEGER',
},
{
name: 'useranswer',
type: 'TEXT',
},
],
// A user can attempt several times per page and retake.
primaryKeys: ['lessonid', 'retake', 'pageid', 'timemodified'],
},
],
};
/**
* Database variables for AddonModLessonSyncProvider.
*/
export const RETAKES_FINISHED_SYNC_TABLE_NAME = 'addon_mod_lesson_retakes_finished_sync';
export const SYNC_SITE_SCHEMA: CoreSiteSchema = {
name: 'AddonModLessonSyncProvider',
version: 1,
tables: [
{
name: RETAKES_FINISHED_SYNC_TABLE_NAME,
columns: [
{
name: 'lessonid',
type: 'INTEGER',
primaryKey: true,
},
{
name: 'retake',
type: 'INTEGER',
},
{
name: 'pageid',
type: 'INTEGER',
},
{
name: 'timefinished',
type: 'INTEGER',
},
],
},
],
};
/**
* Lesson retake data.
*/
export type AddonModLessonPasswordDBRecord = {
lessonid: number;
password: string;
timemodified: number;
};
/**
* Lesson retake data.
*/
export type AddonModLessonRetakeDBRecord = {
lessonid: number;
retake: number;
courseid: number;
finished: number;
outoftime?: number | null;
timemodified?: number | null;
lastquestionpage?: number | null;
};
/**
* Lesson page attempts data.
*/
export type AddonModLessonPageAttemptDBRecord = {
lessonid: number;
retake: number;
pageid: number;
timemodified: number;
courseid: number;
data: string | null;
type: number;
newpageid: number;
correct: number;
answerid: number | null;
useranswer: string | null;
};
/**
* Data about a retake finished in sync.
*/
export type AddonModLessonRetakeFinishedInSyncDBRecord = {
lessonid: number;
retake: number;
pageid: number;
timefinished: number;
};

View File

@ -0,0 +1,102 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreContentLinksModuleGradeHandler } from '@features/contentlinks/classes/module-grade-handler';
import { CoreCourse } from '@features/course/services/course';
import { CoreCourseHelper } from '@features/course/services/course-helper';
import { CoreNavigator } from '@services/navigator';
import { CoreDomUtils } from '@services/utils/dom';
import { makeSingleton } from '@singletons';
import { AddonModLesson } from '../lesson';
import { AddonModLessonModuleHandlerService } from './module';
/**
* Handler to treat links to lesson grade.
*/
@Injectable({ providedIn: 'root' })
export class AddonModLessonGradeLinkHandlerService extends CoreContentLinksModuleGradeHandler {
name = 'AddonModLessonGradeLinkHandler';
canReview = true;
constructor() {
super('AddonModLesson', 'lesson');
}
/**
* Go to the page to review.
*
* @param url The URL to treat.
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param courseId Course ID related to the URL.
* @param siteId Site to use.
* @return Promise resolved when done.
*/
protected async goToReview(
url: string,
params: Record<string, unknown>,
courseId: number,
siteId: string,
): Promise<void> {
const moduleId = Number(params.id);
const modal = await CoreDomUtils.instance.showModalLoading();
try {
const module = await CoreCourse.instance.getModuleBasicInfo(moduleId, siteId);
courseId = Number(module.course || courseId || params.courseid || params.cid);
// Check if the user can see the user reports in the lesson.
const accessInfo = await AddonModLesson.instance.getAccessInformation(module.instance, { cmId: module.id, siteId });
if (accessInfo.canviewreports) {
// User can view reports, go to view the report.
CoreNavigator.instance.navigateToSitePath(
AddonModLessonModuleHandlerService.PAGE_NAME + `/user-retake/${courseId}/${module.instance}`,
{
params: { userId: Number(params.userid) },
siteId,
},
);
} else {
// User cannot view the report, go to lesson index.
CoreCourseHelper.instance.navigateToModule(moduleId, siteId, courseId, module.section);
}
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errorgetmodule', true);
} finally {
modal.dismiss();
}
}
/**
* Check if the handler is enabled for a certain site (site + user) and a URL.
* If not defined, defaults to true.
*
* @param siteId The site ID.
* @param url The URL to treat.
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param courseId Course ID related to the URL. Optional but recommended.
* @return Whether the handler is enabled for the URL and site.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async isEnabled(siteId: string, url: string, params: Record<string, string>, courseId?: number): Promise<boolean> {
return AddonModLesson.instance.isPluginEnabled(siteId);
}
}
export class AddonModLessonGradeLinkHandler extends makeSingleton(AddonModLessonGradeLinkHandlerService) {}

View File

@ -0,0 +1,121 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreContentLinksModuleIndexHandler } from '@features/contentlinks/classes/module-index-handler';
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
import { CoreCourse } from '@features/course/services/course';
import { CoreCourseHelper } from '@features/course/services/course-helper';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { makeSingleton } from '@singletons';
import { AddonModLesson } from '../lesson';
/**
* Handler to treat links to lesson index.
*/
@Injectable({ providedIn: 'root' })
export class AddonModLessonIndexLinkHandlerService extends CoreContentLinksModuleIndexHandler {
name = 'AddonModLessonIndexLinkHandler';
constructor() {
super('AddonModLesson', 'lesson');
}
/**
* Get the list of actions for a link (url).
*
* @param siteIds List of sites the URL belongs to.
* @param url The URL to treat.
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param courseId Course ID related to the URL. Optional but recommended.
* @return List of (or promise resolved with list of) actions.
*/
getActions(
siteIds: string[],
url: string,
params: Record<string, string>,
courseId?: number,
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
courseId = Number(courseId || params.courseid || params.cid);
return [{
action: (siteId): void => {
/* Ignore the pageid param. If we open the lesson player with a certain page and the user hasn't started
the lesson, an error is thrown: could not find lesson_timer records. */
if (params.userpassword) {
this.navigateToModuleWithPassword(parseInt(params.id, 10), courseId!, params.userpassword, siteId);
} else {
CoreCourseHelper.instance.navigateToModule(parseInt(params.id, 10), siteId, courseId);
}
},
}];
}
/**
* Check if the handler is enabled for a certain site (site + user) and a URL.
* If not defined, defaults to true.
*
* @param siteId The site ID.
* @param url The URL to treat.
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param courseId Course ID related to the URL. Optional but recommended.
* @return Whether the handler is enabled for the URL and site.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
isEnabled(siteId: string, url: string, params: Record<string, string>, courseId?: number): Promise<boolean> {
return AddonModLesson.instance.isPluginEnabled(siteId);
}
/**
* Navigate to a lesson module (index page) with a fixed password.
*
* @param moduleId Module ID.
* @param courseId Course ID.
* @param password Password.
* @param siteId Site ID.
* @return Promise resolved when navigated.
*/
protected async navigateToModuleWithPassword(
moduleId: number,
courseId: number,
password: string,
siteId: string,
): Promise<void> {
const modal = await CoreDomUtils.instance.showModalLoading();
try {
// Get the module.
const module = await CoreCourse.instance.getModuleBasicInfo(moduleId, siteId);
courseId = courseId || module.course;
// Store the password so it's automatically used.
await CoreUtils.instance.ignoreErrors(AddonModLesson.instance.storePassword(module.instance, password, siteId));
await CoreCourseHelper.instance.navigateToModule(moduleId, siteId, courseId, module.section);
} catch {
// Error, go to index page.
await CoreCourseHelper.instance.navigateToModule(moduleId, siteId, courseId);
} finally {
modal.dismiss();
}
}
}
export class AddonModLessonIndexLinkHandler extends makeSingleton(AddonModLessonIndexLinkHandlerService) {}

View File

@ -0,0 +1,45 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreContentLinksModuleListHandler } from '@features/contentlinks/classes/module-list-handler';
import { makeSingleton } from '@singletons';
import { AddonModLesson } from '../lesson';
/**
* Handler to treat links to lesson list page.
*/
@Injectable({ providedIn: 'root' })
export class AddonModLessonListLinkHandlerService extends CoreContentLinksModuleListHandler {
name = 'AddonModLessonListLinkHandler';
constructor() {
super('AddonModLesson', 'lesson');
}
/**
* Check if the handler is enabled on a site level.
*
* @return Promise resolved with boolean: whether or not the handler is enabled on a site level.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
isEnabled(siteId: string, url: string, params: Record<string, string>, courseId?: number): Promise<boolean> {
return AddonModLesson.instance.isPluginEnabled(siteId);
}
}
export class AddonModLessonListLinkHandler extends makeSingleton(AddonModLessonListLinkHandlerService) {}

View File

@ -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 = 'mod_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<boolean> {
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<Type<unknown> | undefined> {
return AddonModLessonIndexComponent;
}
}
export class AddonModLessonModuleHandler extends makeSingleton(AddonModLessonModuleHandlerService) {}

View File

@ -0,0 +1,576 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreCanceledError } from '@classes/errors/cancelederror';
import { CoreError } from '@classes/errors/error';
import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler';
import { CoreCourse, CoreCourseCommonModWSOptions, CoreCourseAnyModuleData } from '@features/course/services/course';
import { CoreFilepool } from '@services/filepool';
import { CoreGroups } from '@services/groups';
import { CoreFileSizeSum, CorePluginFileDelegate } from '@services/plugin-file-delegate';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreUtils } from '@services/utils/utils';
import { CoreWSExternalFile } from '@services/ws';
import { makeSingleton, ModalController, Translate } from '@singletons';
import { AddonModLessonPasswordModalComponent } from '../../components/password-modal/password-modal';
import {
AddonModLesson,
AddonModLessonGetAccessInformationWSResponse,
AddonModLessonLessonWSData,
AddonModLessonPasswordOptions,
AddonModLessonProvider,
} from '../lesson';
import { AddonModLessonSync, AddonModLessonSyncResult } from '../lesson-sync';
/**
* Handler to prefetch lessons.
*/
@Injectable({ providedIn: 'root' })
export class AddonModLessonPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase {
name = 'AddonModLesson';
modName = 'lesson';
component = AddonModLessonProvider.COMPONENT;
// Don't check timers to decrease positives. If a user performs some action it will be reflected in other items.
updatesNames = /^configuration$|^.*files$|^grades$|^gradeitems$|^pages$|^answers$|^questionattempts$|^pagesviewed$/;
/**
* Ask password.
*
* @return Promise resolved with the password.
*/
protected async askUserPassword(): Promise<string> {
// Create and show the modal.
const modal = await ModalController.instance.create({
component: AddonModLessonPasswordModalComponent,
});
await modal.present();
const password = <string | undefined> await modal.onWillDismiss();
if (typeof password != 'string') {
throw new CoreCanceledError();
}
return password;
}
/**
* Get the download size of a module.
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @param single True if we're downloading a single module, false if we're downloading a whole section.
* @return Promise resolved with the size.
*/
async getDownloadSize(module: CoreCourseAnyModuleData, courseId: number, single?: boolean): Promise<CoreFileSizeSum> {
const siteId = CoreSites.instance.getCurrentSiteId();
let lesson = await AddonModLesson.instance.getLesson(courseId, module.id, { siteId });
// Get the lesson password if it's needed.
const passwordData = await this.getLessonPassword(lesson.id, {
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
askPassword: single,
siteId,
});
lesson = passwordData.lesson || lesson;
// Get intro files and media files.
let files = lesson.mediafiles || [];
files = files.concat(this.getIntroFilesFromInstance(module, lesson));
const result = await CorePluginFileDelegate.instance.getFilesDownloadSize(files);
// Get the pages to calculate the size.
const pages = await AddonModLesson.instance.getPages(lesson.id, {
cmId: module.id,
password: passwordData.password,
siteId,
});
pages.forEach((page) => {
result.size += page.filessizetotal;
});
return result;
}
/**
* Get the lesson password if needed. If not stored, it can ask the user to enter it.
*
* @param lessonId Lesson ID.
* @param options Other options.
* @return Promise resolved when done.
*/
async getLessonPassword(
lessonId: number,
options: AddonModLessonGetPasswordOptions = {},
): Promise<AddonModLessonGetPasswordResult> {
options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId();
// Get access information to check if password is needed.
const accessInfo = await AddonModLesson.instance.getAccessInformation(lessonId, options);
if (!accessInfo.preventaccessreasons.length) {
// Password not needed.
return { accessInfo };
}
const passwordNeeded = accessInfo.preventaccessreasons.length == 1 &&
AddonModLesson.instance.isPasswordProtected(accessInfo);
if (!passwordNeeded) {
// Lesson cannot be played, reject.
throw new CoreError(accessInfo.preventaccessreasons[0].message);
}
// The lesson requires a password. Check if there is one in DB.
let password = await CoreUtils.instance.ignoreErrors(AddonModLesson.instance.getStoredPassword(lessonId));
if (password) {
try {
return this.validatePassword(lessonId, accessInfo, password, options);
} catch {
// Error validating it.
}
}
// Ask for the password if allowed.
if (!options.askPassword) {
// Cannot ask for password, reject.
throw new CoreError(accessInfo.preventaccessreasons[0].message);
}
password = await this.askUserPassword();
return this.validatePassword(lessonId, accessInfo, password, options);
}
/**
* Invalidate the prefetched content.
*
* @param moduleId The module ID.
* @param courseId The course ID the module belongs to.
* @return Promise resolved when the data is invalidated.
*/
async invalidateContent(moduleId: number, courseId: number): Promise<void> {
// Only invalidate the data that doesn't ignore cache when prefetching.
await Promise.all([
AddonModLesson.instance.invalidateLessonData(courseId),
CoreCourse.instance.invalidateModule(moduleId),
CoreGroups.instance.invalidateActivityAllowedGroups(moduleId),
]);
}
/**
* Invalidate WS calls needed to determine module status.
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @return Promise resolved when invalidated.
*/
async invalidateModule(module: CoreCourseAnyModuleData, courseId: number): Promise<void> {
// Invalidate data to determine if module is downloadable.
const siteId = CoreSites.instance.getCurrentSiteId();
const lesson = await AddonModLesson.instance.getLesson(courseId, module.id, {
readingStrategy: CoreSitesReadingStrategy.PreferCache,
siteId,
});
await Promise.all([
AddonModLesson.instance.invalidateLessonData(courseId, siteId),
AddonModLesson.instance.invalidateAccessInformation(lesson.id, siteId),
]);
}
/**
* Check if a module can be downloaded. If the function is not defined, we assume that all modules are downloadable.
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @return Whether the module can be downloaded. The promise should never be rejected.
*/
async isDownloadable(module: CoreCourseAnyModuleData, courseId: number): Promise<boolean> {
const siteId = CoreSites.instance.getCurrentSiteId();
const lesson = await AddonModLesson.instance.getLesson(courseId, module.id, { siteId });
const accessInfo = await AddonModLesson.instance.getAccessInformation(lesson.id, { cmId: module.id, siteId });
// If it's a student and lesson isn't offline, it isn't downloadable.
if (!accessInfo.canviewreports && !AddonModLesson.instance.isLessonOffline(lesson)) {
return false;
}
// It's downloadable if there are no prevent access reasons or there is just 1 and it's password.
return !accessInfo.preventaccessreasons.length ||
(accessInfo.preventaccessreasons.length == 1 && AddonModLesson.instance.isPasswordProtected(accessInfo));
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return Promise resolved with a boolean indicating if the handler is enabled.
*/
isEnabled(): Promise<boolean> {
return AddonModLesson.instance.isPluginEnabled();
}
/**
* Prefetch a module.
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @param single True if we're downloading a single module, false if we're downloading a whole section.
* @param dirPath Path of the directory where to store all the content files.
* @return Promise resolved when done.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
prefetch(module: CoreCourseAnyModuleData, courseId?: number, single?: boolean, dirPath?: string): Promise<void> {
return this.prefetchPackage(module, courseId, this.prefetchLesson.bind(this, module, courseId, single));
}
/**
* Prefetch a lesson.
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @param single True if we're downloading a single module, false if we're downloading a whole section.
* @return Promise resolved when done.
*/
protected async prefetchLesson(module: CoreCourseAnyModuleData, courseId?: number, single?: boolean): Promise<void> {
const siteId = CoreSites.instance.getCurrentSiteId();
courseId = courseId || module.course || 1;
const commonOptions = {
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
};
const modOptions = {
cmId: module.id,
...commonOptions, // Include all common options.
};
let lesson = await AddonModLesson.instance.getLesson(courseId, module.id, commonOptions);
// Get the lesson password if it's needed.
const passwordData = await this.getLessonPassword(lesson.id, {
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
askPassword: single,
siteId,
});
lesson = passwordData.lesson || lesson;
let accessInfo = passwordData.accessInfo;
const password = passwordData.password;
if (AddonModLesson.instance.isLessonOffline(lesson) && !AddonModLesson.instance.leftDuringTimed(accessInfo)) {
// The user didn't left during a timed session. Call launch retake to make sure there is a started retake.
accessInfo = await this.launchRetake(lesson.id, password, modOptions, siteId);
}
const promises: Promise<void>[] = [];
// Download intro files and media files.
const files = (lesson.mediafiles || []).concat(this.getIntroFilesFromInstance(module, lesson));
promises.push(CoreFilepool.instance.addFilesToQueue(siteId, files, this.component, module.id));
if (AddonModLesson.instance.isLessonOffline(lesson)) {
promises.push(this.prefetchPlayData(lesson, password, accessInfo.attemptscount, modOptions));
}
if (accessInfo.canviewreports) {
promises.push(this.prefetchGroupInfo(module.id, lesson.id, modOptions));
promises.push(this.prefetchReportsData(module.id, lesson.id, modOptions));
}
await Promise.all(promises);
}
/**
* Launch a retake and return the updated access information.
*
* @param lessonId Lesson ID.
* @param password Password (if needed).
* @param modOptions Options.
* @param siteId Site ID.
*/
protected async launchRetake(
lessonId: number,
password: string | undefined,
modOptions: CoreCourseCommonModWSOptions,
siteId: string,
): Promise<AddonModLessonGetAccessInformationWSResponse> {
// The user didn't left during a timed session. Call launch retake to make sure there is a started retake.
await AddonModLesson.instance.launchRetake(lessonId, password, undefined, false, siteId);
const results = await Promise.all([
CoreUtils.instance.ignoreErrors(CoreFilepool.instance.updatePackageDownloadTime(siteId, this.component, module.id)),
AddonModLesson.instance.getAccessInformation(lessonId, modOptions),
]);
return results[1];
}
/**
* Prefetch data to play the lesson in offline.
*
* @param lesson Lesson.
* @param password Password (if needed).
* @param retake Retake to prefetch.
* @param options Options.
* @return Promise resolved when done.
*/
protected async prefetchPlayData(
lesson: AddonModLessonLessonWSData,
password: string | undefined,
retake: number,
modOptions: CoreCourseCommonModWSOptions,
): Promise<void> {
const passwordOptions = {
password,
...modOptions, // Include all mod options.
};
await Promise.all([
this.prefetchPagesData(lesson, passwordOptions),
// Prefetch user timers to be able to calculate timemodified in offline.
CoreUtils.instance.ignoreErrors(AddonModLesson.instance.getTimers(lesson.id, modOptions)),
// Prefetch viewed pages in last retake to calculate progress.
AddonModLesson.instance.getContentPagesViewedOnline(lesson.id, retake, modOptions),
// Prefetch question attempts in last retake for offline calculations.
AddonModLesson.instance.getQuestionsAttemptsOnline(lesson.id, retake, modOptions),
]);
}
/**
* Prefetch data related to pages.
*
* @param lesson Lesson.
* @param options Options.
* @return Promise resolved when done.
*/
protected async prefetchPagesData(
lesson: AddonModLessonLessonWSData,
options: AddonModLessonPasswordOptions,
): Promise<void> {
const pages = await AddonModLesson.instance.getPages(lesson.id, options);
let hasRandomBranch = false;
// Get the data for each page.
const promises = pages.map(async (data) => {
// Check if any page has a RANDOMBRANCH jump.
if (!hasRandomBranch) {
for (let i = 0; i < data.jumps.length; i++) {
if (data.jumps[i] == AddonModLessonProvider.LESSON_RANDOMBRANCH) {
hasRandomBranch = true;
break;
}
}
}
// Get the page data. We don't pass accessInfo because we don't need to calculate the offline data.
const pageData = await AddonModLesson.instance.getPageData(lesson, data.page.id, {
includeContents: true,
includeOfflineData: false,
...options, // Include all options.
});
// Download the page files.
let pageFiles = pageData.contentfiles || [];
pageData.answers.forEach((answer) => {
pageFiles = pageFiles.concat(answer.answerfiles);
pageFiles = pageFiles.concat(answer.responsefiles);
});
await CoreFilepool.instance.addFilesToQueue(options.siteId!, pageFiles, this.component, module.id);
});
// Prefetch the list of possible jumps for offline navigation. Do it here because we know hasRandomBranch.
promises.push(this.prefetchPossibleJumps(lesson.id, hasRandomBranch, options));
await Promise.all(promises);
}
/**
* Prefetch possible jumps.
*
* @param lessonId Lesson ID.
* @param hasRandomBranch Whether any page has a random branch jump.
* @param modOptions Options.
* @return Promise resolved when done.
*/
protected async prefetchPossibleJumps(
lessonId: number,
hasRandomBranch: boolean,
modOptions: CoreCourseCommonModWSOptions,
): Promise<void> {
try {
await AddonModLesson.instance.getPagesPossibleJumps(lessonId, modOptions);
} catch (error) {
if (hasRandomBranch) {
// The WebSevice probably failed because RANDOMBRANCH aren't supported if the user hasn't seen any page.
throw new CoreError(Translate.instance.instant('addon.mod_lesson.errorprefetchrandombranch'));
}
throw error;
}
}
/**
* Prefetch group info.
*
* @param moduleId Module ID.
* @param lessonId Lesson ID.
* @param modOptions Options.
* @return Promise resolved when done.
*/
protected async prefetchGroupInfo(
moduleId: number,
lessonId: number,
modOptions: CoreCourseCommonModWSOptions,
): Promise<void> {
const groupInfo = await CoreGroups.instance.getActivityGroupInfo(moduleId, false, undefined, modOptions.siteId, true);
await Promise.all(groupInfo.groups?.map(async (group) => {
await AddonModLesson.instance.getRetakesOverview(lessonId, {
groupId: group.id,
...modOptions, // Include all options.
});
}) || []);
}
/**
* Prefetch reports data.
*
* @param moduleId Module ID.
* @param lessonId Lesson ID.
* @param modOptions Options.
* @return Promise resolved when done.
*/
protected async prefetchReportsData(
moduleId: number,
lessonId: number,
modOptions: CoreCourseCommonModWSOptions,
): Promise<void> {
// Always get all participants, even if there are no groups.
const data = await AddonModLesson.instance.getRetakesOverview(lessonId, modOptions);
if (!data || !data.students) {
return;
}
// Prefetch the last retake for each user.
await Promise.all(data.students.map(async (student) => {
const lastRetake = student.attempts?.[student.attempts.length - 1];
if (!lastRetake) {
return;
}
const attempt = await AddonModLesson.instance.getUserRetake(lessonId, lastRetake.try, {
userId: student.id,
...modOptions, // Include all options.
});
if (!attempt?.answerpages) {
return;
}
// Download embedded files in essays.
const files: CoreWSExternalFile[] = [];
attempt.answerpages.forEach((answerPage) => {
if (!answerPage.page || answerPage.page.qtype != AddonModLessonProvider.LESSON_PAGE_ESSAY) {
return;
}
answerPage.answerdata?.answers?.forEach((answer) => {
files.push(...CoreFilepool.instance.extractDownloadableFilesFromHtmlAsFakeFileObjects(answer[0]));
});
});
await CoreFilepool.instance.addFilesToQueue(modOptions.siteId!, files, this.component, moduleId);
}));
}
/**
* Validate the password.
*
* @param lessonId Lesson ID.
* @param info Lesson access info.
* @param pwd Password to check.
* @param options Other options.
* @return Promise resolved when done.
*/
protected async validatePassword(
lessonId: number,
accessInfo: AddonModLessonGetAccessInformationWSResponse,
password: string,
options: CoreCourseCommonModWSOptions = {},
): Promise<AddonModLessonGetPasswordResult> {
options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId();
const lesson = await AddonModLesson.instance.getLessonWithPassword(lessonId, {
password,
...options, // Include all options.
});
// Password is ok, store it and return the data.
await AddonModLesson.instance.storePassword(lesson.id, password, options.siteId);
return {
password,
lesson,
accessInfo,
};
}
/**
* Sync a module.
*
* @param module Module.
* @param courseId Course ID the module belongs to
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
sync(module: CoreCourseAnyModuleData, courseId: number, siteId?: string): Promise<AddonModLessonSyncResult> {
return AddonModLessonSync.instance.syncLesson(module.instance!, false, false, siteId);
}
}
export class AddonModLessonPrefetchHandler extends makeSingleton(AddonModLessonPrefetchHandlerService) {}
/**
* Options to pass to get lesson password.
*/
export type AddonModLessonGetPasswordOptions = CoreCourseCommonModWSOptions & {
askPassword?: boolean; // True if we should ask for password if needed, false otherwise.
};
/**
* Result of getLessonPassword.
*/
export type AddonModLessonGetPasswordResult = {
password?: string;
lesson?: AddonModLessonLessonWSData;
accessInfo: AddonModLessonGetAccessInformationWSResponse;
};

View File

@ -0,0 +1,70 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreGrades } from '@features/grades/services/grades';
import { CoreGradesHelper } from '@features/grades/services/grades-helper';
import { CorePushNotificationsClickHandler } from '@features/pushnotifications/services/push-delegate';
import { CorePushNotificationsNotificationBasicData } from '@features/pushnotifications/services/pushnotifications';
import { CoreUtils } from '@services/utils/utils';
import { makeSingleton } from '@singletons';
/**
* Handler for lesson push notifications clicks.
*/
@Injectable({ providedIn: 'root' })
export class AddonModLessonPushClickHandlerService implements CorePushNotificationsClickHandler {
name = 'AddonModLessonPushClickHandler';
priority = 200;
featureName = 'CoreCourseModuleDelegate_AddonModLesson';
/**
* Check if a notification click is handled by this handler.
*
* @param notification The notification to check.
* @return Whether the notification click is handled by this handler.
*/
async handles(notification: NotificationData): Promise<boolean> {
if (CoreUtils.instance.isTrueOrOne(notification.notif) && notification.moodlecomponent == 'mod_lesson' &&
notification.name == 'graded_essay') {
return CoreGrades.instance.isPluginEnabledForCourse(Number(notification.courseid), notification.site);
}
return false;
}
/**
* Handle the notification click.
*
* @param notification The notification to check.
* @return Promise resolved when done.
*/
handleClick(notification: NotificationData): Promise<void> {
const data = notification.customdata || {};
const courseId = Number(notification.courseid);
const moduleId = Number(data.cmid);
return CoreGradesHelper.instance.goToGrades(courseId, undefined, moduleId, notification.site);
}
}
export class AddonModLessonPushClickHandler extends makeSingleton(AddonModLessonPushClickHandlerService) {}
type NotificationData = CorePushNotificationsNotificationBasicData & {
courseid: number;
};

View File

@ -0,0 +1,165 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
import { CoreCourse } from '@features/course/services/course';
import { CoreNavigator } from '@services/navigator';
import { CoreDomUtils } from '@services/utils/dom';
import { makeSingleton } from '@singletons';
import { AddonModLesson } from '../lesson';
import { AddonModLessonModuleHandlerService } from './module';
/**
* Handler to treat links to lesson report.
*/
@Injectable({ providedIn: 'root' })
export class AddonModLessonReportLinkHandlerService extends CoreContentLinksHandlerBase {
name = 'AddonModLessonReportLinkHandler';
featureName = 'CoreCourseModuleDelegate_AddonModLesson';
pattern = /\/mod\/lesson\/report\.php.*([&?]id=\d+)/;
/**
* Get the list of actions for a link (url).
*
* @param siteIds List of sites the URL belongs to.
* @param url The URL to treat.
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param courseId Course ID related to the URL. Optional but recommended.
* @param data Extra data to handle the URL.
* @return List of (or promise resolved with list of) actions.
*/
getActions(
siteIds: string[],
url: string,
params: Record<string, string>,
courseId?: number,
data?: unknown, // eslint-disable-line @typescript-eslint/no-unused-vars
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
courseId = Number(courseId || params.courseid || params.cid);
return [{
action: (siteId) => {
if (!params.action || params.action == 'reportoverview') {
// Go to overview.
this.openReportOverview(Number(params.id), courseId, Number(params.group), siteId);
} else if (params.action == 'reportdetail') {
this.openUserRetake(Number(params.id), Number(params.userid), Number(params.try), siteId, courseId);
}
},
}];
}
/**
* Check if the handler is enabled for a certain site (site + user) and a URL.
* If not defined, defaults to true.
*
* @param siteId The site ID.
* @param url The URL to treat.
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param courseId Course ID related to the URL. Optional but recommended.
* @return Whether the handler is enabled for the URL and site.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async isEnabled(siteId: string, url: string, params: Record<string, string>, courseId?: number): Promise<boolean> {
if (params.action == 'reportdetail' && !params.userid) {
// Individual details are only available if the teacher is seeing a certain user.
return false;
}
return AddonModLesson.instance.isPluginEnabled(siteId);
}
/**
* Open report overview.
*
* @param moduleId Module ID.
* @param courseId Course ID.
* @param groupId Group ID.
* @param siteId Site ID.
* @param navCtrl The NavController to use to navigate.
* @return Promise resolved when done.
*/
protected async openReportOverview(moduleId: number, courseId?: number, groupId?: number, siteId?: string): Promise<void> {
const modal = await CoreDomUtils.instance.showModalLoading();
try {
// Get the module object.
const module = await CoreCourse.instance.getModuleBasicInfo(moduleId, siteId);
const params = {
module: module,
courseId: courseId || module.course,
action: 'report',
group: groupId === undefined || isNaN(groupId) ? null : groupId,
};
CoreNavigator.instance.navigateToSitePath(AddonModLessonModuleHandlerService.PAGE_NAME, { params, siteId });
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'Error processing link.');
} finally {
modal.dismiss();
}
}
/**
* Open a user's retake.
*
* @param moduleId Module ID.
* @param userId User ID.
* @param courseId Course ID.
* @param retake Retake to open.
* @param groupId Group ID.
* @param siteId Site ID.
* @param navCtrl The NavController to use to navigate.
* @return Promise resolved when done.
*/
protected async openUserRetake(
moduleId: number,
userId: number,
retake: number,
siteId: string,
courseId?: number,
): Promise<void> {
const modal = await CoreDomUtils.instance.showModalLoading();
try {
// Get the module object.
const module = await CoreCourse.instance.getModuleBasicInfo(moduleId, siteId);
courseId = courseId || module.course;
const params = {
userId: userId,
retake: retake || 0,
};
CoreNavigator.instance.navigateToSitePath(
AddonModLessonModuleHandlerService.PAGE_NAME + `/user-retake/${courseId}/${module.instance}`,
{ params, siteId },
);
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'Error processing link.');
} finally {
modal.dismiss();
}
}
}
export class AddonModLessonReportLinkHandler extends makeSingleton(AddonModLessonReportLinkHandlerService) {}

View File

@ -0,0 +1,52 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreCronHandler } from '@services/cron';
import { makeSingleton } from '@singletons';
import { AddonModLessonSync } from '../lesson-sync';
/**
* Synchronization cron handler.
*/
@Injectable({ providedIn: 'root' })
export class AddonModLessonSyncCronHandlerService implements CoreCronHandler {
name = 'AddonModLessonSyncCronHandler';
/**
* Execute the process.
* Receives the ID of the site affected, undefined for all sites.
*
* @param siteId ID of the site affected, undefined for all sites.
* @param force Wether the execution is forced (manual sync).
* @return Promise resolved when done, rejected if failure.
*/
execute(siteId?: string, force?: boolean): Promise<void> {
return AddonModLessonSync.instance.syncAllLessons(siteId, force);
}
/**
* Get the time between consecutive executions.
*
* @return Time between consecutive executions (in ms).
*/
getInterval(): number {
return AddonModLessonSync.instance.syncInterval;
}
}
export class AddonModLessonSyncCronHandler extends makeSingleton(AddonModLessonSyncCronHandlerService) {}

View File

@ -0,0 +1,739 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text';
import { CoreTimeUtils } from '@services/utils/time';
import { makeSingleton, Translate } from '@singletons';
import {
AddonModLesson,
AddonModLessonAttemptsOverviewsAttemptWSData,
AddonModLessonGetPageDataWSResponse,
AddonModLessonProvider,
} from './lesson';
/**
* Helper service that provides some features for quiz.
*/
@Injectable({ providedIn: 'root' })
export class AddonModLessonHelperProvider {
constructor(
protected formBuilder: FormBuilder,
) {}
/**
* Given the HTML of next activity link, format it to extract the href and the text.
*
* @param activityLink HTML of the activity link.
* @return Formatted data.
*/
formatActivityLink(activityLink: string): AddonModLessonActivityLink {
const element = CoreDomUtils.instance.convertToElement(activityLink);
const anchor = element.querySelector('a');
if (!anchor) {
// Anchor not found, return the original HTML.
return {
formatted: false,
label: activityLink,
href: '',
};
}
return {
formatted: true,
label: anchor.innerHTML,
href: anchor.href,
};
}
/**
* Given the HTML of an answer from a content page, extract the data to render the answer.
*
* @param html Answer's HTML.
* @return Data to render the answer.
*/
getContentPageAnswerDataFromHtml(html: string): {buttonText: string; content: string} {
const data = {
buttonText: '',
content: '',
};
const element = CoreDomUtils.instance.convertToElement(html);
// Search the input button.
const button = <HTMLInputElement> element.querySelector('input[type="button"]');
if (button) {
// Extract the button content and remove it from the HTML.
data.buttonText = button.value;
button.remove();
}
data.content = element.innerHTML.trim();
return data;
}
/**
* Get the buttons to change pages.
*
* @param html Page's HTML.
* @return List of buttons.
*/
getPageButtonsFromHtml(html: string): AddonModLessonPageButton[] {
const buttons: AddonModLessonPageButton[] = [];
const element = CoreDomUtils.instance.convertToElement(html);
// Get the container of the buttons if it exists.
let buttonsContainer = element.querySelector('.branchbuttoncontainer');
if (!buttonsContainer) {
// Button container not found, might be a legacy lesson (from 1.9).
if (!element.querySelector('form input[type="submit"]')) {
// No buttons found.
return buttons;
}
buttonsContainer = element;
}
const forms = Array.from(buttonsContainer.querySelectorAll('form'));
forms.forEach((form) => {
const buttonSelector = 'input[type="submit"], button[type="submit"]';
const buttonEl = <HTMLInputElement | HTMLButtonElement> form.querySelector(buttonSelector);
const inputs = Array.from(form.querySelectorAll('input'));
if (!buttonEl || !inputs || !inputs.length) {
// Button not found or no inputs, ignore it.
return;
}
const button: AddonModLessonPageButton = {
id: buttonEl.id,
title: buttonEl.title || buttonEl.value,
content: buttonEl.tagName == 'INPUT' ? buttonEl.value : buttonEl.innerHTML.trim(),
data: {},
};
inputs.forEach((input) => {
if (input.type != 'submit') {
button.data[input.name] = input.value;
}
});
buttons.push(button);
});
return buttons;
}
/**
* Given a page data, get the page contents.
*
* @param data Page data.
* @return Page contents.
*/
getPageContentsFromPageData(data: AddonModLessonGetPageDataWSResponse): string {
// Search the page contents inside the whole page HTML. Use data.pagecontent because it's filtered.
const element = CoreDomUtils.instance.convertToElement(data.pagecontent || '');
const contents = element.querySelector('.contents');
if (contents) {
return contents.innerHTML.trim();
}
// Cannot find contents element.
if (AddonModLesson.instance.isQuestionPage(data.page?.type || -1) ||
data.page?.qtype == AddonModLessonProvider.LESSON_PAGE_BRANCHTABLE) {
// Return page.contents to prevent having duplicated elements (some elements like videos might not work).
return data.page?.contents || '';
} else {
// It's an end of cluster, end of branch, etc. Return the whole pagecontent to match what's displayed in web.
return data.pagecontent || '';
}
}
/**
* Get a question and all the data required to render it from the page data.
*
* @param questionForm The form group where to add the controls.
* @param pageData Page data.
* @return Question data.
*/
getQuestionFromPageData(questionForm: FormGroup, pageData: AddonModLessonGetPageDataWSResponse): AddonModLessonQuestion {
const element = CoreDomUtils.instance.convertToElement(pageData.pagecontent || '');
// Get the container of the question answers if it exists.
const fieldContainer = <HTMLElement> element.querySelector('.fcontainer');
// Get hidden inputs and add their data to the form group.
const hiddenInputs = <HTMLInputElement[]> Array.from(element.querySelectorAll('input[type="hidden"]'));
hiddenInputs.forEach((input) => {
questionForm.addControl(input.name, this.formBuilder.control(input.value));
});
// Get the submit button and extract its value.
const submitButton = <HTMLInputElement> element.querySelector('input[type="submit"]');
const question: AddonModLessonQuestion = {
template: '',
submitLabel: submitButton ? submitButton.value : Translate.instance.instant('addon.mod_lesson.submit'),
};
if (!fieldContainer) {
// Element not found, return.
return question;
}
let type = 'text';
switch (pageData.page?.qtype) {
case AddonModLessonProvider.LESSON_PAGE_TRUEFALSE:
case AddonModLessonProvider.LESSON_PAGE_MULTICHOICE:
return this.getMultiChoiceQuestionData(questionForm, question, fieldContainer);
case AddonModLessonProvider.LESSON_PAGE_NUMERICAL:
type = 'number';
case AddonModLessonProvider.LESSON_PAGE_SHORTANSWER:
return this.getInputQuestionData(questionForm, question, fieldContainer, type);
case AddonModLessonProvider.LESSON_PAGE_ESSAY: {
return this.getEssayQuestionData(questionForm, question, fieldContainer);
}
case AddonModLessonProvider.LESSON_PAGE_MATCHING: {
return this.getMatchingQuestionData(questionForm, question, fieldContainer);
}
}
return question;
}
/**
* Get a multichoice question data.
*
* @param questionForm The form group where to add the controls.
* @param question Basic question data.
* @param fieldContainer HTMLElement containing the data.
* @return Question data.
*/
protected getMultiChoiceQuestionData(
questionForm: FormGroup,
question: AddonModLessonQuestion,
fieldContainer: HTMLElement,
): AddonModLessonMultichoiceQuestion {
const multiChoiceQuestion = <AddonModLessonMultichoiceQuestion> {
...question,
template: 'multichoice',
options: [],
multi: false,
};
// Get all the inputs. Search radio first.
let inputs = <HTMLInputElement[]> Array.from(fieldContainer.querySelectorAll('input[type="radio"]'));
if (!inputs || !inputs.length) {
// Radio buttons not found, it might be a multi answer. Search for checkbox.
multiChoiceQuestion.multi = true;
inputs = <HTMLInputElement[]> Array.from(fieldContainer.querySelectorAll('input[type="checkbox"]'));
if (!inputs || !inputs.length) {
// No checkbox found either. Stop.
return multiChoiceQuestion;
}
}
let controlAdded = false;
inputs.forEach((input) => {
const parent = input.parentElement;
const option: AddonModLessonMultichoiceOption = {
id: input.id,
name: input.name,
value: input.value,
checked: !!input.checked,
disabled: !!input.disabled,
text: '',
};
if (option.checked || multiChoiceQuestion.multi) {
// Add the control.
const value = multiChoiceQuestion.multi ?
{ value: option.checked, disabled: option.disabled } : option.value;
questionForm.addControl(option.name, this.formBuilder.control(value));
controlAdded = true;
}
// Remove the input and use the rest of the parent contents as the label.
input.remove();
option.text = parent?.innerHTML.trim() || '';
multiChoiceQuestion.options!.push(option);
});
if (!multiChoiceQuestion.multi) {
multiChoiceQuestion.controlName = inputs[0].name;
if (!controlAdded) {
// No checked option for single choice, add the control with an empty value.
questionForm.addControl(multiChoiceQuestion.controlName, this.formBuilder.control(''));
}
}
return multiChoiceQuestion;
}
/**
* Get an input question data.
*
* @param questionForm The form group where to add the controls.
* @param question Basic question data.
* @param fieldContainer HTMLElement containing the data.
* @param type Type of the input.
* @return Question data.
*/
protected getInputQuestionData(
questionForm: FormGroup,
question: AddonModLessonQuestion,
fieldContainer: HTMLElement,
type: string,
): AddonModLessonInputQuestion {
const inputQuestion = <AddonModLessonInputQuestion> question;
inputQuestion.template = 'shortanswer';
// Get the input.
const input = <HTMLInputElement> fieldContainer.querySelector('input[type="text"], input[type="number"]');
if (!input) {
return inputQuestion;
}
inputQuestion.input = {
id: input.id,
name: input.name,
maxlength: input.maxLength,
type,
};
// Init the control.
questionForm.addControl(input.name, this.formBuilder.control({ value: input.value, disabled: input.readOnly }));
return inputQuestion;
}
/**
* Get an essay question data.
*
* @param questionForm The form group where to add the controls.
* @param question Basic question data.
* @param fieldContainer HTMLElement containing the data.
* @return Question data.
*/
protected getEssayQuestionData(
questionForm: FormGroup,
question: AddonModLessonQuestion,
fieldContainer: HTMLElement,
): AddonModLessonEssayQuestion {
const essayQuestion = <AddonModLessonEssayQuestion> question;
essayQuestion.template = 'essay';
// Get the textarea.
const textarea = fieldContainer.querySelector('textarea');
if (!textarea) {
// Textarea not found, probably review mode.
const answerEl = fieldContainer.querySelector('.reviewessay');
if (!answerEl) {
// Answer not found, stop.
return essayQuestion;
}
essayQuestion.useranswer = answerEl.innerHTML;
} else {
essayQuestion.textarea = {
id: textarea.id,
name: textarea.name || 'answer[text]',
};
// Init the control.
essayQuestion.control = this.formBuilder.control('');
questionForm.addControl(essayQuestion.textarea.name, essayQuestion.control);
}
return essayQuestion;
}
/**
* Get a matching question data.
*
* @param questionForm The form group where to add the controls.
* @param question Basic question data.
* @param fieldContainer HTMLElement containing the data.
* @return Question data.
*/
protected getMatchingQuestionData(
questionForm: FormGroup,
question: AddonModLessonQuestion,
fieldContainer: HTMLElement,
): AddonModLessonMatchingQuestion {
const matchingQuestion = <AddonModLessonMatchingQuestion> {
...question,
template: 'matching',
rows: [],
};
const rows = Array.from(fieldContainer.querySelectorAll('.answeroption'));
rows.forEach((row) => {
const label = row.querySelector('label');
const select = row.querySelector('select');
const options = Array.from(row.querySelectorAll('option'));
if (!label || !select || !options || !options.length) {
return;
}
// Get the row's text (label).
const rowData: AddonModLessonMatchingRow = {
text: label.innerHTML.trim(),
id: select.id,
name: select.name,
options: [],
};
// Treat each option.
let controlAdded = false;
options.forEach((option) => {
if (typeof option.value == 'undefined') {
// Option not valid, ignore it.
return;
}
const optionData: AddonModLessonMatchingRowOption = {
value: option.value,
label: option.innerHTML.trim(),
selected: option.selected,
};
if (optionData.selected) {
controlAdded = true;
questionForm.addControl(
rowData.name,
this.formBuilder.control({ value: optionData.value, disabled: !!select.disabled }),
);
}
rowData.options.push(optionData);
});
if (!controlAdded) {
// No selected option, add the control with an empty value.
questionForm.addControl(rowData.name, this.formBuilder.control({ value: '', disabled: !!select.disabled }));
}
matchingQuestion.rows.push(rowData);
});
return matchingQuestion;
}
/**
* Given the HTML of an answer from a question page, extract the data to render the answer.
*
* @param html Answer's HTML.
* @return Object with the data to render the answer. If the answer doesn't require any parsing, return a string with the HTML.
*/
getQuestionPageAnswerDataFromHtml(html: string): AddonModLessonAnswerData {
const element = CoreDomUtils.instance.convertToElement(html);
// Check if it has a checkbox.
let input = <HTMLInputElement> element.querySelector('input[type="checkbox"][name*="answer"]');
if (input) {
// Truefalse or multichoice.
const data: AddonModLessonCheckboxAnswerData = {
isCheckbox: true,
checked: !!input.checked,
name: input.name,
highlight: !!element.querySelector('.highlight'),
content: '',
};
input.remove();
data.content = element.innerHTML.trim();
return data;
}
// Check if it has an input text or number.
input = <HTMLInputElement> element.querySelector('input[type="number"],input[type="text"]');
if (input) {
// Short answer or numeric.
return {
isText: true,
value: input.value,
};
}
// Check if it has a select.
const select = element.querySelector('select');
if (select?.options) {
// Matching.
const selectedOption = select.options[select.selectedIndex];
const data: AddonModLessonSelectAnswerData = {
isSelect: true,
id: select.id,
value: selectedOption ? selectedOption.value : '',
content: '',
};
select.remove();
data.content = element.innerHTML.trim();
return data;
}
// The answer doesn't need any parsing, return the HTML as it is.
return html;
}
/**
* Get a label to identify a retake (lesson attempt).
*
* @param retake Retake object.
* @param includeDuration Whether to include the duration of the retake.
* @return Retake label.
*/
getRetakeLabel(retake: AddonModLessonAttemptsOverviewsAttemptWSData, includeDuration?: boolean): string {
const data = {
retake: retake.try + 1,
grade: '',
timestart: '',
duration: '',
};
const hasGrade = retake.grade != null;
if (hasGrade || retake.end) {
// Retake finished with or without grade (if the lesson only has content pages, it has no grade).
if (hasGrade) {
data.grade = Translate.instance.instant('core.percentagenumber', { $a: retake.grade });
}
data.timestart = CoreTimeUtils.instance.userDate(retake.timestart * 1000);
if (includeDuration) {
data.duration = CoreTimeUtils.instance.formatTime(retake.timeend - retake.timestart);
}
} else {
// The user has not completed the retake.
data.grade = Translate.instance.instant('addon.mod_lesson.notcompleted');
if (retake.timestart) {
data.timestart = CoreTimeUtils.instance.userDate(retake.timestart * 1000);
}
}
return Translate.instance.instant('addon.mod_lesson.retakelabel' + (includeDuration ? 'full' : 'short'), data);
}
/**
* Prepare the question data to be sent to server.
*
* @param question Question to prepare.
* @param data Data to prepare.
* @return Data to send.
*/
prepareQuestionData(question: AddonModLessonQuestion, data: Record<string, unknown>): Record<string, unknown> {
if (question.template == 'essay') {
const textarea = (<AddonModLessonEssayQuestion> question).textarea;
// Add some HTML to the answer if needed.
if (textarea) {
data[textarea.name] = CoreTextUtils.instance.formatHtmlLines(<string> data[textarea.name]);
}
} else if (question.template == 'multichoice' && (<AddonModLessonMultichoiceQuestion> question).multi) {
// Only send the options with value set to true.
for (const name in data) {
if (name.match(/answer\[\d+\]/) && data[name] == false) {
delete data[name];
}
}
}
return data;
}
/**
* Given the feedback of a process page in HTML, remove the question text.
*
* @param html Feedback's HTML.
* @return Feedback without the question text.
*/
removeQuestionFromFeedback(html: string): string {
const element = CoreDomUtils.instance.convertToElement(html);
// Remove the question text.
CoreDomUtils.instance.removeElement(element, '.generalbox:not(.feedback):not(.correctanswer)');
return element.innerHTML.trim();
}
}
export class AddonModLessonHelper extends makeSingleton(AddonModLessonHelperProvider) {}
/**
* Page button data.
*/
export type AddonModLessonPageButton = {
id: string;
title: string;
content: string;
data: Record<string, string>;
};
/**
* Generic question data.
*/
export type AddonModLessonQuestionBasicData = {
template: string; // Name of the template to use.
submitLabel: string; // Text to display in submit.
};
/**
* Multichoice question data.
*/
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.
};
/**
* Short answer or numeric question data.
*/
export type AddonModLessonInputQuestion = AddonModLessonQuestionBasicData & {
input?: AddonModLessonQuestionInput; // Text input for text/number questions.
};
/**
* Essay question data.
*/
export type AddonModLessonEssayQuestion = AddonModLessonQuestionBasicData & {
useranswer?: string; // User answer, for reviewing.
textarea?: AddonModLessonTextareaData; // Data for the textarea.
control?: FormControl; // Form control.
};
/**
* Matching question data.
*/
export type AddonModLessonMatchingQuestion = AddonModLessonQuestionBasicData & {
rows: AddonModLessonMatchingRow[];
};
/**
* Data for each option in a multichoice question.
*/
export type AddonModLessonMultichoiceOption = {
id: string;
name: string;
value: string;
checked: boolean;
disabled: boolean;
text: string;
};
/**
* Input data for text/number questions.
*/
export type AddonModLessonQuestionInput = {
id: string;
name: string;
maxlength: number;
type: string;
};
/**
* Textarea data for essay questions.
*/
export type AddonModLessonTextareaData = {
id: string;
name: string;
};
/**
* Data for each row in a matching question.
*/
export type AddonModLessonMatchingRow = {
id: string;
name: string;
text: string;
options: AddonModLessonMatchingRowOption[];
};
/**
* Data for each option in a row in a matching question.
*/
export type AddonModLessonMatchingRowOption = {
value: string;
label: string;
selected: boolean;
};
/**
* Checkbox answer.
*/
export type AddonModLessonCheckboxAnswerData = {
isCheckbox: true;
checked: boolean;
name: string;
highlight: boolean;
content: string;
};
/**
* Text answer.
*/
export type AddonModLessonTextAnswerData = {
isText: true;
value: string;
};
/**
* Select answer.
*/
export type AddonModLessonSelectAnswerData = {
isSelect: true;
id: string;
value: string;
content: string;
};
/**
* Any possible answer data.
*/
export type AddonModLessonAnswerData =
AddonModLessonCheckboxAnswerData | AddonModLessonTextAnswerData | AddonModLessonSelectAnswerData | string;
/**
* 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;
};

View File

@ -0,0 +1,565 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreSites } from '@services/sites';
import { CoreTextUtils } from '@services/utils/text';
import { CoreTimeUtils } from '@services/utils/time';
import { CoreUtils } from '@services/utils/utils';
import { makeSingleton } from '@singletons';
import {
AddonModLessonPageAttemptDBRecord,
AddonModLessonRetakeDBRecord,
PAGE_ATTEMPTS_TABLE_NAME,
RETAKES_TABLE_NAME,
} from './database/lesson';
import { AddonModLessonPageWSData, AddonModLessonProvider } from './lesson';
/**
* Service to handle offline lesson.
*/
@Injectable({ providedIn: 'root' })
export class AddonModLessonOfflineProvider {
/**
* Delete an offline attempt.
*
* @param lessonId Lesson ID.
* @param retake Lesson retake number.
* @param pageId Page ID.
* @param timemodified The timemodified of the attempt.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async deleteAttempt(lessonId: number, retake: number, pageId: number, timemodified: number, siteId?: string): Promise<void> {
const site = await CoreSites.instance.getSite(siteId);
await site.getDb().deleteRecords(PAGE_ATTEMPTS_TABLE_NAME, <Partial<AddonModLessonPageAttemptDBRecord>> {
lessonid: lessonId,
retake: retake,
pageid: pageId,
timemodified: timemodified,
});
}
/**
* Delete offline lesson retake.
*
* @param lessonId Lesson ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async deleteRetake(lessonId: number, siteId?: string): Promise<void> {
const site = await CoreSites.instance.getSite(siteId);
await site.getDb().deleteRecords(RETAKES_TABLE_NAME, <Partial<AddonModLessonRetakeDBRecord>> { lessonid: lessonId });
}
/**
* Delete offline attempts for a retake and page.
*
* @param lessonId Lesson ID.
* @param retake Lesson retake number.
* @param pageId Page ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async deleteRetakeAttemptsForPage(lessonId: number, retake: number, pageId: number, siteId?: string): Promise<void> {
const site = await CoreSites.instance.getSite(siteId);
await site.getDb().deleteRecords(PAGE_ATTEMPTS_TABLE_NAME, <Partial<AddonModLessonPageAttemptDBRecord>> {
lessonid: lessonId,
retake: retake,
pageid: pageId,
});
}
/**
* Mark a retake as finished.
*
* @param lessonId Lesson ID.
* @param courseId Course ID the lesson belongs to.
* @param retake Retake number.
* @param finished Whether retake is finished.
* @param outOfTime If the user ran out of time.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved in success, rejected otherwise.
*/
async finishRetake(
lessonId: number,
courseId: number,
retake: number,
finished?: boolean,
outOfTime?: boolean,
siteId?: string,
): Promise<void> {
const site = await CoreSites.instance.getSite(siteId);
// Get current stored retake (if any). If not found, it will create a new one.
const entry = await this.getRetakeWithFallback(lessonId, courseId, retake, site.id);
entry.finished = finished ? 1 : 0;
entry.outoftime = outOfTime ? 1 : 0;
entry.timemodified = CoreTimeUtils.instance.timestamp();
await site.getDb().insertRecord(RETAKES_TABLE_NAME, entry);
}
/**
* Get all the offline page attempts in a certain site.
*
* @param siteId Site ID. If not set, use current site.
* @return Promise resolved when the offline attempts are retrieved.
*/
async getAllAttempts(siteId?: string): Promise<AddonModLessonPageAttemptRecord[]> {
const db = await CoreSites.instance.getSiteDb(siteId);
const attempts = await db.getAllRecords<AddonModLessonPageAttemptDBRecord>(PAGE_ATTEMPTS_TABLE_NAME);
return this.parsePageAttempts(attempts);
}
/**
* Get all the lessons that have offline data in a certain site.
*
* @param siteId Site ID. If not set, use current site.
* @return Promise resolved with an object containing the lessons.
*/
async getAllLessonsWithData(siteId?: string): Promise<AddonModLessonLessonStoredData[]> {
const lessons: Record<number, AddonModLessonLessonStoredData> = {};
const [pageAttempts, retakes] = await Promise.all([
CoreUtils.instance.ignoreErrors(this.getAllAttempts(siteId)),
CoreUtils.instance.ignoreErrors(this.getAllRetakes(siteId)),
]);
this.getLessonsFromEntries(lessons, pageAttempts || []);
this.getLessonsFromEntries(lessons, retakes || []);
return CoreUtils.instance.objectToArray(lessons);
}
/**
* Get all the offline retakes in a certain site.
*
* @param siteId Site ID. If not set, use current site.
* @return Promise resolved when the offline retakes are retrieved.
*/
async getAllRetakes(siteId?: string): Promise<AddonModLessonRetakeDBRecord[]> {
const db = await CoreSites.instance.getSiteDb(siteId);
return db.getAllRecords(RETAKES_TABLE_NAME);
}
/**
* Retrieve the last offline attempt stored in a retake.
*
* @param lessonId Lesson ID.
* @param retake Retake number.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the attempt (undefined if no attempts).
*/
async getLastQuestionPageAttempt(
lessonId: number,
retake: number,
siteId?: string,
): Promise<AddonModLessonPageAttemptRecord | undefined> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
try {
const retakeData = await this.getRetakeWithFallback(lessonId, 0, retake, siteId);
if (!retakeData.lastquestionpage) {
// No question page attempted.
return;
}
const attempts = await this.getRetakeAttemptsForPage(lessonId, retake, retakeData.lastquestionpage, siteId);
// Return the attempt with highest timemodified.
return attempts.reduce((a, b) => a.timemodified > b.timemodified ? a : b);
} catch {
// Error, return undefined.
}
}
/**
* Retrieve all offline attempts for a lesson.
*
* @param lessonId Lesson ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the attempts.
*/
async getLessonAttempts(lessonId: number, siteId?: string): Promise<AddonModLessonPageAttemptRecord[]> {
const site = await CoreSites.instance.getSite(siteId);
const attempts = await site.getDb().getRecords<AddonModLessonPageAttemptDBRecord>(
PAGE_ATTEMPTS_TABLE_NAME,
{ lessonid: lessonId },
);
return this.parsePageAttempts(attempts);
}
/**
* Given a list of DB entries (either retakes or page attempts), get the list of lessons.
*
* @param lessons Object where to store the lessons.
* @param entries List of DB entries.
*/
protected getLessonsFromEntries(
lessons: Record<number, AddonModLessonLessonStoredData>,
entries: (AddonModLessonPageAttemptRecord | AddonModLessonRetakeDBRecord)[],
): void {
entries.forEach((entry) => {
if (!lessons[entry.lessonid]) {
lessons[entry.lessonid] = {
id: entry.lessonid,
courseId: entry.courseid,
};
}
});
}
/**
* Get attempts for question pages and retake in a lesson.
*
* @param lessonId Lesson ID.
* @param retake Retake number.
* @param correct True to only fetch correct attempts, false to get them all.
* @param pageId If defined, only get attempts on this page.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the attempts.
*/
async getQuestionsAttempts(
lessonId: number,
retake: number,
correct?: boolean,
pageId?: number,
siteId?: string,
): Promise<AddonModLessonPageAttemptRecord[]> {
const attempts = pageId ?
await this.getRetakeAttemptsForPage(lessonId, retake, pageId, siteId) :
await this.getRetakeAttemptsForType(lessonId, retake, AddonModLessonProvider.TYPE_QUESTION, siteId);
if (correct) {
return attempts.filter((attempt) => !!attempt.correct);
}
return attempts;
}
/**
* Retrieve a retake from site DB.
*
* @param lessonId Lesson ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the retake.
*/
async getRetake(lessonId: number, siteId?: string): Promise<AddonModLessonRetakeDBRecord> {
const site = await CoreSites.instance.getSite(siteId);
return site.getDb().getRecord(RETAKES_TABLE_NAME, { lessonid: lessonId });
}
/**
* Retrieve all offline attempts for a retake.
*
* @param lessonId Lesson ID.
* @param retake Retake number.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the retake attempts.
*/
async getRetakeAttempts(lessonId: number, retake: number, siteId?: string): Promise<AddonModLessonPageAttemptRecord[]> {
const site = await CoreSites.instance.getSite(siteId);
const attempts = await site.getDb().getRecords<AddonModLessonPageAttemptDBRecord>(
PAGE_ATTEMPTS_TABLE_NAME,
<Partial<AddonModLessonPageAttemptDBRecord>> {
lessonid: lessonId,
retake,
},
);
return this.parsePageAttempts(attempts);
}
/**
* Retrieve offline attempts for a retake and page.
*
* @param lessonId Lesson ID.
* @param retake Lesson retake number.
* @param pageId Page ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the retake attempts.
*/
async getRetakeAttemptsForPage(
lessonId: number,
retake: number,
pageId: number,
siteId?: string,
): Promise<AddonModLessonPageAttemptRecord[]> {
const site = await CoreSites.instance.getSite(siteId);
const attempts = await site.getDb().getRecords<AddonModLessonPageAttemptDBRecord>(
PAGE_ATTEMPTS_TABLE_NAME,
<Partial<AddonModLessonPageAttemptDBRecord>> {
lessonid: lessonId,
retake,
pageid: pageId,
},
);
return this.parsePageAttempts(attempts);
}
/**
* Retrieve offline attempts for certain pages for a retake.
*
* @param lessonId Lesson ID.
* @param retake Retake number.
* @param type Type of the pages to get: TYPE_QUESTION or TYPE_STRUCTURE.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the retake attempts.
*/
async getRetakeAttemptsForType(
lessonId: number,
retake: number,
type: number,
siteId?: string,
): Promise<AddonModLessonPageAttemptRecord[]> {
const site = await CoreSites.instance.getSite(siteId);
const attempts = await site.getDb().getRecords<AddonModLessonPageAttemptDBRecord>(
PAGE_ATTEMPTS_TABLE_NAME,
<Partial<AddonModLessonPageAttemptDBRecord>> {
lessonid: lessonId,
retake,
type,
},
);
return this.parsePageAttempts(attempts);
}
/**
* Get stored retake. If not found or doesn't match the retake number, return a new one.
*
* @param lessonId Lesson ID.
* @param courseId Course ID the lesson belongs to.
* @param retake Retake number.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the retake.
*/
protected async getRetakeWithFallback(
lessonId: number,
courseId: number,
retake: number,
siteId?: string,
): Promise<AddonModLessonRetakeDBRecord> {
try {
// Get current stored retake.
const retakeData = await this.getRetake(lessonId, siteId);
if (retakeData.retake == retake) {
return retakeData;
}
} catch {
// No retake, create a new one.
}
// Create a new retake.
return {
lessonid: lessonId,
retake,
courseid: courseId,
finished: 0,
};
}
/**
* Check if there is a finished retake for a certain lesson.
*
* @param lessonId Lesson ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with boolean.
*/
async hasFinishedRetake(lessonId: number, siteId?: string): Promise<boolean> {
try {
const retake = await this.getRetake(lessonId, siteId);
return !!retake.finished;
} catch {
return false;
}
}
/**
* Check if a lesson has offline data.
*
* @param lessonId Lesson ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with boolean.
*/
async hasOfflineData(lessonId: number, siteId?: string): Promise<boolean> {
const [retake, attempts] = await Promise.all([
CoreUtils.instance.ignoreErrors(this.getRetake(lessonId, siteId)),
CoreUtils.instance.ignoreErrors(this.getLessonAttempts(lessonId, siteId)),
]);
return !!retake || !!attempts?.length;
}
/**
* Check if there are offline attempts for a retake.
*
* @param lessonId Lesson ID.
* @param retake Retake number.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with a boolean.
*/
async hasRetakeAttempts(lessonId: number, retake: number, siteId?: string): Promise<boolean> {
try {
const list = await this.getRetakeAttempts(lessonId, retake, siteId);
return !!list.length;
} catch {
return false;
}
}
/**
* Parse some properties of a page attempt.
*
* @param attempt The attempt to treat.
* @return The treated attempt.
*/
protected parsePageAttempt(attempt: AddonModLessonPageAttemptDBRecord): AddonModLessonPageAttemptRecord {
return {
...attempt,
data: attempt.data ? CoreTextUtils.instance.parseJSON(attempt.data) : null,
useranswer: attempt.useranswer ? CoreTextUtils.instance.parseJSON(attempt.useranswer) : null,
};
}
/**
* Parse some properties of some page attempts.
*
* @param attempts The attempts to treat.
* @return The treated attempts.
*/
protected parsePageAttempts(attempts: AddonModLessonPageAttemptDBRecord[]): AddonModLessonPageAttemptRecord[] {
return attempts.map((attempt) => this.parsePageAttempt(attempt));
}
/**
* Process a lesson page, saving its data.
*
* @param lessonId Lesson ID.
* @param courseId Course ID the lesson belongs to.
* @param retake Retake number.
* @param page Page.
* @param data Data to save.
* @param newPageId New page ID (calculated).
* @param answerId The answer ID that the user answered.
* @param correct If answer is correct. Only for question pages.
* @param userAnswer The user's answer (userresponse from checkAnswer).
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved in success, rejected otherwise.
*/
async processPage(
lessonId: number,
courseId: number,
retake: number,
page: AddonModLessonPageWSData,
data: Record<string, unknown>,
newPageId: number,
answerId?: number,
correct?: boolean,
userAnswer?: unknown,
siteId?: string,
): Promise<void> {
const site = await CoreSites.instance.getSite(siteId);
const entry: AddonModLessonPageAttemptDBRecord = {
lessonid: lessonId,
retake: retake,
pageid: page.id,
timemodified: CoreTimeUtils.instance.timestamp(),
courseid: courseId,
data: data ? JSON.stringify(data) : null,
type: page.type,
newpageid: newPageId,
correct: correct ? 1 : 0,
answerid: answerId || null,
useranswer: userAnswer ? JSON.stringify(userAnswer) : null,
};
await site.getDb().insertRecord(PAGE_ATTEMPTS_TABLE_NAME, entry);
if (page.type == AddonModLessonProvider.TYPE_QUESTION) {
// It's a question page, set it as last question page attempted.
await this.setLastQuestionPageAttempted(lessonId, courseId, retake, page.id, siteId);
}
}
/**
* Set the last question page attempted in a retake.
*
* @param lessonId Lesson ID.
* @param courseId Course ID the lesson belongs to.
* @param retake Retake number.
* @param lastPage ID of the last question page attempted.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved in success, rejected otherwise.
*/
async setLastQuestionPageAttempted(
lessonId: number,
courseId: number,
retake: number,
lastPage: number,
siteId?: string,
): Promise<void> {
const site = await CoreSites.instance.getSite(siteId);
// Get current stored retake (if any). If not found, it will create a new one.
const entry = await this.getRetakeWithFallback(lessonId, courseId, retake, site.id);
entry.lastquestionpage = lastPage;
entry.timemodified = CoreTimeUtils.instance.timestamp();
await site.getDb().insertRecord(RETAKES_TABLE_NAME, entry);
}
}
export class AddonModLessonOffline extends makeSingleton(AddonModLessonOfflineProvider) {}
/**
* Attempt DB record with parsed data.
*/
export type AddonModLessonPageAttemptRecord = Omit<AddonModLessonPageAttemptDBRecord, 'data'|'useranswer'> & {
data: Record<string, unknown> | null;
useranswer: unknown | null;
};
/**
* Lesson data stored in DB.
*/
export type AddonModLessonLessonStoredData = {
id: number;
courseId: number;
};

View File

@ -0,0 +1,518 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreError } from '@classes/errors/error';
import { CoreNetworkError } from '@classes/errors/network-error';
import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync';
import { CoreCourse } from '@features/course/services/course';
import { CoreCourseLogHelper } from '@features/course/services/log-helper';
import { CoreApp } from '@services/app';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreSync } from '@services/sync';
import { CoreTextUtils } from '@services/utils/text';
import { CoreTimeUtils } from '@services/utils/time';
import { CoreUrlUtils } from '@services/utils/url';
import { CoreUtils } from '@services/utils/utils';
import { makeSingleton, Translate } from '@singletons';
import { CoreEvents, CoreEventSiteData } from '@singletons/events';
import { AddonModLessonRetakeFinishedInSyncDBRecord, RETAKES_FINISHED_SYNC_TABLE_NAME } from './database/lesson';
import { AddonModLessonGetPasswordResult, AddonModLessonPrefetchHandler } from './handlers/prefetch';
import { AddonModLesson, AddonModLessonLessonWSData, AddonModLessonProvider } from './lesson';
import { AddonModLessonOffline, AddonModLessonPageAttemptRecord } from './lesson-offline';
/**
* Service to sync lesson.
*/
@Injectable({ providedIn: 'root' })
export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvider<AddonModLessonSyncResult> {
static readonly AUTO_SYNCED = 'addon_mod_lesson_autom_synced';
protected componentTranslate?: string;
constructor() {
super('AddonModLessonSyncProvider');
}
/**
* Unmark a retake as finished in a synchronization.
*
* @param lessonId Lesson ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async deleteRetakeFinishedInSync(lessonId: number, siteId?: string): Promise<void> {
const site = await CoreSites.instance.getSite(siteId);
// Ignore errors, maybe there is none.
await CoreUtils.instance.ignoreErrors(site.getDb().deleteRecords(RETAKES_FINISHED_SYNC_TABLE_NAME, { lessonid: lessonId }));
}
/**
* Get a retake finished in a synchronization for a certain lesson (if any).
*
* @param lessonId Lesson ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the retake entry (undefined if no retake).
*/
async getRetakeFinishedInSync(
lessonId: number,
siteId?: string,
): Promise<AddonModLessonRetakeFinishedInSyncDBRecord | undefined> {
const site = await CoreSites.instance.getSite(siteId);
return CoreUtils.instance.ignoreErrors(site.getDb().getRecord(RETAKES_FINISHED_SYNC_TABLE_NAME, { lessonid: lessonId }));
}
/**
* Check if a lesson has data to synchronize.
*
* @param lessonId Lesson ID.
* @param retake Retake number.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with boolean: whether it has data to sync.
*/
async hasDataToSync(lessonId: number, retake: number, siteId?: string): Promise<boolean> {
const [hasAttempts, hasFinished] = await Promise.all([
CoreUtils.instance.ignoreErrors(AddonModLessonOffline.instance.hasRetakeAttempts(lessonId, retake, siteId)),
CoreUtils.instance.ignoreErrors(AddonModLessonOffline.instance.hasFinishedRetake(lessonId, siteId)),
]);
return !!(hasAttempts || hasFinished);
}
/**
* Mark a retake as finished in a synchronization.
*
* @param lessonId Lesson ID.
* @param retake The retake number.
* @param pageId The page ID to start reviewing from.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async setRetakeFinishedInSync(lessonId: number, retake: number, pageId: number, siteId?: string): Promise<void> {
const site = await CoreSites.instance.getSite(siteId);
await site.getDb().insertRecord(RETAKES_FINISHED_SYNC_TABLE_NAME, <AddonModLessonRetakeFinishedInSyncDBRecord> {
lessonid: lessonId,
retake: Number(retake),
pageid: Number(pageId),
timefinished: CoreTimeUtils.instance.timestamp(),
});
}
/**
* Try to synchronize all the lessons in a certain site or in all sites.
*
* @param siteId Site ID to sync. If not defined, sync all sites.
* @param force Wether to force sync not depending on last execution.
* @return Promise resolved if sync is successful, rejected if sync fails.
*/
syncAllLessons(siteId?: string, force?: boolean): Promise<void> {
return this.syncOnSites('all lessons', this.syncAllLessonsFunc.bind(this, !!force), siteId);
}
/**
* Sync all lessons on a site.
*
* @param force Wether to force sync not depending on last execution.
* @param siteId Site ID to sync.
* @param Promise resolved if sync is successful, rejected if sync fails.
*/
protected async syncAllLessonsFunc(force: boolean, siteId: string): Promise<void> {
// Get all the lessons that have something to be synchronized.
const lessons = await AddonModLessonOffline.instance.getAllLessonsWithData(siteId);
// Sync all lessons that need it.
await Promise.all(lessons.map(async (lesson) => {
const result = force ?
await this.syncLesson(lesson.id, false, false, siteId) :
await this.syncLessonIfNeeded(lesson.id, false, siteId);
if (result?.updated) {
// Sync successful, send event.
CoreEvents.trigger<AddonModLessonAutoSyncData>(AddonModLessonSyncProvider.AUTO_SYNCED, {
lessonId: lesson.id,
warnings: result.warnings,
}, siteId);
}
}));
}
/**
* Sync a lesson only if a certain time has passed since the last time.
*
* @param lessonId Lesson ID.
* @param askPreflight Whether we should ask for password if needed.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when the lesson is synced or if it doesn't need to be synced.
*/
async syncLessonIfNeeded(
lessonId: number,
askPassword?: boolean,
siteId?: string,
): Promise<AddonModLessonSyncResult | undefined> {
const needed = await this.isSyncNeeded(lessonId, siteId);
if (needed) {
return this.syncLesson(lessonId, askPassword, false, siteId);
}
}
/**
* Try to synchronize a lesson.
*
* @param lessonId Lesson ID.
* @param askPassword True if we should ask for password if needed, false otherwise.
* @param ignoreBlock True to ignore the sync block setting.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved in success.
*/
async syncLesson(
lessonId: number,
askPassword?: boolean,
ignoreBlock?: boolean,
siteId?: string,
): Promise<AddonModLessonSyncResult> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
this.componentTranslate = this.componentTranslate || CoreCourse.instance.translateModuleName('lesson');
let syncPromise = this.getOngoingSync(lessonId, siteId);
if (syncPromise) {
// There's already a sync ongoing for this lesson, return the promise.
return syncPromise;
}
// Verify that lesson isn't blocked.
if (!ignoreBlock && CoreSync.instance.isBlocked(AddonModLessonProvider.COMPONENT, lessonId, siteId)) {
this.logger.debug('Cannot sync lesson ' + lessonId + ' because it is blocked.');
throw new CoreError(Translate.instance.instant('core.errorsyncblocked', { $a: this.componentTranslate }));
}
this.logger.debug('Try to sync lesson ' + lessonId + ' in site ' + siteId);
syncPromise = this.performSyncLesson(lessonId, askPassword, ignoreBlock, siteId);
return this.addOngoingSync(lessonId, syncPromise, siteId);
}
/**
* Try to synchronize a lesson.
*
* @param lessonId Lesson ID.
* @param askPassword True if we should ask for password if needed, false otherwise.
* @param ignoreBlock True to ignore the sync block setting.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved in success.
*/
protected async performSyncLesson(
lessonId: number,
askPassword?: boolean,
ignoreBlock?: boolean,
siteId?: string,
): Promise<AddonModLessonSyncResult> {
// Sync offline logs.
await CoreUtils.instance.ignoreErrors(
CoreCourseLogHelper.instance.syncActivity(AddonModLessonProvider.COMPONENT, lessonId, siteId),
);
const result: AddonModLessonSyncResult = {
warnings: [],
updated: false,
};
// Try to synchronize the page attempts first.
const passwordData = await this.syncAttempts(lessonId, result, askPassword, siteId);
// Now sync the retake.
await this.syncRetake(lessonId, result, passwordData, askPassword, ignoreBlock, siteId);
if (result.updated && result.courseId) {
try {
// Data has been sent to server, update data.
const module = await CoreCourse.instance.getModuleBasicInfoByInstance(lessonId, 'lesson', siteId);
await this.prefetchAfterUpdate(AddonModLessonPrefetchHandler.instance, module, result.courseId, undefined, siteId);
} catch {
// Ignore errors.
}
}
// Sync finished, set sync time.
await CoreUtils.instance.ignoreErrors(this.setSyncTime(String(lessonId), siteId));
// All done, return the result.
return result;
}
/**
* Sync all page attempts.
*
* @param lessonId Lesson ID.
* @param result Sync result where to store the result.
* @param askPassword True if we should ask for password if needed, false otherwise.
* @param siteId Site ID. If not defined, current site.
*/
protected async syncAttempts(
lessonId: number,
result: AddonModLessonSyncResult,
askPassword?: boolean,
siteId?: string,
): Promise<AddonModLessonGetPasswordResult | undefined> {
let attempts = await AddonModLessonOffline.instance.getLessonAttempts(lessonId, siteId);
if (!attempts.length) {
return;
} else if (!CoreApp.instance.isOnline()) {
// Cannot sync in offline.
throw new CoreNetworkError();
}
result.courseId = attempts[0].courseid;
const attemptsLength = attempts.length;
// Get the info, access info and the lesson password if needed.
const lesson = await AddonModLesson.instance.getLessonById(result.courseId, lessonId, { siteId });
const passwordData = await AddonModLessonPrefetchHandler.instance.getLessonPassword(lessonId, {
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
askPassword,
siteId,
});
const promises: Promise<void>[] = [];
passwordData.lesson = passwordData.lesson || lesson;
// Filter the attempts, get only the ones that belong to the current retake.
attempts = attempts.filter((attempt) => {
if (attempt.retake == passwordData.accessInfo.attemptscount) {
return true;
}
// Attempt doesn't belong to current retake, delete.
promises.push(CoreUtils.instance.ignoreErrors(AddonModLessonOffline.instance.deleteAttempt(
lesson.id,
attempt.retake,
attempt.pageid,
attempt.timemodified,
siteId,
)));
return false;
});
if (attempts.length != attemptsLength) {
// Some attempts won't be sent, add a warning.
result.warnings.push(Translate.instance.instant('core.warningofflinedatadeleted', {
component: this.componentTranslate,
name: lesson.name,
error: Translate.instance.instant('addon.mod_lesson.warningretakefinished'),
}));
}
await Promise.all(promises);
if (!attempts.length) {
return passwordData;
}
// Send the attempts in the same order they were answered.
attempts.sort((a, b) => a.timemodified - b.timemodified);
const promisesData = attempts.map((attempt) => ({
function: this.sendAttempt.bind(this, lesson, passwordData.password, attempt, result, siteId),
blocking: true,
}));
await CoreUtils.instance.executeOrderedPromises(promisesData);
return passwordData;
}
/**
* Send an attempt to the site and delete it afterwards.
*
* @param lesson Lesson.
* @param password Password (if any).
* @param attempt Attempt to send.
* @param result Result where to store the data.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
protected async sendAttempt(
lesson: AddonModLessonLessonWSData,
password: string,
attempt: AddonModLessonPageAttemptRecord,
result: AddonModLessonSyncResult,
siteId?: string,
): Promise<void> {
const retake = attempt.retake;
const pageId = attempt.pageid;
const timemodified = attempt.timemodified;
try {
// Send the page data.
await AddonModLesson.instance.processPageOnline(lesson.id, attempt.pageid, attempt.data || {}, {
password,
siteId,
});
result.updated = true;
await AddonModLessonOffline.instance.deleteAttempt(lesson.id, retake, pageId, timemodified, siteId);
} catch (error) {
if (!error || !CoreUtils.instance.isWebServiceError(error)) {
// Couldn't connect to server.
throw error;
}
// The WebService has thrown an error, this means that the attempt cannot be submitted. Delete it.
result.updated = true;
await AddonModLessonOffline.instance.deleteAttempt(lesson.id, retake, pageId, timemodified, siteId);
// Attempt deleted, add a warning.
result.warnings.push(Translate.instance.instant('core.warningofflinedatadeleted', {
component: this.componentTranslate,
name: lesson.name,
error: CoreTextUtils.instance.getErrorMessageFromError(error),
}));
}
}
/**
* Sync retake.
*
* @param lessonId Lesson ID.
* @param result Sync result where to store the result.
* @param passwordData Password data. If not provided it will be calculated.
* @param askPassword True if we should ask for password if needed, false otherwise.
* @param ignoreBlock True to ignore the sync block setting.
* @param siteId Site ID. If not defined, current site.
*/
protected async syncRetake(
lessonId: number,
result: AddonModLessonSyncResult,
passwordData?: AddonModLessonGetPasswordResult,
askPassword?: boolean,
ignoreBlock?: boolean,
siteId?: string,
): Promise<void> {
// Attempts sent or there was none. If there is a finished retake, send it.
const retake = await CoreUtils.instance.ignoreErrors(AddonModLessonOffline.instance.getRetake(lessonId, siteId));
if (!retake) {
// No retake to sync.
return;
}
if (!retake.finished) {
// The retake isn't marked as finished, nothing to send. Delete the retake.
await AddonModLessonOffline.instance.deleteRetake(lessonId, siteId);
return;
} else if (!CoreApp.instance.isOnline()) {
// Cannot sync in offline.
throw new CoreNetworkError();
}
result.courseId = retake.courseid || result.courseId;
if (!passwordData?.lesson) {
// Retrieve the needed data.
const lesson = await AddonModLesson.instance.getLessonById(result.courseId!, lessonId, { siteId });
passwordData = await AddonModLessonPrefetchHandler.instance.getLessonPassword(lessonId, {
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
askPassword,
siteId,
});
passwordData.lesson = passwordData.lesson || lesson;
}
if (retake.retake != passwordData.accessInfo.attemptscount) {
// The retake changed, add a warning if it isn't there already.
if (!result.warnings.length) {
result.warnings.push(Translate.instance.instant('core.warningofflinedatadeleted', {
component: this.componentTranslate,
name: passwordData.lesson.name,
error: Translate.instance.instant('addon.mod_lesson.warningretakefinished'),
}));
}
await AddonModLessonOffline.instance.deleteRetake(lessonId, siteId);
}
try {
// All good, finish the retake.
const response = await AddonModLesson.instance.finishRetakeOnline(lessonId, {
password: passwordData.password,
siteId,
});
result.updated = true;
// Mark the retake as finished in a sync if it can be reviewed.
if (!ignoreBlock && response.data?.reviewlesson) {
const params = CoreUrlUtils.instance.extractUrlParams(<string> response.data.reviewlesson.value);
if (params.pageid) {
// The retake can be reviewed, mark it as finished. Don't block the user for this.
this.setRetakeFinishedInSync(lessonId, retake.retake, Number(params.pageid), siteId);
}
}
await AddonModLessonOffline.instance.deleteRetake(lessonId, siteId);
} catch (error) {
if (!error || !CoreUtils.instance.isWebServiceError(error)) {
// Couldn't connect to server.
throw error;
}
// The WebService has thrown an error, this means that responses cannot be submitted. Delete them.
result.updated = true;
await AddonModLessonOffline.instance.deleteRetake(lessonId, siteId);
// Retake deleted, add a warning.
result.warnings.push(Translate.instance.instant('core.warningofflinedatadeleted', {
component: this.componentTranslate,
name: passwordData.lesson.name,
error: CoreTextUtils.instance.getErrorMessageFromError(error),
}));
}
}
}
export class AddonModLessonSync extends makeSingleton(AddonModLessonSyncProvider) {}
/**
* Data returned by a lesson sync.
*/
export type AddonModLessonSyncResult = {
warnings: string[]; // List of warnings.
updated: boolean; // Whether some data was sent to the server or offline data was updated.
courseId?: number; // Course the lesson belongs to (if known).
};
/**
* Data passed to AUTO_SYNCED event.
*/
export type AddonModLessonAutoSyncData = CoreEventSiteData & {
lessonId: number;
warnings: string[];
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { AddonModLessonModule } from './lesson/lesson.module';
@NgModule({
declarations: [],
imports: [
AddonModLessonModule,
],
providers: [],
exports: [],
})
export class AddonModModule { }

View File

@ -0,0 +1,628 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {
Component,
Input,
Output,
EventEmitter,
OnInit,
OnChanges,
OnDestroy,
AfterViewInit,
ViewChild,
ElementRef,
} from '@angular/core';
import { IonSlides } from '@ionic/angular';
import { Subscription } from 'rxjs';
import { CoreApp } from '@services/app';
import { CoreConfig } from '@services/config';
import { CoreConstants } from '@/core/constants';
import { Platform, Translate } from '@singletons';
/**
* Class to abstract some common code for tabs.
*/
@Component({
template: '',
})
export class CoreTabsBaseComponent<T extends CoreTabBase> implements OnInit, AfterViewInit, OnChanges, OnDestroy {
// Minimum tab's width.
protected static readonly MIN_TAB_WIDTH = 107;
// Max height that allows tab hiding.
protected static readonly MAX_HEIGHT_TO_HIDE_TABS = 768;
@Input() protected selectedIndex = 0; // Index of the tab to select.
@Input() hideUntil = false; // Determine when should the contents be shown.
@Output() protected ionChange = new EventEmitter<T>(); // Emitted when the tab changes.
@ViewChild(IonSlides) protected slides?: IonSlides;
tabs: T[] = []; // List of tabs.
selected?: string; // Selected tab id.
showPrevButton = false;
showNextButton = false;
maxSlides = 3;
numTabsShown = 0;
direction = 'ltr';
description = '';
lastScroll = 0;
slidesOpts = {
initialSlide: 0,
slidesPerView: 3,
centerInsufficientSlides: true,
};
protected initialized = false;
protected afterViewInitTriggered = false;
protected tabBarHeight = 0;
protected tabsElement?: HTMLElement; // The tabs parent element. It's the element that will be "scrolled" to hide tabs.
protected tabBarElement?: HTMLIonTabBarElement; // The top tab bar element.
protected tabsShown = true;
protected resizeFunction?: EventListenerOrEventListenerObject;
protected isDestroyed = false;
protected isCurrentView = true;
protected shouldSlideToInitial = false; // Whether we need to slide to the initial slide because it's out of view.
protected hasSliddenToInitial = false; // Whether we've already slidden to the initial slide or there was no need.
protected selectHistory: string[] = [];
protected firstSelectedTab?: string; // ID of the first selected tab to control history.
protected unregisterBackButtonAction: any;
protected languageChangedSubscription?: Subscription;
protected isInTransition = false; // Weather Slides is in transition.
protected slidesSwiper: any; // eslint-disable-line @typescript-eslint/no-explicit-any
protected slidesSwiperLoaded = false;
protected scrollListenersSet: Record<string | number, boolean> = {}; // Prevent setting listeners twice.
constructor(
protected element: ElementRef,
) {
}
/**
* Component being initialized.
*/
ngOnInit(): void {
this.direction = Platform.instance.isRTL ? 'rtl' : 'ltr';
// Change the side when the language changes.
this.languageChangedSubscription = Translate.instance.onLangChange.subscribe(() => {
setTimeout(() => {
this.direction = Platform.instance.isRTL ? 'rtl' : 'ltr';
});
});
}
/**
* View has been initialized.
*/
async ngAfterViewInit(): Promise<void> {
if (this.isDestroyed) {
return;
}
this.afterViewInitTriggered = true;
this.tabBarElement = this.element.nativeElement.querySelector('ion-tab-bar');
if (!this.initialized && this.hideUntil) {
// Tabs should be shown, initialize them.
await this.initializeTabs();
}
this.resizeFunction = this.windowResized.bind(this);
window.addEventListener('resize', this.resizeFunction!);
}
/**
* Calculate the tab bar height.
*/
protected calculateTabBarHeight(): void {
if (!this.tabBarElement) {
return;
}
this.tabBarHeight = this.tabBarElement.offsetHeight;
if (this.tabsShown) {
// Smooth translation.
this.tabBarElement.style.top = - this.lastScroll + 'px';
this.tabBarElement.style.height = 'calc(100% + ' + scroll + 'px';
} else {
this.tabBarElement.classList.add('tabs-hidden');
this.tabBarElement.style.top = '0';
this.tabBarElement.style.height = '';
}
}
/**
* Detect changes on input properties.
*/
ngOnChanges(): void {
// Wait for ngAfterViewInit so it works in the case that each tab has its own component.
if (!this.initialized && this.hideUntil && this.afterViewInitTriggered) {
// Tabs should be shown, initialize them.
// Use a setTimeout so child components update their inputs before initializing the tabs.
setTimeout(() => {
this.initializeTabs();
});
}
}
/**
* User entered the page that contains the component.
*/
ionViewDidEnter(): void {
this.isCurrentView = true;
this.calculateSlides();
this.registerBackButtonAction();
}
/**
* Register back button action.
*/
protected registerBackButtonAction(): void {
this.unregisterBackButtonAction = CoreApp.instance.registerBackButtonAction(() => {
// The previous page in history is not the last one, we need the previous one.
if (this.selectHistory.length > 1) {
const tabIndex = this.selectHistory[this.selectHistory.length - 2];
// Remove curent and previous tabs from history.
this.selectHistory = this.selectHistory.filter((tabId) => this.selected != tabId && tabIndex != tabId);
this.selectTab(tabIndex);
return true;
} else if (this.selected != this.firstSelectedTab) {
// All history is gone but we are not in the first selected tab.
this.selectHistory = [];
this.selectTab(this.firstSelectedTab!);
return true;
}
return false;
}, 750);
}
/**
* User left the page that contains the component.
*/
ionViewDidLeave(): void {
// Unregister the custom back button action for this page
this.unregisterBackButtonAction && this.unregisterBackButtonAction();
this.isCurrentView = false;
}
/**
* Calculate slides.
*/
protected async calculateSlides(): Promise<void> {
if (!this.isCurrentView || !this.initialized) {
// Don't calculate if component isn't in current view, the calculations are wrong.
return;
}
if (!this.tabsShown) {
if (window.innerHeight >= CoreTabsBaseComponent.MAX_HEIGHT_TO_HIDE_TABS) {
// Ensure tabbar is shown.
this.tabsShown = true;
this.tabBarElement?.classList.remove('tabs-hidden');
this.lastScroll = 0;
this.calculateTabBarHeight();
} else {
// Don't recalculate.
return;
}
}
await this.calculateMaxSlides();
this.updateSlides();
}
/**
* Get the tab on a index.
*
* @param tabId Tab ID.
* @return Selected tab.
*/
protected getTabIndex(tabId: string): number {
return this.tabs.findIndex((tab) => tabId == tab.id);
}
/**
* Get the current selected tab.
*
* @return Selected tab.
*/
getSelected(): T | undefined {
const index = this.selected && this.getTabIndex(this.selected);
return index !== undefined && index >= 0 ? this.tabs[index] : undefined;
}
/**
* Initialize the tabs, determining the first tab to be shown.
*/
protected async initializeTabs(): Promise<void> {
// 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) {
// The tab is not enabled or not shown. Get the first tab that is enabled.
selectedTab = this.tabs.find((tab) => tab.enabled) || undefined;
}
if (!selectedTab) {
return;
}
this.firstSelectedTab = selectedTab.id!;
this.selectTab(this.firstSelectedTab);
// Setup tab scrolling.
this.calculateTabBarHeight();
this.initialized = true;
// Check which arrows should be shown.
this.calculateSlides();
}
/**
* Method executed when the slides are changed.
*/
async slideChanged(): Promise<void> {
if (!this.slidesSwiperLoaded) {
return;
}
this.isInTransition = false;
const slidesCount = await this.slides?.length() || 0;
if (slidesCount > 0) {
this.showPrevButton = !await this.slides?.isBeginning();
this.showNextButton = !await this.slides?.isEnd();
} else {
this.showPrevButton = false;
this.showNextButton = false;
}
const currentIndex = await this.slides?.getActiveIndex();
if (this.shouldSlideToInitial && currentIndex != this.selectedIndex) {
// Current tab has changed, don't slide to initial anymore.
this.shouldSlideToInitial = false;
}
}
/**
* Updates the number of slides to show.
*/
protected async updateSlides(): Promise<void> {
this.numTabsShown = this.tabs.reduce((prev: number, current) => current.enabled ? prev + 1 : prev, 0);
this.slidesOpts = { ...this.slidesOpts, slidesPerView: Math.min(this.maxSlides, this.numTabsShown) };
this.slideChanged();
this.calculateTabBarHeight();
// @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) {
this.hasSliddenToInitial = true;
this.shouldSlideToInitial = true;
setTimeout(() => {
if (this.shouldSlideToInitial) {
this.slides!.slideTo(this.selectedIndex, 0);
this.shouldSlideToInitial = false;
}
}, 400);
return;
} else if (this.selectedIndex) {
this.hasSliddenToInitial = true;
}
setTimeout(() => {
this.slideChanged(); // Call slide changed again, sometimes the slide active index takes a while to be updated.
}, 400);
}
/**
* Calculate the number of slides that can fit on the screen.
*/
protected async calculateMaxSlides(): Promise<void> {
if (!this.slidesSwiperLoaded) {
return;
}
this.maxSlides = 3;
const width = this.slidesSwiper.width;
if (!width) {
return;
}
const fontSize = await CoreConfig.instance.get(CoreConstants.SETTINGS_FONT_SIZE, CoreConstants.CONFIG.font_sizes[0]);
this.maxSlides = Math.floor(width / (fontSize / CoreConstants.CONFIG.font_sizes[0] * CoreTabsBaseComponent.MIN_TAB_WIDTH));
}
/**
* Method that shows the next tab.
*/
async slideNext(): Promise<void> {
// Stop if slides are in transition.
if (!this.showNextButton || this.isInTransition) {
return;
}
if (await this.slides!.isBeginning()) {
// Slide to the second page.
this.slides!.slideTo(this.maxSlides);
} else {
const currentIndex = await this.slides!.getActiveIndex();
if (typeof currentIndex !== 'undefined') {
const nextSlideIndex = currentIndex + this.maxSlides;
this.isInTransition = true;
if (nextSlideIndex < this.numTabsShown) {
// Slide to the next page.
await this.slides!.slideTo(nextSlideIndex);
} else {
// Slide to the latest slide.
await this.slides!.slideTo(this.numTabsShown - 1);
}
}
}
}
/**
* Method that shows the previous tab.
*/
async slidePrev(): Promise<void> {
// Stop if slides are in transition.
if (!this.showPrevButton || this.isInTransition) {
return;
}
if (await this.slides!.isEnd()) {
this.slides!.slideTo(this.numTabsShown - this.maxSlides * 2);
// Slide to the previous of the latest page.
} else {
const currentIndex = await this.slides!.getActiveIndex();
if (typeof currentIndex !== 'undefined') {
const prevSlideIndex = currentIndex - this.maxSlides;
this.isInTransition = true;
if (prevSlideIndex >= 0) {
// Slide to the previous page.
await this.slides!.slideTo(prevSlideIndex);
} else {
// Slide to the first page.
await this.slides!.slideTo(0);
}
}
}
}
/**
* Show or hide the tabs. This is used when the user is scrolling inside a tab.
*
* @param scrollEvent Scroll event to check scroll position.
* @param content Content element to check measures.
*/
showHideTabs(scrollEvent: CustomEvent, content: HTMLElement): void {
if (!this.tabBarElement || !this.tabsElement || !content) {
return;
}
// Always show on very tall screens.
if (window.innerHeight >= CoreTabsBaseComponent.MAX_HEIGHT_TO_HIDE_TABS) {
return;
}
if (!this.tabBarHeight && this.tabBarElement.offsetHeight != this.tabBarHeight) {
// Wrong tab height, recalculate it.
this.calculateTabBarHeight();
}
if (!this.tabBarHeight) {
// We don't have the tab bar height, this means the tab bar isn't shown.
return;
}
const scroll = parseInt(scrollEvent.detail.scrollTop, 10);
if (scroll <= 0) {
// Ensure tabbar is shown.
this.tabsElement.style.top = '0';
this.tabsElement.style.height = '';
this.tabBarElement.classList.remove('tabs-hidden');
this.tabsShown = true;
this.lastScroll = 0;
return;
}
if (scroll == this.lastScroll) {
// Ensure scroll has been modified to avoid flicks.
return;
}
if (this.tabsShown && scroll > this.tabBarHeight) {
this.tabsShown = false;
// Hide tabs.
this.tabBarElement.classList.add('tabs-hidden');
this.tabsElement.style.top = '0';
this.tabsElement.style.height = '';
} else if (!this.tabsShown && scroll <= this.tabBarHeight) {
this.tabsShown = true;
this.tabBarElement.classList.remove('tabs-hidden');
}
if (this.tabsShown && content.scrollHeight > content.clientHeight + (this.tabBarHeight - scroll)) {
// Smooth translation.
this.tabsElement.style.top = - scroll + 'px';
this.tabsElement.style.height = 'calc(100% + ' + scroll + 'px';
}
// Use lastScroll after moving the tabs to avoid flickering.
this.lastScroll = parseInt(scrollEvent.detail.scrollTop, 10);
}
/**
* Select a tab by ID.
*
* @param tabId Tab ID.
* @param e Event.
* @return Promise resolved when done.
*/
async selectTab(tabId: string, e?: Event): Promise<void> {
const index = this.tabs.findIndex((tab) => tabId == tab.id);
return this.selectByIndex(index, e);
}
/**
* Select a tab by index.
*
* @param index Index to select.
* @param e Event.
* @return Promise resolved when done.
*/
async selectByIndex(index: number, e?: Event): Promise<void> {
if (index < 0 || index >= this.tabs.length) {
if (this.selected) {
// Invalid index do not change tab.
e?.preventDefault();
e?.stopPropagation();
return;
}
// Index isn't valid, select the first one.
index = 0;
}
const tabToSelect = this.tabs[index];
if (!tabToSelect || !tabToSelect.enabled || tabToSelect.id == this.selected) {
// Already selected or not enabled.
e?.preventDefault();
e?.stopPropagation();
return;
}
if (this.selected) {
await this.slides!.slideTo(index);
}
const ok = await this.loadTab(tabToSelect);
if (ok !== false) {
this.selectHistory.push(tabToSelect.id!);
this.selected = tabToSelect.id;
this.selectedIndex = index;
this.ionChange.emit(tabToSelect);
}
}
/**
* Load the tab.
*
* @param tabToSelect Tab to load.
* @return Promise resolved with true if tab is successfully loaded.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected async loadTab(tabToSelect: T): Promise<boolean> {
// Each implementation should override this function.
return true;
}
/**
* Listen scroll events in an element's inner ion-content (if any).
*
* @param element Element to search ion-content in.
* @param id ID of the tab/page.
* @return Promise resolved when done.
*/
async listenContentScroll(element: HTMLElement, id: number | string): Promise<void> {
const content = element.querySelector('ion-content');
if (!content || this.scrollListenersSet[id]) {
return;
}
const scroll = await content.getScrollElement();
content.scrollEvents = true;
this.scrollListenersSet[id] = true;
content.addEventListener('ionScroll', (e: CustomEvent): void => {
this.showHideTabs(e, scroll);
});
}
/**
* Adapt tabs to a window resize.
*/
protected windowResized(): void {
setTimeout(() => {
this.calculateSlides();
});
}
/**
* Component destroyed.
*/
ngOnDestroy(): void {
this.isDestroyed = true;
if (this.resizeFunction) {
window.removeEventListener('resize', this.resizeFunction);
}
this.languageChangedSubscription?.unsubscribe();
}
}
/**
* Data for each tab.
*/
export type CoreTabBase = {
title: string; // The translatable tab title.
id?: string; // Unique tab id.
class?: string; // Class, if needed.
icon?: string; // The tab icon.
badge?: string; // A badge to add in the tab.
badgeStyle?: string; // The badge color.
enabled?: boolean; // Whether the tab is enabled.
};

View File

@ -32,6 +32,8 @@ import { CoreShowPasswordComponent } from './show-password/show-password';
import { CoreSplitViewComponent } from './split-view/split-view';
import { CoreEmptyBoxComponent } from './empty-box/empty-box';
import { CoreTabsComponent } from './tabs/tabs';
import { CoreTabComponent } from './tabs/tab';
import { CoreTabsOutletComponent } from './tabs-outlet/tabs-outlet';
import { CoreInfiniteLoadingComponent } from './infinite-loading/infinite-loading';
import { CoreProgressBarComponent } from './progress-bar/progress-bar';
import { CoreContextMenuComponent } from './context-menu/context-menu';
@ -41,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';
@ -61,6 +64,8 @@ import { CorePipesModule } from '@pipes/pipes.module';
CoreSplitViewComponent,
CoreEmptyBoxComponent,
CoreTabsComponent,
CoreTabComponent,
CoreTabsOutletComponent,
CoreInfiniteLoadingComponent,
CoreProgressBarComponent,
CoreContextMenuComponent,
@ -70,6 +75,7 @@ import { CorePipesModule } from '@pipes/pipes.module';
CoreUserAvatarComponent,
CoreDynamicComponent,
CoreSendMessageFormComponent,
CoreTimerComponent,
],
imports: [
CommonModule,
@ -94,6 +100,8 @@ import { CorePipesModule } from '@pipes/pipes.module';
CoreSplitViewComponent,
CoreEmptyBoxComponent,
CoreTabsComponent,
CoreTabComponent,
CoreTabsOutletComponent,
CoreInfiniteLoadingComponent,
CoreProgressBarComponent,
CoreContextMenuComponent,
@ -103,6 +111,7 @@ import { CorePipesModule } from '@pipes/pipes.module';
CoreUserAvatarComponent,
CoreDynamicComponent,
CoreSendMessageFormComponent,
CoreTimerComponent,
],
})
export class CoreComponentsModule {}

View File

@ -0,0 +1,31 @@
<ion-tabs class="hide-header">
<ion-tab-bar slot="top" class="core-tabs-bar" [hidden]="!tabs || numTabsShown <= 1" #tabBar>
<ion-spinner *ngIf="!hideUntil"></ion-spinner>
<ion-row *ngIf="hideUntil">
<ion-col class="col-with-arrow ion-no-padding" (click)="slidePrev()" size="1">
<ion-icon *ngIf="showPrevButton" name="fas-chevron-left"></ion-icon>
</ion-col>
<ion-col class="ion-no-padding" size="10">
<ion-slides (ionSlideDidChange)="slideChanged()" [options]="slidesOpts" [dir]="direction" role="tablist"
[attr.aria-label]="description" aria-hidden="false">
<ng-container *ngFor="let tab of tabs">
<ion-slide [hidden]="!hideUntil" [attr.aria-selected]="selected == tab.id" class="tab-slide" role="tab"
[attr.aria-label]="tab.title | translate" [attr.aria-controls]="tab.id" [id]="tab.id! + '-tab'"
[tabindex]="selected == tab.id ? null : -1">
<ion-tab-button (ionTabButtonClick)="selectTab(tab.id, $event)" [tab]="tab.page" [layout]="layout"
class="{{tab.class}}">
<ion-icon *ngIf="tab.icon" [name]="tab.icon"></ion-icon>
<ion-label>{{ tab.title | translate}}</ion-label>
<ion-badge *ngIf="tab.badge">{{ tab.badge }}</ion-badge>
</ion-tab-button>
</ion-slide>
</ng-container>
</ion-slides>
</ion-col>
<ion-col class="col-with-arrow ion-no-padding" (click)="slideNext()" size="1">
<ion-icon *ngIf="showNextButton" name="fas-chevron-right"></ion-icon>
</ion-col>
</ion-row>
</ion-tab-bar>
</ion-tabs>

View File

@ -0,0 +1,176 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {
Component,
Input,
OnInit,
OnChanges,
OnDestroy,
AfterViewInit,
ViewChild,
ElementRef,
} from '@angular/core';
import { IonTabs } from '@ionic/angular';
import { Subscription } from 'rxjs';
import { CoreUtils } from '@services/utils/utils';
import { Params } from '@angular/router';
import { CoreNavBarButtonsComponent } from '../navbar-buttons/navbar-buttons';
import { CoreDomUtils } from '@services/utils/dom';
import { StackEvent } from '@ionic/angular/directives/navigation/stack-utils';
import { CoreNavigator } from '@services/navigator';
import { CoreTabBase, CoreTabsBaseComponent } from '@classes/tabs';
/**
* This component displays some top scrollable tabs that will autohide on vertical scroll.
* Each tab will load a page using Angular router.
*
* Example usage:
*
* <core-tabs-outlet selectedIndex="1" [tabs]="tabs"></core-tabs-outlet>
*
* Tab contents will only be shown if that tab is selected.
*
* @todo: Test behaviour when tabs are added late.
* @todo: Test RTL and tab history.
*/
@Component({
selector: 'core-tabs-outlet',
templateUrl: 'core-tabs-outlet.html',
styleUrls: ['../tabs/tabs.scss'],
})
export class CoreTabsOutletComponent extends CoreTabsBaseComponent<CoreTabsOutletTab>
implements OnInit, AfterViewInit, OnChanges, OnDestroy {
/**
* Determine tabs layout.
*/
@Input() layout: 'icon-top' | 'icon-start' | 'icon-end' | 'icon-bottom' | 'icon-hide' | 'label-hide' = 'icon-hide';
@Input() tabs: CoreTabsOutletTab[] = [];
@ViewChild(IonTabs) protected ionTabs?: IonTabs;
protected stackEventsSubscription?: Subscription;
constructor(
element: ElementRef,
) {
super(element);
}
/**
* Component being initialized.
*/
async ngOnInit(): Promise<void> {
super.ngOnInit();
this.tabs.forEach((tab) => {
this.initTab(tab);
});
}
/**
* Init tab info.
*
* @param tab Tab.
*/
protected initTab(tab: CoreTabsOutletTab): void {
tab.id = tab.id || 'core-tab-outlet-' + CoreUtils.instance.getUniqueId('CoreTabsOutletComponent');
if (typeof tab.enabled == 'undefined') {
tab.enabled = true;
}
}
/**
* View has been initialized.
*/
async ngAfterViewInit(): Promise<void> {
super.ngAfterViewInit();
if (this.isDestroyed) {
return;
}
this.tabsElement = this.element.nativeElement.querySelector('ion-tabs');
this.stackEventsSubscription = this.ionTabs?.outlet.stackEvents.subscribe(async (stackEvent: StackEvent) => {
if (!this.isCurrentView) {
return;
}
this.listenContentScroll(stackEvent.enteringView.element, stackEvent.enteringView.id);
this.showHideNavBarButtons(stackEvent.enteringView.element.tagName);
});
}
/**
* Detect changes on input properties.
*/
ngOnChanges(): void {
this.tabs.forEach((tab) => {
this.initTab(tab);
});
super.ngOnChanges();
}
/**
* Load the tab.
*
* @param tabToSelect Tab to load.
* @return Promise resolved with true if tab is successfully loaded.
*/
protected async loadTab(tabToSelect: CoreTabsOutletTab): Promise<boolean> {
return CoreNavigator.instance.navigate(tabToSelect.page, {
params: tabToSelect.pageParams,
});
}
/**
* Get all child core-navbar-buttons and show or hide depending on the page state.
* We need to use querySelectorAll because ContentChildren doesn't work with ng-template.
* https://github.com/angular/angular/issues/14842
*
* @param activatedPageName Activated page name.
*/
protected showHideNavBarButtons(activatedPageName: string): void {
const elements = this.ionTabs!.outlet.nativeEl.querySelectorAll('core-navbar-buttons');
const domUtils = CoreDomUtils.instance;
elements.forEach((element) => {
const instance: CoreNavBarButtonsComponent = domUtils.getInstanceByElement(element);
if (instance) {
const pagetagName = element.closest('.ion-page')?.tagName;
instance.forceHide(activatedPageName != pagetagName);
}
});
}
/**
* Component destroyed.
*/
ngOnDestroy(): void {
super.ngOnDestroy();
this.stackEventsSubscription?.unsubscribe();
}
}
/**
* Tab to be displayed in CoreTabsOutlet.
*/
export type CoreTabsOutletTab = CoreTabBase & {
page: string; // Page to navigate to.
pageParams?: Params; // Page params.
};

View File

@ -1,5 +1,4 @@
<ion-tabs class="hide-header">
<ion-tab-bar slot="top" class="core-tabs-bar" [hidden]="!tabs || numTabsShown <= 1">
<ion-tab-bar slot="top" class="core-tabs-bar" [hidden]="!tabs || numTabsShown <= 1" #tabBar>
<ion-spinner *ngIf="!hideUntil"></ion-spinner>
<ion-row *ngIf="hideUntil">
<ion-col class="col-with-arrow ion-no-padding" (click)="slidePrev()" size="1">
@ -9,16 +8,12 @@
<ion-slides (ionSlideDidChange)="slideChanged()" [options]="slidesOpts" [dir]="direction" role="tablist"
[attr.aria-label]="description" aria-hidden="false">
<ng-container *ngFor="let tab of tabs">
<ion-slide [hidden]="!hideUntil" [attr.aria-selected]="selected == tab.id" class="tab-slide"
[attr.aria-label]="tab.title | translate" role="tab" [attr.aria-controls]="tab.id" [id]="tab.id + '-tab'"
[tabindex]="selected == tab.id ? null : -1">
<ion-tab-button (ionTabButtonClick)="selectTab(tab.id, $event)" [tab]="tab.page" [layout]="layout"
class="{{tab.class}}">
<ion-slide [hidden]="!hideUntil" [attr.aria-selected]="selected == tab.id" class="tab-slide {{tab.class}}"
role="tab" [attr.aria-label]="tab.title | translate" [attr.aria-controls]="tab.id" [id]="tab.id! + '-tab'"
[tabindex]="selected == tab.id ? null : -1" (click)="selectTab(tab.id, $event)">
<ion-icon *ngIf="tab.icon" [name]="tab.icon"></ion-icon>
<ion-label>{{ tab.title | translate}}</ion-label>
<ion-badge *ngIf="tab.badge">{{ tab.badge }}</ion-badge>
</ion-tab-button>
</ion-slide>
</ng-container>
</ion-slides>
@ -27,5 +22,7 @@
<ion-icon *ngIf="showNextButton" name="fas-chevron-right"></ion-icon>
</ion-col>
</ion-row>
</ion-tab-bar>
</ion-tabs>
</ion-tab-bar>
<div class="core-tabs-content-container" #originalTabs>
<ng-content></ng-content>
</div>

View File

@ -0,0 +1,147 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, Input, Output, OnInit, OnDestroy, ElementRef, EventEmitter, ContentChild, TemplateRef } from '@angular/core';
import { CoreTabBase } from '@classes/tabs';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { CoreNavBarButtonsComponent } from '../navbar-buttons/navbar-buttons';
import { CoreTabsComponent } from './tabs';
/**
* A tab to use inside core-tabs. The content of this tab will be displayed when the tab is selected.
*
* You must provide either a title or an icon for the tab.
*
* The tab content MUST be surrounded by ng-template. This component uses ngTemplateOutlet instead of ng-content because the
* latter executes all the code immediately. This means that all the tabs would be initialized as soon as your view is
* loaded, leading to performance issues.
*
* Example usage:
*
* <core-tabs selectedIndex="1">
* <core-tab [title]="'core.courses.timeline' | translate" (ionSelect)="switchTab('timeline')">
* <ng-template> <!-- This ng-template is required. -->
* <!-- Tab contents. -->
* </ng-template>
* </core-tab>
* </core-tabs>
*/
@Component({
selector: 'core-tab',
template: '<ng-container *ngIf="loaded" [ngTemplateOutlet]="template"></ng-container>',
})
export class CoreTabComponent implements OnInit, OnDestroy, CoreTabBase {
@Input() title!: string; // The tab title.
@Input() icon?: string; // The tab icon.
@Input() badge?: string; // A badge to add in the tab.
@Input() badgeStyle?: string; // The badge color.
@Input() enabled = true; // Whether the tab is enabled.
@Input() class?: string; // Class, if needed.
@Input() set show(val: boolean) { // Whether the tab should be shown. Use a setter to detect changes on the value.
if (typeof val != 'undefined') {
const hasChanged = this.isShown != val;
this.isShown = val;
if (this.initialized && hasChanged) {
this.tabs.tabVisibilityChanged();
}
}
}
@Input() id?: string; // An ID to identify the tab.
@Output() ionSelect: EventEmitter<CoreTabComponent> = new EventEmitter<CoreTabComponent>();
@ContentChild(TemplateRef) template?: TemplateRef<unknown>; // Template defined by the content.
element: HTMLElement; // The core-tab element.
loaded = false;
initialized = false;
isShown = true;
tabElement?: HTMLElement | null;
constructor(
protected tabs: CoreTabsComponent,
element: ElementRef,
) {
this.element = element.nativeElement;
this.element.setAttribute('role', 'tabpanel');
this.element.setAttribute('tabindex', '0');
}
/**
* Component being initialized.
*/
ngOnInit(): void {
this.id = this.id || 'core-tab-' + CoreUtils.instance.getUniqueId('CoreTabComponent');
this.element.setAttribute('aria-labelledby', this.id + '-tab');
this.element.setAttribute('id', this.id);
this.tabs.addTab(this);
this.initialized = true;
}
/**
* Component destroyed.
*/
ngOnDestroy(): void {
this.tabs.removeTab(this);
}
/**
* Select tab.
*/
async selectTab(): Promise<void> {
this.element.classList.add('selected');
this.tabElement = this.tabElement || document.getElementById(this.id + '-tab');
this.tabElement?.setAttribute('aria-selected', 'true');
this.loaded = true;
this.ionSelect.emit(this);
this.showHideNavBarButtons(true);
// Setup tab scrolling.
this.tabs.listenContentScroll(this.element, this.id!);
}
/**
* Unselect tab.
*/
unselectTab(): void {
this.tabElement?.setAttribute('aria-selected', 'false');
this.element.classList.remove('selected');
this.showHideNavBarButtons(false);
}
/**
* Show all hide all children navbar buttons.
*
* @param show Whether to show or hide the buttons.
*/
protected showHideNavBarButtons(show: boolean): void {
const elements = this.element.querySelectorAll('core-navbar-buttons');
elements.forEach((element) => {
const instance: CoreNavBarButtonsComponent = CoreDomUtils.instance.getInstanceByElement(element);
if (instance) {
instance.forceHide(!show);
}
});
}
}

View File

@ -69,4 +69,26 @@
transform: translateY(0) !important;
}
}
::ng-deep {
core-tab, .core-tab {
display: none;
height: 100%;
position: relative;
z-index: 1;
&.selected {
display: block;
}
ion-header {
display: none;
}
.fixed-content, .scroll-content {
margin-top: 0 !important;
margin-bottom: 0 !important;
}
}
}
}

View File

@ -15,652 +15,157 @@
import {
Component,
Input,
Output,
EventEmitter,
OnInit,
OnChanges,
OnDestroy,
AfterViewInit,
ViewChild,
ElementRef,
} from '@angular/core';
import { Platform, IonSlides, IonTabs } from '@ionic/angular';
import { TranslateService } from '@ngx-translate/core';
import { Subscription } from 'rxjs';
import { CoreApp } from '@services/app';
import { CoreConfig } from '@services/config';
import { CoreConstants } from '@/core/constants';
import { CoreUtils } from '@services/utils/utils';
import { Params } from '@angular/router';
import { CoreNavBarButtonsComponent } from '../navbar-buttons/navbar-buttons';
import { CoreDomUtils } from '@services/utils/dom';
import { StackEvent } from '@ionic/angular/directives/navigation/stack-utils';
import { CoreNavigator } from '@services/navigator';
import { CoreTabsBaseComponent } from '@classes/tabs';
import { CoreTabComponent } from './tab';
/**
* This component displays some top scrollable tabs that will autohide on vertical scroll.
* Unlike core-tabs-outlet, this component does NOT use Angular router.
*
* Example usage:
*
* <core-tabs selectedIndex="1" [tabs]="tabs"></core-tabs>
*
* Tab contents will only be shown if that tab is selected.
*
* @todo: Test behaviour when tabs are added late.
* @todo: Test RTL and tab history.
* <core-tabs selectedIndex="1">
* <core-tab [title]="'core.courses.timeline' | translate" (ionSelect)="switchTab('timeline')">
* <ng-template> <!-- This ng-template is required, @see CoreTabComponent. -->
* <!-- Tab contents. -->
* </ng-template>
* </core-tab>
* </core-tabs>
*/
@Component({
selector: 'core-tabs',
templateUrl: 'core-tabs.html',
styleUrls: ['tabs.scss'],
})
export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDestroy {
export class CoreTabsComponent extends CoreTabsBaseComponent<CoreTabComponent> implements AfterViewInit {
@Input() parentScrollable = false; // Determine if the scroll should be in the parent content or the tab itself.
// Minimum tab's width to display fully the word "Competencies" which is the longest tab in the app.
protected static readonly MIN_TAB_WIDTH = 107;
// Max height that allows tab hiding.
protected static readonly MAX_HEIGHT_TO_HIDE_TABS = 768;
@ViewChild('originalTabs') originalTabsRef?: ElementRef;
@Input() protected selectedIndex = 0; // Index of the tab to select.
@Input() hideUntil = false; // Determine when should the contents be shown.
/**
* Determine tabs layout.
*/
@Input() layout: 'icon-top' | 'icon-start' | 'icon-end' | 'icon-bottom' | 'icon-hide' | 'label-hide' = 'icon-hide';
@Input() tabs: CoreTab[] = [];
@Output() protected ionChange: EventEmitter<CoreTab> = new EventEmitter<CoreTab>(); // Emitted when the tab changes.
@ViewChild(IonSlides) protected slides?: IonSlides;
@ViewChild(IonTabs) protected ionTabs?: IonTabs;
selected?: string; // Selected tab id.
showPrevButton = false;
showNextButton = false;
maxSlides = 3;
numTabsShown = 0;
direction = 'ltr';
description = '';
lastScroll = 0;
slidesOpts = {
initialSlide: 0,
slidesPerView: 3,
centerInsufficientSlides: true,
};
protected initialized = false;
protected afterViewInitTriggered = false;
protected tabBarHeight = 0;
protected tabBarElement?: HTMLIonTabBarElement; // The top tab bar element.
protected tabsElement?: HTMLIonTabsElement; // The ionTabs native Element.
protected tabsShown = true;
protected resizeFunction?: EventListenerOrEventListenerObject;
protected isDestroyed = false;
protected isCurrentView = true;
protected shouldSlideToInitial = false; // Whether we need to slide to the initial slide because it's out of view.
protected hasSliddenToInitial = false; // Whether we've already slidden to the initial slide or there was no need.
protected selectHistory: string[] = [];
protected firstSelectedTab?: string; // ID of the first selected tab to control history.
protected unregisterBackButtonAction: any;
protected languageChangedSubscription: Subscription;
protected isInTransition = false; // Weather Slides is in transition.
protected slidesSwiper: any; // eslint-disable-line @typescript-eslint/no-explicit-any
protected slidesSwiperLoaded = false;
protected stackEventsSubscription?: Subscription;
protected originalTabsContainer?: HTMLElement; // The container of the original tabs. It will include each tab's content.
constructor(
protected element: ElementRef,
platform: Platform,
translate: TranslateService,
element: ElementRef,
) {
this.direction = platform.isRTL ? 'rtl' : 'ltr';
// Change the side when the language changes.
this.languageChangedSubscription = translate.onLangChange.subscribe(() => {
setTimeout(() => {
this.direction = platform.isRTL ? 'rtl' : 'ltr';
});
});
}
/**
* Component being initialized.
*/
async ngOnInit(): Promise<void> {
this.tabs.forEach((tab) => {
this.initTab(tab);
});
}
/**
* Init tab info.
*
* @param tab Tab class.
*/
protected initTab(tab: CoreTab): void {
tab.id = tab.id || 'core-tab-' + CoreUtils.instance.getUniqueId('CoreTabsComponent');
if (typeof tab.enabled == 'undefined') {
tab.enabled = true;
}
super(element);
}
/**
* View has been initialized.
*/
async ngAfterViewInit(): Promise<void> {
super.ngAfterViewInit();
if (this.isDestroyed) {
return;
}
this.stackEventsSubscription = this.ionTabs!.outlet.stackEvents.subscribe(async (stackEvent: StackEvent) => {
if (this.isCurrentView) {
const content = stackEvent.enteringView.element.querySelector('ion-content');
this.showHideNavBarButtons(stackEvent.enteringView.element.tagName);
if (content) {
const scroll = await content.getScrollElement();
content.scrollEvents = true;
content.addEventListener('ionScroll', (e: CustomEvent): void => {
this.showHideTabs(e, scroll);
});
}
}
});
this.tabBarElement = this.element.nativeElement.querySelector('ion-tab-bar');
this.tabsElement = this.element.nativeElement.querySelector('ion-tabs');
this.slidesSwiper = await this.slides?.getSwiper();
this.slidesSwiper.once('progress', () => {
this.slidesSwiperLoaded = true;
this.calculateSlides();
});
this.afterViewInitTriggered = true;
if (!this.initialized && this.hideUntil) {
// Tabs should be shown, initialize them.
await this.initializeTabs();
}
this.resizeFunction = this.windowResized.bind(this);
window.addEventListener('resize', this.resizeFunction!);
}
/**
* Detect changes on input properties.
*/
ngOnChanges(): void {
this.tabs.forEach((tab) => {
this.initTab(tab);
});
// We need to wait for ngAfterViewInit because we need core-tab components to be executed.
if (!this.initialized && this.hideUntil && this.afterViewInitTriggered) {
// Tabs should be shown, initialize them.
// Use a setTimeout so child core-tab update their inputs before initializing the tabs.
setTimeout(() => {
this.initializeTabs();
});
}
}
/**
* User entered the page that contains the component.
*/
ionViewDidEnter(): void {
this.isCurrentView = true;
this.calculateSlides();
this.registerBackButtonAction();
}
/**
* Register back button action.
*/
protected registerBackButtonAction(): void {
this.unregisterBackButtonAction = CoreApp.instance.registerBackButtonAction(() => {
// The previous page in history is not the last one, we need the previous one.
if (this.selectHistory.length > 1) {
const tabIndex = this.selectHistory[this.selectHistory.length - 2];
// Remove curent and previous tabs from history.
this.selectHistory = this.selectHistory.filter((tabId) => this.selected != tabId && tabIndex != tabId);
this.selectTab(tabIndex);
return true;
} else if (this.selected != this.firstSelectedTab) {
// All history is gone but we are not in the first selected tab.
this.selectHistory = [];
this.selectTab(this.firstSelectedTab!);
return true;
}
return false;
}, 750);
}
/**
* User left the page that contains the component.
*/
ionViewDidLeave(): void {
// Unregister the custom back button action for this page
this.unregisterBackButtonAction && this.unregisterBackButtonAction();
this.isCurrentView = false;
}
/**
* Calculate slides.
*/
protected async calculateSlides(): Promise<void> {
if (!this.isCurrentView || !this.initialized) {
// Don't calculate if component isn't in current view, the calculations are wrong.
return;
}
if (!this.tabsShown) {
if (window.innerHeight >= CoreTabsComponent.MAX_HEIGHT_TO_HIDE_TABS) {
// Ensure tabbar is shown.
this.tabsShown = true;
this.tabBarElement!.classList.remove('tabs-hidden');
this.lastScroll = 0;
}
}
await this.calculateMaxSlides();
this.updateSlides();
}
/**
* Calculate the tab bar height.
*/
protected calculateTabBarHeight(): void {
if (!this.tabBarElement || !this.tabsElement) {
return;
}
this.tabBarHeight = this.tabBarElement.offsetHeight;
if (this.tabsShown) {
// Smooth translation.
this.tabsElement.style.top = - this.lastScroll + 'px';
this.tabsElement.style.height = 'calc(100% + ' + scroll + 'px';
} else {
this.tabBarElement.classList.add('tabs-hidden');
this.tabsElement.style.top = '0';
this.tabsElement.style.height = '';
}
}
/**
* Get the tab on a index.
*
* @param tabId Tab ID.
* @return Selected tab.
*/
protected getTabIndex(tabId: string): number {
return this.tabs.findIndex((tab) => tabId == tab.id);
}
/**
* Get the current selected tab.
*
* @return Selected tab.
*/
getSelected(): CoreTab | undefined {
const index = this.selected && this.getTabIndex(this.selected);
return index && index >= 0 ? this.tabs[index] : undefined;
this.tabsElement = this.element.nativeElement;
this.originalTabsContainer = this.originalTabsRef?.nativeElement;
}
/**
* Initialize the tabs, determining the first tab to be shown.
*/
protected async initializeTabs(): Promise<void> {
let selectedTab: CoreTab | undefined = this.tabs[this.selectedIndex || 0] || undefined;
await super.initializeTabs();
if (!selectedTab || !selectedTab.enabled) {
// The tab is not enabled or not shown. Get the first tab that is enabled.
selectedTab = this.tabs.find((tab) => tab.enabled) || undefined;
// @todo: Is this still needed?
// if (this.content) {
// if (!this.parentScrollable) {
// // Parent scroll element (if core-tabs is inside a ion-content).
// const scroll = await this.content.getScrollElement();
// if (scroll) {
// scroll.classList.add('no-scroll');
// }
// } else {
// this.originalTabsContainer?.classList.add('no-scroll');
// }
// }
}
if (!selectedTab) {
return;
}
/**
* Add a new tab if it isn't already in the list of tabs.
*
* @param tab The tab to add.
*/
addTab(tab: CoreTabComponent): void {
// Check if tab is already in the list.
if (this.getTabIndex(tab.id!) == -1) {
this.tabs.push(tab);
this.sortTabs();
this.firstSelectedTab = selectedTab.id!;
this.selectTab(this.firstSelectedTab);
setTimeout(() => {
this.calculateSlides();
});
// Setup tab scrolling.
if (this.initialized && this.tabs.length > 1 && this.tabBarHeight == 0) {
// Calculate the tabBarHeight again now that there is more than 1 tab and the bar will be seen.
// Use timeout to wait for the view to be rendered. 0 ms should be enough, use 50 to be sure.
setTimeout(() => {
this.calculateTabBarHeight();
}, 50);
}
}
}
this.initialized = true;
/**
* Remove a tab from the list of tabs.
*
* @param tab The tab to remove.
*/
removeTab(tab: CoreTabComponent): void {
const index = this.getTabIndex(tab.id!);
this.tabs.splice(index, 1);
// Check which arrows should be shown.
this.calculateSlides();
}
/**
* Method executed when the slides are changed.
*/
async slideChanged(): Promise<void> {
if (!this.slidesSwiperLoaded) {
return;
}
this.isInTransition = false;
const slidesCount = await this.slides?.length() || 0;
if (slidesCount > 0) {
this.showPrevButton = !await this.slides?.isBeginning();
this.showNextButton = !await this.slides?.isEnd();
} else {
this.showPrevButton = false;
this.showNextButton = false;
}
const currentIndex = await this.slides!.getActiveIndex();
if (this.shouldSlideToInitial && currentIndex != this.selectedIndex) {
// Current tab has changed, don't slide to initial anymore.
this.shouldSlideToInitial = false;
}
}
/**
* Updates the number of slides to show.
*/
protected async updateSlides(): Promise<void> {
this.numTabsShown = this.tabs.reduce((prev: number, current: CoreTab) => current.enabled ? prev + 1 : prev, 0);
this.slidesOpts = { ...this.slidesOpts, slidesPerView: Math.min(this.maxSlides, this.numTabsShown) };
this.calculateTabBarHeight();
await this.slides!.update();
if (!this.hasSliddenToInitial && this.selectedIndex && this.selectedIndex >= this.slidesOpts.slidesPerView) {
this.hasSliddenToInitial = true;
this.shouldSlideToInitial = true;
setTimeout(() => {
if (this.shouldSlideToInitial) {
this.slides!.slideTo(this.selectedIndex, 0);
this.shouldSlideToInitial = false;
}
}, 400);
return;
} else if (this.selectedIndex) {
this.hasSliddenToInitial = true;
}
setTimeout(() => {
this.slideChanged(); // Call slide changed again, sometimes the slide active index takes a while to be updated.
}, 400);
}
/**
* Calculate the number of slides that can fit on the screen.
*/
protected async calculateMaxSlides(): Promise<void> {
if (!this.slidesSwiperLoaded) {
return;
}
this.maxSlides = 3;
const width = this.slidesSwiper.width;
if (width) {
const fontSize = await
CoreConfig.instance.get(CoreConstants.SETTINGS_FONT_SIZE, CoreConstants.CONFIG.font_sizes[0]);
this.maxSlides = Math.floor(width / (fontSize / CoreConstants.CONFIG.font_sizes[0] *
CoreTabsComponent.MIN_TAB_WIDTH));
}
}
/**
* Method that shows the next tab.
*/
async slideNext(): Promise<void> {
// Stop if slides are in transition.
if (!this.showNextButton || this.isInTransition) {
return;
}
if (await this.slides!.isBeginning()) {
// Slide to the second page.
this.slides!.slideTo(this.maxSlides);
} else {
const currentIndex = await this.slides!.getActiveIndex();
if (typeof currentIndex !== 'undefined') {
const nextSlideIndex = currentIndex + this.maxSlides;
this.isInTransition = true;
if (nextSlideIndex < this.numTabsShown) {
// Slide to the next page.
await this.slides!.slideTo(nextSlideIndex);
} else {
// Slide to the latest slide.
await this.slides!.slideTo(this.numTabsShown - 1);
}
}
}
}
/**
* Method that shows the previous tab.
*/
async slidePrev(): Promise<void> {
// Stop if slides are in transition.
if (!this.showPrevButton || this.isInTransition) {
return;
}
if (await this.slides!.isEnd()) {
this.slides!.slideTo(this.numTabsShown - this.maxSlides * 2);
// Slide to the previous of the latest page.
} else {
const currentIndex = await this.slides!.getActiveIndex();
if (typeof currentIndex !== 'undefined') {
const prevSlideIndex = currentIndex - this.maxSlides;
this.isInTransition = true;
if (prevSlideIndex >= 0) {
// Slide to the previous page.
await this.slides!.slideTo(prevSlideIndex);
} else {
// Slide to the first page.
await this.slides!.slideTo(0);
}
}
}
}
/**
* Show or hide the tabs. This is used when the user is scrolling inside a tab.
* Load the tab.
*
* @param scrollEvent Scroll event to check scroll position.
* @param content Content element to check measures.
* @param tabToSelect Tab to load.
* @return Promise resolved with true if tab is successfully loaded.
*/
protected showHideTabs(scrollEvent: CustomEvent, content: HTMLElement): void {
if (!this.tabBarElement || !this.tabsElement || !content) {
return;
}
protected async loadTab(tabToSelect: CoreTabComponent): Promise<boolean> {
const currentTab = this.getSelected();
currentTab?.unselectTab();
tabToSelect.selectTab();
// Always show on very tall screens.
if (window.innerHeight >= CoreTabsComponent.MAX_HEIGHT_TO_HIDE_TABS) {
return;
}
if (!this.tabBarHeight && this.tabBarElement.offsetHeight != this.tabBarHeight) {
// Wrong tab height, recalculate it.
this.calculateTabBarHeight();
}
if (!this.tabBarHeight) {
// We don't have the tab bar height, this means the tab bar isn't shown.
return;
}
const scroll = parseInt(scrollEvent.detail.scrollTop, 10);
if (scroll <= 0) {
// Ensure tabbar is shown.
this.tabsElement.style.top = '0';
this.tabsElement.style.height = '';
this.tabBarElement!.classList.remove('tabs-hidden');
this.tabsShown = true;
this.lastScroll = 0;
return;
}
if (scroll == this.lastScroll) {
// Ensure scroll has been modified to avoid flicks.
return;
}
if (this.tabsShown && scroll > this.tabBarHeight) {
this.tabsShown = false;
// Hide tabs.
this.tabBarElement.classList.add('tabs-hidden');
this.tabsElement.style.top = '0';
this.tabsElement.style.height = '';
} else if (!this.tabsShown && scroll <= this.tabBarHeight) {
this.tabsShown = true;
this.tabBarElement!.classList.remove('tabs-hidden');
}
if (this.tabsShown && content.scrollHeight > content.clientHeight + (this.tabBarHeight - scroll)) {
// Smooth translation.
this.tabsElement.style.top = - scroll + 'px';
this.tabsElement.style.height = 'calc(100% + ' + scroll + 'px';
}
// Use lastScroll after moving the tabs to avoid flickering.
this.lastScroll = parseInt(scrollEvent.detail.scrollTop, 10);
return true;
}
/**
* Select a tab by ID.
*
* @param tabId Tab ID.
* @param e Event.
* @return Promise resolved when done.
* Sort the tabs, keeping the same order as in the original list.
*/
async selectTab(tabId: string, e?: Event): Promise<void> {
const index = this.tabs.findIndex((tab) => tabId == tab.id);
return this.selectByIndex(index, e);
}
/**
* Select a tab by index.
*
* @param index Index to select.
* @param e Event.
* @return Promise resolved when done.
*/
async selectByIndex(index: number, e?: Event): Promise<void> {
if (index < 0 || index >= this.tabs.length) {
if (this.selected) {
// Invalid index do not change tab.
e?.preventDefault();
e?.stopPropagation();
protected sortTabs(): void {
if (!this.originalTabsContainer) {
return;
}
// Index isn't valid, select the first one.
index = 0;
const newTabs: CoreTabComponent[] = [];
this.tabs.forEach((tab) => {
const originalIndex = Array.prototype.indexOf.call(this.originalTabsContainer?.children, tab.element);
if (originalIndex != -1) {
newTabs[originalIndex] = tab;
}
const tabToSelect = this.tabs[index];
if (!tabToSelect || !tabToSelect.enabled || tabToSelect.id == this.selected) {
// Already selected or not enabled.
e?.preventDefault();
e?.stopPropagation();
return;
}
if (this.selected) {
await this.slides!.slideTo(index);
}
const ok = await CoreNavigator.instance.navigate(tabToSelect.page, {
params: tabToSelect.pageParams,
});
if (ok !== false) {
this.selectHistory.push(tabToSelect.id!);
this.selected = tabToSelect.id;
this.selectedIndex = index;
this.ionChange.emit(tabToSelect);
}
this.tabs = newTabs;
}
/**
* Get all child core-navbar-buttons and show or hide depending on the page state.
* We need to use querySelectorAll because ContentChildren doesn't work with ng-template.
* https://github.com/angular/angular/issues/14842
*
* @param activatedPageName Activated page name.
* Function to call when the visibility of a tab has changed.
*/
protected showHideNavBarButtons(activatedPageName: string): void {
const elements = this.ionTabs!.outlet.nativeEl.querySelectorAll('core-navbar-buttons');
const domUtils = CoreDomUtils.instance;
elements.forEach((element) => {
const instance: CoreNavBarButtonsComponent = domUtils.getInstanceByElement(element);
if (instance) {
const pagetagName = element.closest('.ion-page')?.tagName;
instance.forceHide(activatedPageName != pagetagName);
}
});
}
/**
* Adapt tabs to a window resize.
*/
protected windowResized(): void {
setTimeout(() => {
tabVisibilityChanged(): void {
this.calculateSlides();
});
}
/**
* Component destroyed.
*/
ngOnDestroy(): void {
this.isDestroyed = true;
if (this.resizeFunction) {
window.removeEventListener('resize', this.resizeFunction);
}
this.stackEventsSubscription?.unsubscribe();
this.languageChangedSubscription.unsubscribe();
}
}
/**
* Core Tab class.
*/
export type CoreTab = {
page: string; // Page to navigate to.
title: string; // The translatable tab title.
id?: string; // Unique tab id.
class?: string; // Class, if needed.
icon?: string; // The tab icon.
badge?: string; // A badge to add in the tab.
badgeStyle?: string; // The badge color.
enabled?: boolean; // Whether the tab is enabled.
pageParams?: Params; // Page params.
};

View File

@ -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>

View File

@ -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);
}
}
}
}
}
}

View File

@ -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);
}
}

View File

@ -12,7 +12,6 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Params } from '@angular/router';
import { CoreContentLinksHandler, CoreContentLinksAction } from '../services/contentlinks-delegate';
/**
@ -67,7 +66,7 @@ export class CoreContentLinksHandlerBase implements CoreContentLinksHandler {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
url: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
params: Params,
params: Record<string, string>,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
courseId?: number,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -112,7 +111,7 @@ export class CoreContentLinksHandlerBase implements CoreContentLinksHandler {
* @return Whether the handler is enabled for the URL and site.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
isEnabled(siteId: string, url: string, params: Params, courseId?: number): boolean | Promise<boolean> {
async isEnabled(siteId: string, url: string, params: Record<string, string>, courseId?: number): Promise<boolean> {
return true;
}

View File

@ -16,8 +16,7 @@ import { CoreContentLinksAction } from '../services/contentlinks-delegate';
import { CoreContentLinksHandlerBase } from './base-handler';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
// import { CoreCourseHelper } from '@features/course/services/helper';
import { Params } from '@angular/router';
import { CoreCourseHelper } from '@features/course/services/course-helper';
/**
* Handler to handle URLs pointing to the grade of a module.
@ -64,21 +63,26 @@ export class CoreContentLinksModuleGradeHandler extends CoreContentLinksHandlerB
getActions(
siteIds: string[],
url: string,
params: Params,
params: Record<string, string>,
courseId?: number,
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
courseId = courseId || params.courseid || params.cid;
courseId = Number(courseId || params.courseid || params.cid);
return [{
action: async (siteId): Promise<void> => {
// Check if userid is the site's current user.
const modal = await CoreDomUtils.instance.showModalLoading();
const site = await CoreSites.instance.getSite(siteId);
if (!params.userid || params.userid == site.getUserId()) {
if (!params.userid || Number(params.userid) == site.getUserId()) {
// No user specified or current user. Navigate to module.
// @todo CoreCourseHelper.instance.navigateToModule(parseInt(params.id, 10), siteId, courseId, undefined,
// this.useModNameToGetModule ? this.modName : undefined, undefined, navCtrl);
CoreCourseHelper.instance.navigateToModule(
Number(params.id),
siteId,
courseId,
undefined,
this.useModNameToGetModule ? this.modName : undefined,
);
} else if (this.canReview) {
// Use the goToReview function.
this.goToReview(url, params, courseId!, siteId);
@ -103,7 +107,7 @@ export class CoreContentLinksModuleGradeHandler extends CoreContentLinksHandlerB
*/
protected async goToReview(
url: string, // eslint-disable-line @typescript-eslint/no-unused-vars
params: Params, // eslint-disable-line @typescript-eslint/no-unused-vars
params: Record<string, string>, // eslint-disable-line @typescript-eslint/no-unused-vars
courseId: number, // eslint-disable-line @typescript-eslint/no-unused-vars
siteId: string, // eslint-disable-line @typescript-eslint/no-unused-vars
): Promise<void> {

View File

@ -15,6 +15,7 @@
import { CoreContentLinksHandlerBase } from './base-handler';
import { Params } from '@angular/router';
import { CoreContentLinksAction } from '../services/contentlinks-delegate';
import { CoreCourseHelper } from '@features/course/services/course-helper';
/**
* Handler to handle URLs pointing to the index of a module.
@ -59,8 +60,8 @@ export class CoreContentLinksModuleIndexHandler extends CoreContentLinksHandlerB
* @return List of params to pass to navigateToModule / navigateToModuleByInstance.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getPageParams(url: string, params: Params, courseId?: number): Params {
return [];
getPageParams(url: string, params: Record<string, string>, courseId?: number): Params {
return {};
}
/**
@ -73,34 +74,45 @@ export class CoreContentLinksModuleIndexHandler extends CoreContentLinksHandlerB
* @return List of (or promise resolved with list of) actions.
*/
getActions(
siteIds: string[], // eslint-disable-line @typescript-eslint/no-unused-vars
url: string, // eslint-disable-line @typescript-eslint/no-unused-vars
params: Params, // eslint-disable-line @typescript-eslint/no-unused-vars
courseId?: number, // eslint-disable-line @typescript-eslint/no-unused-vars
siteIds: string[],
url: string,
params: Record<string, string>,
courseId?: number,
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
return [];
/*
courseId = courseId || params.courseid || params.cid;
courseId = Number(courseId || params.courseid || params.cid);
const pageParams = this.getPageParams(url, params, courseId);
if (this.instanceIdParam && typeof params[this.instanceIdParam] != 'undefined') {
const instanceId = parseInt(params[this.instanceIdParam], 10);
return [{
action: (siteId): void => {
this.courseHelper.navigateToModuleByInstance(instanceId, this.modName, siteId, courseId, undefined,
this.useModNameToGetModule, pageParams);
action: (siteId) => {
CoreCourseHelper.instance.navigateToModuleByInstance(
instanceId,
this.modName,
siteId,
courseId,
undefined,
this.useModNameToGetModule,
pageParams,
);
},
}];
}
return [{
action: (siteId): void => {
this.courseHelper.navigateToModule(parseInt(params.id, 10), siteId, courseId, undefined,
this.useModNameToGetModule ? this.modName : undefined, pageParams);
action: (siteId) => {
CoreCourseHelper.instance.navigateToModule(
parseInt(params.id, 10),
siteId,
courseId,
undefined,
this.useModNameToGetModule ? this.modName : undefined,
pageParams,
);
},
}];
*/
}
}

View File

@ -14,7 +14,7 @@
import { CoreContentLinksHandlerBase } from './base-handler';
import { Translate } from '@singletons';
import { Params } from '@angular/router';
import { CoreContentLinksAction } from '../services/contentlinks-delegate';
import { CoreNavigator } from '@services/navigator';
@ -53,7 +53,11 @@ export class CoreContentLinksModuleListHandler extends CoreContentLinksHandlerBa
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @return List of (or promise resolved with list of) actions.
*/
getActions(siteIds: string[], url: string, params: Params): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
getActions(
siteIds: string[],
url: string,
params: Record<string, string>,
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
return [{
action: (siteId): void => {

View File

@ -11,7 +11,7 @@
<ion-list>
<ion-item class="ion-text-wrap">
<ion-label>
<p class="item-heading">{{ 'core.contentlinks.chooseaccounttoopenlink' | translate }}</p>
<h3 class="item-heading">{{ 'core.contentlinks.chooseaccounttoopenlink' | translate }}</h3>
<p>{{ url }}</p>
</ion-label>
</ion-item>

View File

@ -17,7 +17,6 @@ import { CoreLogger } from '@singletons/logger';
import { CoreSites } from '@services/sites';
import { CoreUrlUtils } from '@services/utils/url';
import { CoreUtils } from '@services/utils/utils';
import { Params } from '@angular/router';
import { makeSingleton } from '@singletons';
/**
@ -56,8 +55,13 @@ export interface CoreContentLinksHandler {
* @param data Extra data to handle the URL.
* @return List of (or promise resolved with list of) actions.
*/
getActions(siteIds: string[], url: string, params: Params, courseId?: number, data?: unknown):
CoreContentLinksAction[] | Promise<CoreContentLinksAction[]>;
getActions(
siteIds: string[],
url: string,
params: Record<string, string>,
courseId?: number,
data?: unknown,
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]>;
/**
* Check if a URL is handled by this handler.
@ -85,7 +89,7 @@ export interface CoreContentLinksHandler {
* @param courseId Course ID related to the URL. Optional but recommended.
* @return Whether the handler is enabled for the URL and site.
*/
isEnabled?(siteId: string, url: string, params: Params, courseId?: number): boolean | Promise<boolean>;
isEnabled?(siteId: string, url: string, params: Record<string, string>, courseId?: number): Promise<boolean>;
}
/**
@ -244,7 +248,7 @@ export class CoreContentLinksDelegateService {
protected async isHandlerEnabled(
handler: CoreContentLinksHandler,
url: string,
params: Params,
params: Record<string, string>,
courseId: number,
siteId: string,
): Promise<boolean> {
@ -264,7 +268,7 @@ export class CoreContentLinksDelegateService {
return true;
}
return handler.isEnabled(siteId, url, params, courseId);
return await handler.isEnabled(siteId, url, params, courseId);
}
/**

View File

@ -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<IonRefresher>, done?: () => void, showErrors: boolean = false): Promise<void> {
async doRefresh(refresher?: CustomEvent<IonRefresher> | null, done?: () => void, showErrors: boolean = false): Promise<void> {
if (!this.loaded || !this.module) {
return;
}

View File

@ -95,7 +95,7 @@
<ion-buttons class="ion-padding core-course-section-nav-buttons safe-padding-horizontal"
*ngIf="displaySectionSelector && sections?.length">
<ion-button *ngIf="previousSection" color="medium" (click)="sectionChanged(previousSection)"
<ion-button *ngIf="previousSection" color="light" (click)="sectionChanged(previousSection)"
title="{{ 'core.previous' | translate }}">
<ion-icon name="fas-chevron-left" slot="icon-only"></ion-icon>
<core-format-text class="accesshide" [text]="previousSection.name" contextLevel="course"

View File

@ -11,5 +11,5 @@
</ion-toolbar>
</ion-header>
<ion-content>
<core-tabs [tabs]="tabs" [hideUntil]="loaded"></core-tabs>
<core-tabs-outlet [tabs]="tabs" [hideUntil]="loaded"></core-tabs-outlet>
</ion-content>

View File

@ -15,7 +15,7 @@
import { Component, ViewChild, OnDestroy, OnInit } from '@angular/core';
import { Params } from '@angular/router';
import { CoreTab, CoreTabsComponent } from '@components/tabs/tabs';
import { CoreTabsOutletTab, CoreTabsOutletComponent } from '@components/tabs-outlet/tabs-outlet';
import { CoreCourseFormatDelegate } from '../../services/format-delegate';
import { CoreCourseOptionsDelegate } from '../../services/course-options-delegate';
import { CoreCourseAnyCourseData } from '@features/courses/services/courses';
@ -35,7 +35,7 @@ import { CoreNavigator } from '@services/navigator';
})
export class CoreCourseIndexPage implements OnInit, OnDestroy {
@ViewChild(CoreTabsComponent) tabsComponent?: CoreTabsComponent;
@ViewChild(CoreTabsOutletComponent) tabsComponent?: CoreTabsOutletComponent;
title?: string;
course?: CoreCourseAnyCourseData;
@ -45,7 +45,7 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy {
protected currentPagePath = '';
protected selectTabObserver: CoreEventObserver;
protected firstTabName?: string;
protected contentsTab: CoreTab = {
protected contentsTab: CoreTabsOutletTab = {
page: 'contents',
title: 'core.course.contents',
pageParams: {},
@ -183,6 +183,6 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy {
}
type CourseTab = CoreTab & {
type CourseTab = CoreTabsOutletTab & {
name?: string;
};

View File

@ -64,6 +64,9 @@ import { CoreTimeUtils } from '@services/utils/time';
import { CoreEventObserver, CoreEventPackageStatusChanged, CoreEvents } from '@singletons/events';
import { CoreFilterHelper } from '@features/filter/services/filter-helper';
import { CoreNetworkError } from '@classes/errors/network-error';
import { CoreSiteHome } from '@features/sitehome/services/sitehome';
import { CoreNavigator } from '@services/navigator';
import { CoreSiteHomeHomeHandlerService } from '@features/sitehome/services/handlers/sitehome-home';
/**
* Prefetch info of a module.
@ -1392,8 +1395,35 @@ export class CoreCourseHelperProvider {
* @param modParams Params to pass to the module
* @return Promise resolved when done.
*/
navigateToModuleByInstance(): void {
// @todo params and logic
async navigateToModuleByInstance(
instanceId: number,
modName: string,
siteId?: string,
courseId?: number,
sectionId?: number,
useModNameToGetModule: boolean = false,
modParams?: Params,
): Promise<void> {
const modal = await CoreDomUtils.instance.showModalLoading();
try {
const module = await CoreCourse.instance.getModuleBasicInfoByInstance(instanceId, modName, siteId);
this.navigateToModule(
module.id,
siteId,
module.course,
sectionId,
useModNameToGetModule ? modName : undefined,
modParams,
);
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errorgetmodule', true);
} finally {
// Just in case. In fact we need to dismiss the modal before showing a toast or error message.
modal.dismiss();
}
}
/**
@ -1408,8 +1438,82 @@ export class CoreCourseHelperProvider {
* @param modParams Params to pass to the module
* @return Promise resolved when done.
*/
navigateToModule(): void {
// @todo params and logic
async navigateToModule(
moduleId: number,
siteId?: string,
courseId?: number,
sectionId?: number,
modName?: string,
modParams?: Params,
): Promise<void> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
const modal = await CoreDomUtils.instance.showModalLoading();
try {
if (!courseId) {
// We don't have courseId.
const module = await CoreCourse.instance.getModuleBasicInfo(moduleId, siteId);
courseId = module.course;
sectionId = module.section;
} else if (!sectionId) {
// We don't have sectionId but we have courseId.
sectionId = await CoreCourse.instance.getModuleSectionId(moduleId, siteId);
}
// Get the site.
const site = await CoreSites.instance.getSite(siteId);
// Get the module.
const module = <CoreCourseModule>
await CoreCourse.instance.getModule(moduleId, courseId, sectionId, false, false, siteId, modName);
if (CoreSites.instance.getCurrentSiteId() == site.getId()) {
// Try to use the module's handler to navigate cleanly.
module.handlerData = CoreCourseModuleDelegate.instance.getModuleDataFor(
module.modname,
module,
courseId,
sectionId,
false,
);
if (module.handlerData?.action) {
modal.dismiss();
return module.handlerData.action(new Event('click'), module, courseId, { params: modParams });
}
}
this.logger.warn('navCtrl was not passed to navigateToModule by the link handler for ' + module.modname);
const params = {
course: { id: courseId },
module: module,
sectionId: sectionId,
modParams: modParams,
};
if (courseId == site.getSiteHomeId()) {
// Check if site home is available.
const isAvailable = await CoreSiteHome.instance.isAvailable();
if (isAvailable) {
await CoreNavigator.instance.navigateToSitePath(CoreSiteHomeHomeHandlerService.PAGE_NAME, { params, siteId });
return;
}
}
modal.dismiss();
await this.getAndOpenCourse(courseId, params, siteId);
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errorgetmodule', true);
} finally {
modal.dismiss();
}
}
/**
@ -1433,7 +1537,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;
}
@ -1744,9 +1848,7 @@ export class CoreCourseHelperProvider {
params = params || {};
Object.assign(params, { course: course });
// @todo implement open course.
// await CoreNavigator.instance.navigateToSitePath('/course/.../...', { siteId, queryParams: params });
// return CoreNavigator.instance.openInSiteMainMenu(CoreNavigatorService.OPEN_COURSE, params, siteId);
await CoreNavigator.instance.navigateToSitePath('course', { siteId, params });
}
}

View File

@ -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 });

View File

@ -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();

View File

@ -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<CoreCourseModu
*/
getModuleDataFor(
modname: string,
module: CoreCourseWSModule | CoreCourseModuleBasicInfo,
module: CoreCourseAnyModuleData,
courseId: number,
sectionId?: number,
forCoursePage?: boolean,

View File

@ -48,7 +48,7 @@ export class CoreCoursesDashboardLinkHandlerService extends CoreContentLinksHand
* @param siteId The site ID.
* @return Whether the handler is enabled for the URL and site.
*/
isEnabled(siteId: string): boolean | Promise<boolean> {
async isEnabled(siteId: string): Promise<boolean> {
return CoreDashboardHomeHandler.instance.isEnabledForSite(siteId);
}

View File

@ -48,7 +48,7 @@ export class CoreGradesOverviewLinkHandlerService extends CoreContentLinksHandle
* @param siteId The site ID.
* @return Whether the handler is enabled for the URL and site.
*/
isEnabled(siteId: string): boolean | Promise<boolean> {
async isEnabled(siteId: string): Promise<boolean> {
return CoreGrades.instance.isCourseGradesEnabled(siteId);
}

View File

@ -13,7 +13,7 @@
// limitations under the License.
import { Injectable } from '@angular/core';
import { Params } from '@angular/router';
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
import { CoreGrades } from '@features/grades/services/grades';
@ -42,16 +42,16 @@ export class CoreGradesUserLinkHandlerService extends CoreContentLinksHandlerBas
getActions(
siteIds: string[],
url: string,
params: Params,
params: Record<string, string>,
courseId?: number,
data?: { cmid?: string },
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
courseId = courseId || params.id;
courseId = courseId || Number(params.id);
data = data || {};
return [{
action: (siteId): void => {
const userId = params.userid && parseInt(params.userid, 10);
const userId = params.userid ? parseInt(params.userid, 10) : undefined;
const moduleId = data?.cmid && parseInt(data.cmid, 10) || undefined;
CoreGradesHelper.instance.goToGrades(courseId!, userId, moduleId, siteId);
@ -69,12 +69,12 @@ export class CoreGradesUserLinkHandlerService extends CoreContentLinksHandlerBas
* @param courseId Course ID related to the URL. Optional but recommended.
* @return Whether the handler is enabled for the URL and site.
*/
isEnabled(siteId: string, url: string, params: Params, courseId?: number): boolean | Promise<boolean> {
async isEnabled(siteId: string, url: string, params: Record<string, string>, courseId?: number): Promise<boolean> {
if (!courseId && !params.id) {
return false;
}
return CoreGrades.instance.isPluginEnabledForCourse(courseId || params.id, siteId);
return CoreGrades.instance.isPluginEnabledForCourse(courseId || Number(params.id), siteId);
}
}

View File

@ -67,7 +67,7 @@
<ion-item class="ion-text-wrap">
<ion-label>
<p class="item-heading">{{ 'core.whyisthisrequired' | translate }}</p>
<h3 class="item-heading">{{ 'core.whyisthisrequired' | translate }}</h3>
<p>{{ 'core.explanationdigitalminor' | translate }}</p>
</ion-label>
</ion-item>
@ -225,7 +225,7 @@
</ion-item-divider>
<ion-item class="ion-text-wrap" lines="none">
<ion-label>
<p class="item-heading">{{ 'core.considereddigitalminor' | translate }}</p>
<h3 class="item-heading">{{ 'core.considereddigitalminor' | translate }}</h3>
<p>{{ 'core.digitalminor_desc' | translate }}</p>
<p *ngIf="supportName">{{ supportName }}</p>
<p *ngIf="supportEmail">{{ supportEmail }}</p>

View File

@ -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>

View File

@ -35,6 +35,9 @@ export function buildTabMainRoutes(injector: Injector, mainRoute: Route): Routes
@NgModule()
export class CoreMainMenuTabRoutingModule {
/**
* Use this function to declare routes that will be children of all main menu tabs root routes.
*/
static forChild(routes: ModuleRoutesConfig): ModuleWithProviders<CoreMainMenuTabRoutingModule> {
return {
ngModule: CoreMainMenuTabRoutingModule,

View File

@ -15,7 +15,8 @@
<ion-content>
<!-- @todo -->
<core-loading [hideUntil]="loaded">
<core-tabs *ngIf="tabs.length > 0" [selectedIndex]="selectedTab" [hideUntil]="loaded" [tabs]="tabs"></core-tabs>
<core-tabs-outlet *ngIf="tabs.length > 0" [selectedIndex]="selectedTab" [hideUntil]="loaded" [tabs]="tabs">
</core-tabs-outlet>
<ng-container *ngIf="tabs.length == 0">
<core-empty-box icon="fas-home" [message]="'core.courses.nocourses' | translate"></core-empty-box>
</ng-container>

View File

@ -17,7 +17,7 @@ import { Subscription } from 'rxjs';
import { CoreSites } from '@services/sites';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreTab, CoreTabsComponent } from '@components/tabs/tabs';
import { CoreTabsOutletComponent, CoreTabsOutletTab } from '@components/tabs-outlet/tabs-outlet';
import { CoreMainMenuHomeDelegate, CoreMainMenuHomeHandlerToDisplay } from '../../services/home-delegate';
/**
@ -30,10 +30,10 @@ import { CoreMainMenuHomeDelegate, CoreMainMenuHomeHandlerToDisplay } from '../.
})
export class CoreMainMenuHomePage implements OnInit {
@ViewChild(CoreTabsComponent) tabsComponent?: CoreTabsComponent;
@ViewChild(CoreTabsOutletComponent) tabsComponent?: CoreTabsOutletComponent;
siteName!: string;
tabs: CoreTab[] = [];
tabs: CoreTabsOutletTab[] = [];
loaded = false;
selectedTab?: number;

View File

@ -15,6 +15,8 @@
import { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreCronDelegate } from '@services/cron';
import { CORE_SITE_SCHEMAS } from '@services/sites';
import { SITE_SCHEMA } from './services/database/pushnotifications';
import { CorePushNotificationsRegisterCronHandler } from './services/handlers/register-cron';
import { CorePushNotificationsUnregisterCronHandler } from './services/handlers/unregister-cron';
import { CorePushNotifications } from './services/pushnotifications';
@ -25,6 +27,11 @@ import { CorePushNotifications } from './services/pushnotifications';
imports: [
],
providers: [
{
provide: CORE_SITE_SCHEMAS,
useValue: [SITE_SCHEMA],
multi: true,
},
{
provide: APP_INITIALIZER,
multi: true,

View File

@ -13,7 +13,7 @@
// limitations under the License.
import { Injectable } from '@angular/core';
import { Params } from '@angular/router';
import { CoreSites } from '@services/sites';
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
@ -59,7 +59,7 @@ export class CoreSiteHomeIndexLinkHandlerService extends CoreContentLinksHandler
* @param courseId Course ID related to the URL. Optional but recommended.
* @return Whether the handler is enabled for the URL and site.
*/
async isEnabled(siteId: string, url: string, params: Params, courseId?: number): Promise<boolean> {
async isEnabled(siteId: string, url: string, params: Record<string, string>, courseId?: number): Promise<boolean> {
courseId = parseInt(params.id, 10);
if (!courseId) {
return false;

View File

@ -13,7 +13,7 @@
// limitations under the License.
import { Injectable } from '@angular/core';
import { Params } from '@angular/router';
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
import { CoreNavigator } from '@services/navigator';
@ -42,7 +42,7 @@ export class CoreTagIndexLinkHandlerService extends CoreContentLinksHandlerBase
getActions(
siteIds: string[],
url: string,
params: Params,
params: Record<string, string>,
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
return [{
action: (siteId): void => {
@ -77,7 +77,7 @@ export class CoreTagIndexLinkHandlerService extends CoreContentLinksHandlerBase
* @param courseId Course ID related to the URL. Optional but recommended.
* @return Whether the handler is enabled for the URL and site.
*/
isEnabled(siteId: string): boolean | Promise<boolean> {
async isEnabled(siteId: string): Promise<boolean> {
return CoreTag.instance.areTagsAvailable(siteId);
}

View File

@ -13,7 +13,7 @@
// limitations under the License.
import { Injectable } from '@angular/core';
import { Params } from '@angular/router';
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
import { CoreNavigator } from '@services/navigator';
@ -39,7 +39,11 @@ export class CoreTagSearchLinkHandlerService extends CoreContentLinksHandlerBase
* @param data Extra data to handle the URL.
* @return List of (or promise resolved with list of) actions.
*/
getActions(siteIds: string[], url: string, params: Params): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
getActions(
siteIds: string[],
url: string,
params: Record<string, string>,
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
return [{
action: (siteId): void => {
const pageParams = {
@ -59,7 +63,7 @@ export class CoreTagSearchLinkHandlerService extends CoreContentLinksHandlerBase
* @param siteId The site ID.
* @return Whether the handler is enabled for the URL and site.
*/
isEnabled(siteId: string): boolean | Promise<boolean> {
async isEnabled(siteId: string): Promise<boolean> {
return CoreTag.instance.areTagsAvailable(siteId);
}

View File

@ -13,7 +13,6 @@
// limitations under the License.
import { Injectable } from '@angular/core';
import { Params } from '@angular/router';
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
@ -43,7 +42,7 @@ export class CoreUserProfileLinkHandlerService extends CoreContentLinksHandlerBa
getActions(
siteIds: string[],
url: string,
params: Params,
params: Record<string, string>,
courseId?: number, // eslint-disable-line @typescript-eslint/no-unused-vars
data?: unknown, // eslint-disable-line @typescript-eslint/no-unused-vars
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
@ -70,7 +69,7 @@ export class CoreUserProfileLinkHandlerService extends CoreContentLinksHandlerBa
* @return Whether the handler is enabled for the URL and site.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
isEnabled(siteId: string, url: string, params: Params, courseId?: number): boolean | Promise<boolean> {
async isEnabled(siteId: string, url: string, params: Record<string, string>, courseId?: number): Promise<boolean> {
return url.indexOf('/grade/report/') == -1;
}

View File

@ -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>;
}

View File

@ -29,6 +29,7 @@ import { CoreTextUtils } from '@services/utils/text';
import { makeSingleton, NavController, Router } from '@singletons';
import { CoreScreen } from './screen';
import { filter } from 'rxjs/operators';
import { CoreApp } from './app';
const DEFAULT_MAIN_MENU_TAB = CoreMainMenuHomeHandlerService.PAGE_NAME;
@ -255,10 +256,19 @@ export class CoreNavigatorService {
value = params[name];
}
const storedParam = this.storedParams[value];
let storedParam = this.storedParams[value];
// Remove the parameter from our map if it's in there.
delete this.storedParams[value];
if (!CoreApp.instance.isMobile() && !storedParam) {
// Try to retrieve the param from local storage in browser.
const storageParam = localStorage.getItem(value);
if (storageParam) {
storedParam = CoreTextUtils.instance.parseJSON(storageParam);
}
}
return <T> storedParam ?? value;
}
@ -368,6 +378,11 @@ export class CoreNavigatorService {
const id = this.getNewParamId();
this.storedParams[id] = value;
queryParams[name] = id;
if (!CoreApp.instance.isMobile()) {
// In browser, save the param in local storage to be able to retrieve it if the app is refreshed.
localStorage.setItem(id, JSON.stringify(value));
}
}
}

View File

@ -1,3 +1,5 @@
@import "./globals.mixins.ionic.scss";
// Common styles.
.text-left { text-align: left; }
.text-right { text-align: right; }
@ -31,6 +33,16 @@ ion-item.ion-text-wrap ion-label {
white-space: normal !important;
}
// It fixes the click on links where ion-ripple-effect is present.
.ion-activatable ion-label,
.item-multiple-items ion-label {
z-index: 3;
pointer-events: none;
ion-anchor, ion-button, a, button {
pointer-events: visible;
}
}
// Ionic toolbar.
ion-toolbar ion-back-button,
@ -139,6 +151,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;
@ -351,6 +382,10 @@ ion-toolbar ion-title .core-bar-button-image img {
}
// Select.
ion-select::part(text) {
white-space: normal;
}
ion-select.core-button-select,
.core-button-select {
--background: var(--core-button-select-background);

View File

@ -17,6 +17,8 @@
--white: #{$white};
--blue: #{$blue};
--blue-dark: #{$blue-dark};
--blue-light: #{$blue-light};
--turquoise: #{$turquoise};
--green: #{$green};
--red: #{$red};
@ -156,7 +158,7 @@
--core-tab-color-active: var(--custom-tab-color-active, var(--core-color));
--core-tab-border-color-active: var(--custom-tab-border-color-active, var(--core-color));
core-tabs {
core-tabs, core-tabs-outlet {
--background: var(--core-tabs-background);
ion-slide {
--background: var(--core-tab-background);
@ -185,6 +187,7 @@
ion-item-divider {
--background: var(--gray-lighter);
--color: inherit;
}
--core-button-select-background: var(--custom-button-select-background, var(--ion-color-primary-contrast));