commit
6f54e0eb06
|
@ -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 {}
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {}
|
|
@ -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>
|
|
@ -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;
|
||||
};
|
|
@ -0,0 +1,49 @@
|
|||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>{{ pageInstance?.lesson?.name }}</ion-title>
|
||||
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
|
||||
<core-icon slot="icon-only" name="fas-times"></core-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content class="addon-mod_lesson-menu-modal">
|
||||
<nav>
|
||||
<ion-list *ngIf="pageInstance">
|
||||
<!-- Media file. -->
|
||||
<ng-container *ngIf="pageInstance.mediaFile">
|
||||
<ion-item-divider>
|
||||
<ion-label><h2>{{ 'addon.mod_lesson.linkedmedia' | translate }}</h2></ion-label>
|
||||
</ion-item-divider>
|
||||
<core-file [file]="pageInstance.mediaFile" [component]="pageInstance.component"
|
||||
[componentId]="pageInstance.lesson?.coursemodule">
|
||||
</core-file>
|
||||
</ng-container>
|
||||
|
||||
<!-- Lesson menu. -->
|
||||
<ng-container *ngIf="pageInstance.displayMenu">
|
||||
<ion-item-divider>
|
||||
<ion-label><h2>{{ 'addon.mod_lesson.lessonmenu' | translate }}</h2></ion-label>
|
||||
</ion-item-divider>
|
||||
<ion-item class="ion-text-center" *ngIf="pageInstance.loadingMenu">
|
||||
<ion-label><ion-spinner></ion-spinner></ion-label>
|
||||
</ion-item>
|
||||
<div *ngIf="!pageInstance.loadingMenu">
|
||||
<ng-container *ngFor="let page of pageInstance.lessonPages">
|
||||
<ion-item class="ion-text-wrap" *ngIf="page.display && page.displayinmenublock" (click)="loadPage(page.id)"
|
||||
[ngClass]='{"core-selected-item": !pageInstance.eolData && pageInstance.currentPage == page.id}'
|
||||
button detail="true">
|
||||
<ion-label>
|
||||
<core-format-text [text]="page.title" contextLevel="module" [courseId]="pageInstance.courseId"
|
||||
[contextInstanceId]="pageInstance.lesson?.coursemodule">
|
||||
</core-format-text>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ion-list>
|
||||
</nav>
|
||||
</ion-content>
|
|
@ -0,0 +1,55 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Component, Input } from '@angular/core';
|
||||
|
||||
import { ModalController } from '@singletons';
|
||||
import { AddonModLessonPlayerPage } from '../../pages/player/player';
|
||||
|
||||
/**
|
||||
* Modal that renders the lesson menu and media file.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'page-addon-mod-lesson-menu-modal',
|
||||
templateUrl: 'menu-modal.html',
|
||||
})
|
||||
export class AddonModLessonMenuModalPage {
|
||||
|
||||
/**
|
||||
* The instance of the page that opened the modal. We use the instance instead of the needed attributes for these reasons:
|
||||
* - We want the user to be able to see the media file while the menu is being loaded, so we need to be able to update
|
||||
* the menu dynamically based on the data retrieved by the page that opened the modal.
|
||||
* - The onDidDismiss function takes a while to be called, making the app seem slow. This way we can directly call
|
||||
* the functions we need without having to wait for the modal to be dismissed.
|
||||
*/
|
||||
@Input() pageInstance?: AddonModLessonPlayerPage;
|
||||
|
||||
/**
|
||||
* Close modal.
|
||||
*/
|
||||
closeModal(): void {
|
||||
ModalController.instance.dismiss();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a certain page.
|
||||
*
|
||||
* @param pageId The page ID to load.
|
||||
*/
|
||||
loadPage(pageId: number): void {
|
||||
this.pageInstance?.changePage(pageId);
|
||||
this.closeModal();
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
|
@ -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}}"
|
||||
}
|
|
@ -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 {}
|
|
@ -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 {}
|
|
@ -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>
|
|
@ -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 {}
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1,51 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { IonicModule } from '@ionic/angular';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
import { CoreSharedModule } from '@/core/shared.module';
|
||||
import { AddonModLessonPlayerPage } from './player';
|
||||
import { CoreEditorComponentsModule } from '@features/editor/components/components.module';
|
||||
import { CanLeaveGuard } from '@guards/can-leave';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AddonModLessonPlayerPage,
|
||||
canDeactivate: [CanLeaveGuard],
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild(routes),
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
TranslateModule.forChild(),
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
CoreSharedModule,
|
||||
CoreEditorComponentsModule,
|
||||
],
|
||||
declarations: [
|
||||
AddonModLessonPlayerPage,
|
||||
],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AddonModLessonPlayerPageModule {}
|
|
@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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>
|
|
@ -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 {}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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) {}
|
|
@ -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) {}
|
|
@ -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) {}
|
|
@ -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) {}
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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) {}
|
|
@ -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) {}
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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
|
@ -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 { }
|
|
@ -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.
|
||||
};
|
|
@ -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 {}
|
||||
|
|
|
@ -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>
|
|
@ -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.
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
};
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
<ion-item lines="none" class="core-timer" role="timer"
|
||||
[ngClass]="{'ion-text-center': align == 'center', 'ion-text-end': align == 'right'}">
|
||||
<ion-icon name="fas-clock" slot="start" role="presentation"></ion-icon>
|
||||
<ion-label>
|
||||
<span *ngIf="timeLeft && timeLeft > 0 && timerText" class="core-timer-text">{{ timerText }}</span>
|
||||
<span *ngIf="timeLeft && timeLeft > 0" class="core-timer-time-left">{{ timeLeft | coreSecondsToHMS }}</span>
|
||||
<span class="core-timesup" *ngIf="timeLeft !== undefined && timeLeft <= 0">
|
||||
{{ 'core.timesup' | translate }}
|
||||
</span>
|
||||
</ion-label>
|
||||
</ion-item>
|
|
@ -0,0 +1,29 @@
|
|||
$core-timer-warn-color: #cb3d4d !default;
|
||||
$core-timer-iterations: 15 !default;
|
||||
|
||||
:host {
|
||||
.core-timer {
|
||||
--background: transparent !important;
|
||||
|
||||
.core-timer-time-left, .core-timesup {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
span {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
// Create the timer warning colors.
|
||||
@for $i from 0 through $core-timer-iterations {
|
||||
&.core-timer-timeleft-#{$i} {
|
||||
background-color: rgba($core-timer-warn-color, 1 - ($i / $core-timer-iterations)) !important;
|
||||
|
||||
@if $i <= $core-timer-iterations / 2 {
|
||||
label, span, ion-icon {
|
||||
color: var(--white);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, ElementRef } from '@angular/core';
|
||||
|
||||
import { CoreTimeUtils } from '@services/utils/time';
|
||||
|
||||
/**
|
||||
* This directive shows a timer in format HH:MM:SS. When the countdown reaches 0, a function is called.
|
||||
*
|
||||
* Usage:
|
||||
* <core-timer [endTime]="endTime" (finished)="timeUp()" [timerText]="'addon.mod_quiz.timeleft' | translate"></core-timer>
|
||||
*/
|
||||
@Component({
|
||||
selector: 'core-timer',
|
||||
templateUrl: 'core-timer.html',
|
||||
styleUrls: ['timer.scss'],
|
||||
})
|
||||
export class CoreTimerComponent implements OnInit, OnDestroy {
|
||||
|
||||
@Input() endTime?: string | number; // Timestamp (in seconds) when the timer should end.
|
||||
@Input() timerText?: string; // Text to show next to the timer. If not defined, no text shown.
|
||||
@Input() timeLeftClass?: string; // Name of the class to apply with each second. By default, 'core-timer-timeleft-'.
|
||||
@Input() align?: string; // Where to align the time and text. Defaults to 'left'. Other values: 'center', 'right'.
|
||||
@Output() finished = new EventEmitter<void>(); // Will emit an event when the timer reaches 0.
|
||||
|
||||
timeLeft?: number; // Seconds left to end.
|
||||
|
||||
protected timeInterval?: number;
|
||||
protected element?: HTMLElement;
|
||||
|
||||
constructor(
|
||||
protected elementRef: ElementRef,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Component being initialized.
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
const timeLeftClass = this.timeLeftClass || 'core-timer-timeleft-';
|
||||
const endTime = Math.round(Number(this.endTime));
|
||||
const container: HTMLElement | undefined = this.elementRef.nativeElement.querySelector('.core-timer');
|
||||
|
||||
if (!endTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check time left every 200ms.
|
||||
this.timeInterval = window.setInterval(() => {
|
||||
this.timeLeft = endTime - CoreTimeUtils.instance.timestamp();
|
||||
|
||||
if (this.timeLeft < 0) {
|
||||
// Time is up! Stop the timer and call the finish function.
|
||||
clearInterval(this.timeInterval);
|
||||
this.finished.emit();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If the time has nearly expired, change the color.
|
||||
if (this.timeLeft < 100 && container && !container.classList.contains(timeLeftClass + this.timeLeft)) {
|
||||
// Time left has changed. Remove previous classes and add the new one.
|
||||
container.classList.remove(timeLeftClass + (this.timeLeft + 1));
|
||||
container.classList.remove(timeLeftClass + (this.timeLeft + 2));
|
||||
container.classList.add(timeLeftClass + this.timeLeft);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component destroyed.
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
clearInterval(this.timeInterval);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
}];
|
||||
*/
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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>;
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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));
|
||||
|
|
Loading…
Reference in New Issue