commit
6f54e0eb06
|
@ -23,6 +23,7 @@ import { AddonCalendarModule } from './calendar/calendar.module';
|
||||||
import { AddonNotificationsModule } from './notifications/notifications.module';
|
import { AddonNotificationsModule } from './notifications/notifications.module';
|
||||||
import { AddonMessageOutputModule } from './messageoutput/messageoutput.module';
|
import { AddonMessageOutputModule } from './messageoutput/messageoutput.module';
|
||||||
import { AddonMessagesModule } from './messages/messages.module';
|
import { AddonMessagesModule } from './messages/messages.module';
|
||||||
|
import { AddonModModule } from './mod/mod.module';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
|
@ -35,6 +36,7 @@ import { AddonMessagesModule } from './messages/messages.module';
|
||||||
AddonUserProfileFieldModule,
|
AddonUserProfileFieldModule,
|
||||||
AddonNotificationsModule,
|
AddonNotificationsModule,
|
||||||
AddonMessageOutputModule,
|
AddonMessageOutputModule,
|
||||||
|
AddonModModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AddonsModule {}
|
export class AddonsModule {}
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Params } from '@angular/router';
|
|
||||||
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
|
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
|
||||||
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
|
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
|
||||||
import { CoreNavigator } from '@services/navigator';
|
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.
|
* @param courseId Course ID related to the URL. Optional but recommended.
|
||||||
* @return List of (or promise resolved with list of) actions.
|
* @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 [{
|
return [{
|
||||||
action: (siteId: string): void => {
|
action: (siteId: string): void => {
|
||||||
|
|
|
@ -49,7 +49,7 @@ export class AddonBadgesMyBadgesLinkHandlerService extends CoreContentLinksHandl
|
||||||
* @param siteId The site ID.
|
* @param siteId The site ID.
|
||||||
* @return Whether the handler is enabled for the URL and site.
|
* @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);
|
return AddonBadges.instance.isPluginEnabled(siteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,20 +1,18 @@
|
||||||
<ion-list>
|
<ion-list>
|
||||||
<ion-radio-group>
|
<ion-item *ngFor="let type of types" class="addon-calendar-event" [ngClass]="['addon-calendar-eventtype-'+type]">
|
||||||
<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-icon [name]="typeIcons[type]" slot="start"></ion-icon>
|
<ion-label>{{ 'addon.calendar.' + type + 'events' | translate}}</ion-label>
|
||||||
<ion-label>{{ 'addon.calendar.' + type + 'events' | translate}}</ion-label>
|
<ion-toggle [(ngModel)]="filter[type]" (ionChange)="onChange()" slot="end"></ion-toggle>
|
||||||
<ion-toggle [(ngModel)]="filter[type]" (ionChange)="onChange()" slot="end"></ion-toggle>
|
</ion-item>
|
||||||
</ion-item>
|
<ion-item-divider *ngIf="filter.course || filter.category || filter.group">
|
||||||
<ion-item-divider *ngIf="filter.course || filter.category || filter.group">
|
<ion-label></ion-label>
|
||||||
<ion-label></ion-label>
|
</ion-item-divider>
|
||||||
</ion-item-divider>
|
<ng-container *ngIf="filter.course || filter.category || filter.group">
|
||||||
<ion-list *ngIf="filter.course || filter.category || filter.group">
|
<ion-radio-group [(ngModel)]="courseId" (ionChange)="onChange()">
|
||||||
<ion-radio-group [(ngModel)]="courseId" (ionChange)="onChange()">
|
<ion-item class="ion-text-wrap" *ngFor="let course of courses">
|
||||||
<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-label><core-format-text [text]="course.fullname"></core-format-text></ion-label>
|
<ion-radio slot="end" value="{{course.id}}"></ion-radio>
|
||||||
<ion-radio slot="start" value="{{course.id}}"></ion-radio>
|
</ion-item>
|
||||||
</ion-item>
|
</ion-radio-group>
|
||||||
</ion-radio-group>
|
</ng-container>
|
||||||
</ion-list>
|
|
||||||
</ion-radio-group>
|
|
||||||
</ion-list>
|
</ion-list>
|
||||||
|
|
|
@ -157,18 +157,18 @@
|
||||||
</ion-label>
|
</ion-label>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
<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-label>{{ 'addon.calendar.durationnone' | translate }}</ion-label>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
<ion-item (click)="selectDuration('1')">
|
<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-label>{{ 'addon.calendar.durationuntil' | translate }}</ion-label>
|
||||||
<ion-datetime formControlName="timedurationuntil"
|
<ion-datetime formControlName="timedurationuntil"
|
||||||
[placeholder]="'addon.calendar.durationuntil' | translate"
|
[placeholder]="'addon.calendar.durationuntil' | translate"
|
||||||
[displayFormat]="dateFormat" [disabled]="form.controls.duration.value != 1"></ion-datetime>
|
[displayFormat]="dateFormat" [disabled]="form.controls.duration.value != 1"></ion-datetime>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
<ion-item (click)="selectDuration('2')">
|
<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-label>{{ 'addon.calendar.durationminutes' | translate }}</ion-label>
|
||||||
<ion-input type="number" name="timedurationminutes" slot="end"
|
<ion-input type="number" name="timedurationminutes" slot="end"
|
||||||
[placeholder]="'addon.calendar.durationminutes' | translate"
|
[placeholder]="'addon.calendar.durationminutes' | translate"
|
||||||
|
@ -203,11 +203,11 @@
|
||||||
</ion-item>
|
</ion-item>
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-label>{{ 'addon.calendar.repeateditall' | translate:{$a: otherEventsCount} }}</ion-label>
|
<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-item>
|
<ion-item>
|
||||||
<ion-label>{{ 'addon.calendar.repeateditthis' | translate }}</ion-label>
|
<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-item>
|
||||||
</ion-radio-group>
|
</ion-radio-group>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
|
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Params } from '@angular/router';
|
import { Params } from '@angular/router';
|
||||||
|
|
||||||
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
|
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
|
||||||
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
|
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
|
||||||
import { CoreNavigator } from '@services/navigator';
|
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}
|
* @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.
|
* @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 [{
|
return [{
|
||||||
action: (siteId?: string): void => {
|
action: (siteId?: string): void => {
|
||||||
if (!params.view || params.view == 'month' || params.view == 'mini' || params.view == 'minithree') {
|
if (!params.view || params.view == 'month' || params.view == 'mini' || params.view == 'minithree') {
|
||||||
|
@ -47,7 +52,7 @@ export class AddonCalendarViewLinkHandlerService extends CoreContentLinksHandler
|
||||||
const stateParams: Params = {
|
const stateParams: Params = {
|
||||||
courseId: params.course,
|
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);
|
const date = new Date(timestamp);
|
||||||
stateParams.year = date.getFullYear();
|
stateParams.year = date.getFullYear();
|
||||||
|
@ -61,7 +66,7 @@ export class AddonCalendarViewLinkHandlerService extends CoreContentLinksHandler
|
||||||
const stateParams: Params = {
|
const stateParams: Params = {
|
||||||
courseId: params.course,
|
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);
|
const date = new Date(timestamp);
|
||||||
stateParams.year = date.getFullYear();
|
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}
|
* @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.
|
* @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) {
|
if (params.view && SUPPORTED_VIEWS.indexOf(params.view) == -1) {
|
||||||
// This type of view isn't supported in the app.
|
// This type of view isn't supported in the app.
|
||||||
return false;
|
return false;
|
||||||
|
|
|
@ -16,77 +16,74 @@
|
||||||
</ion-header>
|
</ion-header>
|
||||||
<ion-content>
|
<ion-content>
|
||||||
<core-split-view>
|
<core-split-view>
|
||||||
<ion-tab-bar class="core-tabs-bar">
|
<core-tabs [hideUntil]="true">
|
||||||
<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'">
|
|
||||||
<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-label>
|
|
||||||
<h2>
|
|
||||||
<core-format-text [text]="contact.fullname" contextLevel="system" [contextInstanceId]="0">
|
|
||||||
</core-format-text>
|
|
||||||
<ion-icon *ngIf="contact.isblocked" name="fas-user-slash" slot="end">
|
|
||||||
</ion-icon>
|
|
||||||
</h2>
|
|
||||||
</ion-label>
|
|
||||||
</ion-item>
|
|
||||||
</ion-list>
|
|
||||||
|
|
||||||
<core-empty-box *ngIf="!confirmedContacts.length" icon="far-address-book"
|
<!-- Contacts tab. -->
|
||||||
[message]="'addon.messages.nocontactsgetstarted' | translate">
|
<core-tab [title]="'addon.messages.contacts' | translate" (ionSelect)="selectTab('confirmed')">
|
||||||
</core-empty-box>
|
<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" 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">
|
||||||
|
</core-format-text>
|
||||||
|
<ion-icon *ngIf="contact.isblocked" name="fas-user-slash" slot="end">
|
||||||
|
</ion-icon>
|
||||||
|
</h2>
|
||||||
|
</ion-label>
|
||||||
|
</ion-item>
|
||||||
|
</ion-list>
|
||||||
|
|
||||||
<core-infinite-loading [enabled]="confirmedCanLoadMore" (action)="loadMore($event)" [error]="confirmedLoadMoreError"
|
<core-empty-box *ngIf="!confirmedContacts.length" icon="far-address-book"
|
||||||
position="bottom">
|
[message]="'addon.messages.nocontactsgetstarted' | translate">
|
||||||
</core-infinite-loading>
|
</core-empty-box>
|
||||||
</core-loading>
|
|
||||||
</div>
|
|
||||||
<div *ngIf="selected != 'confirmed'">
|
|
||||||
<ion-refresher slot="fixed" [disabled]="!requestsLoaded" (ionRefresh)="refreshData($event)">
|
|
||||||
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
|
|
||||||
</ion-refresher>
|
|
||||||
<core-loading [hideUntil]="requestsLoaded" class="core-loading-center">
|
|
||||||
<ion-list class="ion-no-margin">
|
|
||||||
<ion-item class="ion-text-wrap addon-messages-conversation-item" *ngFor="let request of requests"
|
|
||||||
[title]="request.fullname" (click)="selectUser(request.id)"
|
|
||||||
[class.core-selected-item]="request.id == selectedUserId" detail>
|
|
||||||
<core-user-avatar slot="start" [user]="request" [linkProfile]="false"></core-user-avatar>
|
|
||||||
<ion-label>
|
|
||||||
<core-format-text [text]="request.fullname" contextLevel="system" [contextInstanceId]="0">
|
|
||||||
</core-format-text>
|
|
||||||
<p *ngIf="!request.iscontact">
|
|
||||||
{{ 'addon.messages.wouldliketocontactyou' | translate }}
|
|
||||||
</p>
|
|
||||||
</ion-label>
|
|
||||||
</ion-item>
|
|
||||||
</ion-list>
|
|
||||||
<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>
|
|
||||||
</core-loading>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<core-infinite-loading [enabled]="confirmedCanLoadMore" (action)="loadMore($event)"
|
||||||
|
[error]="confirmedLoadMoreError" position="bottom">
|
||||||
|
</core-infinite-loading>
|
||||||
|
</core-loading>
|
||||||
|
</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>
|
||||||
|
<core-loading [hideUntil]="requestsLoaded" class="core-loading-center">
|
||||||
|
<ion-list class="ion-no-margin">
|
||||||
|
<ion-item class="ion-text-wrap addon-messages-conversation-item" *ngFor="let request of requests"
|
||||||
|
[title]="request.fullname" (click)="selectUser(request.id)"
|
||||||
|
[class.core-selected-item]="request.id == selectedUserId" detail>
|
||||||
|
<core-user-avatar slot="start" [user]="request" [linkProfile]="false"></core-user-avatar>
|
||||||
|
<ion-label>
|
||||||
|
<core-format-text [text]="request.fullname" contextLevel="system" [contextInstanceId]="0">
|
||||||
|
</core-format-text>
|
||||||
|
<p *ngIf="!request.iscontact">
|
||||||
|
{{ 'addon.messages.wouldliketocontactyou' | translate }}
|
||||||
|
</p>
|
||||||
|
</ion-label>
|
||||||
|
</ion-item>
|
||||||
|
</ion-list>
|
||||||
|
<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>
|
||||||
|
</core-loading>
|
||||||
|
</ng-template>
|
||||||
|
</core-tab>
|
||||||
|
</core-tabs>
|
||||||
</core-split-view>
|
</core-split-view>
|
||||||
</ion-content>
|
</ion-content>
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Params } from '@angular/router';
|
|
||||||
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
|
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
|
||||||
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
|
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
|
||||||
import { CoreNavigator } from '@services/navigator';
|
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}
|
* @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.
|
* @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 [{
|
return [{
|
||||||
action: (siteId): void => {
|
action: (siteId): void => {
|
||||||
const stateParams = {
|
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}
|
* @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.
|
* @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);
|
const enabled = await AddonMessages.instance.isPluginEnabled(siteId);
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
return false;
|
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 { CoreSplitViewComponent } from './split-view/split-view';
|
||||||
import { CoreEmptyBoxComponent } from './empty-box/empty-box';
|
import { CoreEmptyBoxComponent } from './empty-box/empty-box';
|
||||||
import { CoreTabsComponent } from './tabs/tabs';
|
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 { CoreInfiniteLoadingComponent } from './infinite-loading/infinite-loading';
|
||||||
import { CoreProgressBarComponent } from './progress-bar/progress-bar';
|
import { CoreProgressBarComponent } from './progress-bar/progress-bar';
|
||||||
import { CoreContextMenuComponent } from './context-menu/context-menu';
|
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 { CoreDynamicComponent } from './dynamic-component/dynamic-component';
|
||||||
import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons';
|
import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons';
|
||||||
import { CoreSendMessageFormComponent } from './send-message-form/send-message-form';
|
import { CoreSendMessageFormComponent } from './send-message-form/send-message-form';
|
||||||
|
import { CoreTimerComponent } from './timer/timer';
|
||||||
|
|
||||||
import { CoreDirectivesModule } from '@directives/directives.module';
|
import { CoreDirectivesModule } from '@directives/directives.module';
|
||||||
import { CorePipesModule } from '@pipes/pipes.module';
|
import { CorePipesModule } from '@pipes/pipes.module';
|
||||||
|
@ -61,6 +64,8 @@ import { CorePipesModule } from '@pipes/pipes.module';
|
||||||
CoreSplitViewComponent,
|
CoreSplitViewComponent,
|
||||||
CoreEmptyBoxComponent,
|
CoreEmptyBoxComponent,
|
||||||
CoreTabsComponent,
|
CoreTabsComponent,
|
||||||
|
CoreTabComponent,
|
||||||
|
CoreTabsOutletComponent,
|
||||||
CoreInfiniteLoadingComponent,
|
CoreInfiniteLoadingComponent,
|
||||||
CoreProgressBarComponent,
|
CoreProgressBarComponent,
|
||||||
CoreContextMenuComponent,
|
CoreContextMenuComponent,
|
||||||
|
@ -70,6 +75,7 @@ import { CorePipesModule } from '@pipes/pipes.module';
|
||||||
CoreUserAvatarComponent,
|
CoreUserAvatarComponent,
|
||||||
CoreDynamicComponent,
|
CoreDynamicComponent,
|
||||||
CoreSendMessageFormComponent,
|
CoreSendMessageFormComponent,
|
||||||
|
CoreTimerComponent,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
@ -94,6 +100,8 @@ import { CorePipesModule } from '@pipes/pipes.module';
|
||||||
CoreSplitViewComponent,
|
CoreSplitViewComponent,
|
||||||
CoreEmptyBoxComponent,
|
CoreEmptyBoxComponent,
|
||||||
CoreTabsComponent,
|
CoreTabsComponent,
|
||||||
|
CoreTabComponent,
|
||||||
|
CoreTabsOutletComponent,
|
||||||
CoreInfiniteLoadingComponent,
|
CoreInfiniteLoadingComponent,
|
||||||
CoreProgressBarComponent,
|
CoreProgressBarComponent,
|
||||||
CoreContextMenuComponent,
|
CoreContextMenuComponent,
|
||||||
|
@ -103,6 +111,7 @@ import { CorePipesModule } from '@pipes/pipes.module';
|
||||||
CoreUserAvatarComponent,
|
CoreUserAvatarComponent,
|
||||||
CoreDynamicComponent,
|
CoreDynamicComponent,
|
||||||
CoreSendMessageFormComponent,
|
CoreSendMessageFormComponent,
|
||||||
|
CoreTimerComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CoreComponentsModule {}
|
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,31 +1,28 @@
|
||||||
<ion-tabs class="hide-header">
|
<ion-tab-bar slot="top" class="core-tabs-bar" [hidden]="!tabs || numTabsShown <= 1" #tabBar>
|
||||||
<ion-tab-bar slot="top" class="core-tabs-bar" [hidden]="!tabs || numTabsShown <= 1">
|
<ion-spinner *ngIf="!hideUntil"></ion-spinner>
|
||||||
<ion-spinner *ngIf="!hideUntil"></ion-spinner>
|
<ion-row *ngIf="hideUntil">
|
||||||
<ion-row *ngIf="hideUntil">
|
<ion-col class="col-with-arrow ion-no-padding" (click)="slidePrev()" size="1">
|
||||||
<ion-col class="col-with-arrow ion-no-padding" (click)="slidePrev()" size="1">
|
<ion-icon *ngIf="showPrevButton" name="fas-chevron-left"></ion-icon>
|
||||||
<ion-icon *ngIf="showPrevButton" name="fas-chevron-left"></ion-icon>
|
</ion-col>
|
||||||
</ion-col>
|
<ion-col class="ion-no-padding" size="10">
|
||||||
<ion-col class="ion-no-padding" size="10">
|
<ion-slides (ionSlideDidChange)="slideChanged()" [options]="slidesOpts" [dir]="direction" role="tablist"
|
||||||
<ion-slides (ionSlideDidChange)="slideChanged()" [options]="slidesOpts" [dir]="direction" role="tablist"
|
[attr.aria-label]="description" aria-hidden="false">
|
||||||
[attr.aria-label]="description" aria-hidden="false">
|
<ng-container *ngFor="let tab of tabs">
|
||||||
<ng-container *ngFor="let tab of tabs">
|
<ion-slide [hidden]="!hideUntil" [attr.aria-selected]="selected == tab.id" class="tab-slide {{tab.class}}"
|
||||||
<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'"
|
||||||
[attr.aria-label]="tab.title | translate" role="tab" [attr.aria-controls]="tab.id" [id]="tab.id + '-tab'"
|
[tabindex]="selected == tab.id ? null : -1" (click)="selectTab(tab.id, $event)">
|
||||||
[tabindex]="selected == tab.id ? null : -1">
|
<ion-icon *ngIf="tab.icon" [name]="tab.icon"></ion-icon>
|
||||||
|
<ion-label>{{ tab.title | translate}}</ion-label>
|
||||||
<ion-tab-button (ionTabButtonClick)="selectTab(tab.id, $event)" [tab]="tab.page" [layout]="layout"
|
<ion-badge *ngIf="tab.badge">{{ tab.badge }}</ion-badge>
|
||||||
class="{{tab.class}}">
|
</ion-slide>
|
||||||
<ion-icon *ngIf="tab.icon" [name]="tab.icon"></ion-icon>
|
</ng-container>
|
||||||
<ion-label>{{ tab.title | translate}}</ion-label>
|
</ion-slides>
|
||||||
<ion-badge *ngIf="tab.badge">{{ tab.badge }}</ion-badge>
|
</ion-col>
|
||||||
</ion-tab-button>
|
<ion-col class="col-with-arrow ion-no-padding" (click)="slideNext()" size="1">
|
||||||
</ion-slide>
|
<ion-icon *ngIf="showNextButton" name="fas-chevron-right"></ion-icon>
|
||||||
</ng-container>
|
</ion-col>
|
||||||
</ion-slides>
|
</ion-row>
|
||||||
</ion-col>
|
</ion-tab-bar>
|
||||||
<ion-col class="col-with-arrow ion-no-padding" (click)="slideNext()" size="1">
|
<div class="core-tabs-content-container" #originalTabs>
|
||||||
<ion-icon *ngIf="showNextButton" name="fas-chevron-right"></ion-icon>
|
<ng-content></ng-content>
|
||||||
</ion-col>
|
</div>
|
||||||
</ion-row>
|
|
||||||
</ion-tab-bar>
|
|
||||||
</ion-tabs>
|
|
||||||
|
|
|
@ -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;
|
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 {
|
import {
|
||||||
Component,
|
Component,
|
||||||
Input,
|
Input,
|
||||||
Output,
|
|
||||||
EventEmitter,
|
|
||||||
OnInit,
|
|
||||||
OnChanges,
|
|
||||||
OnDestroy,
|
|
||||||
AfterViewInit,
|
AfterViewInit,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Platform, IonSlides, IonTabs } from '@ionic/angular';
|
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { CoreTabsBaseComponent } from '@classes/tabs';
|
||||||
import { Subscription } from 'rxjs';
|
import { CoreTabComponent } from './tab';
|
||||||
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';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component displays some top scrollable tabs that will autohide on vertical scroll.
|
* 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:
|
* Example usage:
|
||||||
*
|
*
|
||||||
* <core-tabs selectedIndex="1" [tabs]="tabs"></core-tabs>
|
* <core-tabs selectedIndex="1">
|
||||||
*
|
* <core-tab [title]="'core.courses.timeline' | translate" (ionSelect)="switchTab('timeline')">
|
||||||
* Tab contents will only be shown if that tab is selected.
|
* <ng-template> <!-- This ng-template is required, @see CoreTabComponent. -->
|
||||||
*
|
* <!-- Tab contents. -->
|
||||||
* @todo: Test behaviour when tabs are added late.
|
* </ng-template>
|
||||||
* @todo: Test RTL and tab history.
|
* </core-tab>
|
||||||
|
* </core-tabs>
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'core-tabs',
|
selector: 'core-tabs',
|
||||||
templateUrl: 'core-tabs.html',
|
templateUrl: 'core-tabs.html',
|
||||||
styleUrls: ['tabs.scss'],
|
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.
|
@ViewChild('originalTabs') originalTabsRef?: ElementRef;
|
||||||
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.
|
protected originalTabsContainer?: HTMLElement; // The container of the original tabs. It will include each tab's content.
|
||||||
@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;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected element: ElementRef,
|
element: ElementRef,
|
||||||
platform: Platform,
|
|
||||||
translate: TranslateService,
|
|
||||||
) {
|
) {
|
||||||
this.direction = platform.isRTL ? 'rtl' : 'ltr';
|
super(element);
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* View has been initialized.
|
* View has been initialized.
|
||||||
*/
|
*/
|
||||||
async ngAfterViewInit(): Promise<void> {
|
async ngAfterViewInit(): Promise<void> {
|
||||||
|
super.ngAfterViewInit();
|
||||||
|
|
||||||
if (this.isDestroyed) {
|
if (this.isDestroyed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.stackEventsSubscription = this.ionTabs!.outlet.stackEvents.subscribe(async (stackEvent: StackEvent) => {
|
this.tabsElement = this.element.nativeElement;
|
||||||
if (this.isCurrentView) {
|
this.originalTabsContainer = this.originalTabsRef?.nativeElement;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the tabs, determining the first tab to be shown.
|
* Initialize the tabs, determining the first tab to be shown.
|
||||||
*/
|
*/
|
||||||
protected async initializeTabs(): Promise<void> {
|
protected async initializeTabs(): Promise<void> {
|
||||||
let selectedTab: CoreTab | undefined = this.tabs[this.selectedIndex || 0] || undefined;
|
await super.initializeTabs();
|
||||||
|
|
||||||
if (!selectedTab || !selectedTab.enabled) {
|
// @todo: Is this still needed?
|
||||||
// The tab is not enabled or not shown. Get the first tab that is enabled.
|
// if (this.content) {
|
||||||
selectedTab = this.tabs.find((tab) => tab.enabled) || undefined;
|
// 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');
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.calculateSlides();
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!selectedTab) {
|
/**
|
||||||
return;
|
* 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);
|
||||||
|
|
||||||
this.firstSelectedTab = selectedTab.id!;
|
|
||||||
this.selectTab(this.firstSelectedTab);
|
|
||||||
|
|
||||||
// Setup tab scrolling.
|
|
||||||
this.calculateTabBarHeight();
|
|
||||||
|
|
||||||
this.initialized = true;
|
|
||||||
|
|
||||||
// Check which arrows should be shown.
|
|
||||||
this.calculateSlides();
|
this.calculateSlides();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Method executed when the slides are changed.
|
* Load the tab.
|
||||||
*/
|
|
||||||
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.
|
|
||||||
*
|
*
|
||||||
* @param scrollEvent Scroll event to check scroll position.
|
* @param tabToSelect Tab to load.
|
||||||
* @param content Content element to check measures.
|
* @return Promise resolved with true if tab is successfully loaded.
|
||||||
*/
|
*/
|
||||||
protected showHideTabs(scrollEvent: CustomEvent, content: HTMLElement): void {
|
protected async loadTab(tabToSelect: CoreTabComponent): Promise<boolean> {
|
||||||
if (!this.tabBarElement || !this.tabsElement || !content) {
|
const currentTab = this.getSelected();
|
||||||
return;
|
currentTab?.unselectTab();
|
||||||
}
|
tabToSelect.selectTab();
|
||||||
|
|
||||||
// Always show on very tall screens.
|
return true;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select a tab by ID.
|
* Sort the tabs, keeping the same order as in the original list.
|
||||||
*
|
|
||||||
* @param tabId Tab ID.
|
|
||||||
* @param e Event.
|
|
||||||
* @return Promise resolved when done.
|
|
||||||
*/
|
*/
|
||||||
async selectTab(tabId: string, e?: Event): Promise<void> {
|
protected sortTabs(): void {
|
||||||
const index = this.tabs.findIndex((tab) => tabId == tab.id);
|
if (!this.originalTabsContainer) {
|
||||||
|
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.selected) {
|
const newTabs: CoreTabComponent[] = [];
|
||||||
await this.slides!.slideTo(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ok = await CoreNavigator.instance.navigate(tabToSelect.page, {
|
this.tabs.forEach((tab) => {
|
||||||
params: tabToSelect.pageParams,
|
const originalIndex = Array.prototype.indexOf.call(this.originalTabsContainer?.children, tab.element);
|
||||||
});
|
if (originalIndex != -1) {
|
||||||
|
newTabs[originalIndex] = tab;
|
||||||
if (ok !== false) {
|
|
||||||
this.selectHistory.push(tabToSelect.id!);
|
|
||||||
this.selected = tabToSelect.id;
|
|
||||||
this.selectedIndex = index;
|
|
||||||
|
|
||||||
this.ionChange.emit(tabToSelect);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.tabs = newTabs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adapt tabs to a window resize.
|
* Function to call when the visibility of a tab has changed.
|
||||||
*/
|
*/
|
||||||
protected windowResized(): void {
|
tabVisibilityChanged(): void {
|
||||||
setTimeout(() => {
|
this.calculateSlides();
|
||||||
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
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { Params } from '@angular/router';
|
|
||||||
import { CoreContentLinksHandler, CoreContentLinksAction } from '../services/contentlinks-delegate';
|
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
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
url: string,
|
url: string,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
params: Params,
|
params: Record<string, string>,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
courseId?: number,
|
courseId?: number,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// 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.
|
* @return Whether the handler is enabled for the URL and site.
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,8 +16,7 @@ import { CoreContentLinksAction } from '../services/contentlinks-delegate';
|
||||||
import { CoreContentLinksHandlerBase } from './base-handler';
|
import { CoreContentLinksHandlerBase } from './base-handler';
|
||||||
import { CoreSites } from '@services/sites';
|
import { CoreSites } from '@services/sites';
|
||||||
import { CoreDomUtils } from '@services/utils/dom';
|
import { CoreDomUtils } from '@services/utils/dom';
|
||||||
// import { CoreCourseHelper } from '@features/course/services/helper';
|
import { CoreCourseHelper } from '@features/course/services/course-helper';
|
||||||
import { Params } from '@angular/router';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handler to handle URLs pointing to the grade of a module.
|
* Handler to handle URLs pointing to the grade of a module.
|
||||||
|
@ -64,21 +63,26 @@ export class CoreContentLinksModuleGradeHandler extends CoreContentLinksHandlerB
|
||||||
getActions(
|
getActions(
|
||||||
siteIds: string[],
|
siteIds: string[],
|
||||||
url: string,
|
url: string,
|
||||||
params: Params,
|
params: Record<string, string>,
|
||||||
courseId?: number,
|
courseId?: number,
|
||||||
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
|
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
|
||||||
|
|
||||||
courseId = courseId || params.courseid || params.cid;
|
courseId = Number(courseId || params.courseid || params.cid);
|
||||||
|
|
||||||
return [{
|
return [{
|
||||||
action: async (siteId): Promise<void> => {
|
action: async (siteId): Promise<void> => {
|
||||||
// Check if userid is the site's current user.
|
// Check if userid is the site's current user.
|
||||||
const modal = await CoreDomUtils.instance.showModalLoading();
|
const modal = await CoreDomUtils.instance.showModalLoading();
|
||||||
const site = await CoreSites.instance.getSite(siteId);
|
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.
|
// No user specified or current user. Navigate to module.
|
||||||
// @todo CoreCourseHelper.instance.navigateToModule(parseInt(params.id, 10), siteId, courseId, undefined,
|
CoreCourseHelper.instance.navigateToModule(
|
||||||
// this.useModNameToGetModule ? this.modName : undefined, undefined, navCtrl);
|
Number(params.id),
|
||||||
|
siteId,
|
||||||
|
courseId,
|
||||||
|
undefined,
|
||||||
|
this.useModNameToGetModule ? this.modName : undefined,
|
||||||
|
);
|
||||||
} else if (this.canReview) {
|
} else if (this.canReview) {
|
||||||
// Use the goToReview function.
|
// Use the goToReview function.
|
||||||
this.goToReview(url, params, courseId!, siteId);
|
this.goToReview(url, params, courseId!, siteId);
|
||||||
|
@ -103,7 +107,7 @@ export class CoreContentLinksModuleGradeHandler extends CoreContentLinksHandlerB
|
||||||
*/
|
*/
|
||||||
protected async goToReview(
|
protected async goToReview(
|
||||||
url: 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
|
params: Record<string, string>, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
courseId: number, // 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
|
siteId: string, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
import { CoreContentLinksHandlerBase } from './base-handler';
|
import { CoreContentLinksHandlerBase } from './base-handler';
|
||||||
import { Params } from '@angular/router';
|
import { Params } from '@angular/router';
|
||||||
import { CoreContentLinksAction } from '../services/contentlinks-delegate';
|
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.
|
* 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.
|
* @return List of params to pass to navigateToModule / navigateToModuleByInstance.
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
getPageParams(url: string, params: Params, courseId?: number): Params {
|
getPageParams(url: string, params: Record<string, string>, courseId?: number): Params {
|
||||||
return [];
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -73,34 +74,45 @@ export class CoreContentLinksModuleIndexHandler extends CoreContentLinksHandlerB
|
||||||
* @return List of (or promise resolved with list of) actions.
|
* @return List of (or promise resolved with list of) actions.
|
||||||
*/
|
*/
|
||||||
getActions(
|
getActions(
|
||||||
siteIds: string[], // eslint-disable-line @typescript-eslint/no-unused-vars
|
siteIds: string[],
|
||||||
url: string, // eslint-disable-line @typescript-eslint/no-unused-vars
|
url: string,
|
||||||
params: Params, // eslint-disable-line @typescript-eslint/no-unused-vars
|
params: Record<string, string>,
|
||||||
courseId?: number, // eslint-disable-line @typescript-eslint/no-unused-vars
|
courseId?: number,
|
||||||
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
|
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
|
||||||
return [];
|
|
||||||
/*
|
courseId = Number(courseId || params.courseid || params.cid);
|
||||||
courseId = courseId || params.courseid || params.cid;
|
|
||||||
const pageParams = this.getPageParams(url, params, courseId);
|
const pageParams = this.getPageParams(url, params, courseId);
|
||||||
|
|
||||||
if (this.instanceIdParam && typeof params[this.instanceIdParam] != 'undefined') {
|
if (this.instanceIdParam && typeof params[this.instanceIdParam] != 'undefined') {
|
||||||
const instanceId = parseInt(params[this.instanceIdParam], 10);
|
const instanceId = parseInt(params[this.instanceIdParam], 10);
|
||||||
|
|
||||||
return [{
|
return [{
|
||||||
action: (siteId): void => {
|
action: (siteId) => {
|
||||||
this.courseHelper.navigateToModuleByInstance(instanceId, this.modName, siteId, courseId, undefined,
|
CoreCourseHelper.instance.navigateToModuleByInstance(
|
||||||
this.useModNameToGetModule, pageParams);
|
instanceId,
|
||||||
|
this.modName,
|
||||||
|
siteId,
|
||||||
|
courseId,
|
||||||
|
undefined,
|
||||||
|
this.useModNameToGetModule,
|
||||||
|
pageParams,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [{
|
return [{
|
||||||
action: (siteId): void => {
|
action: (siteId) => {
|
||||||
this.courseHelper.navigateToModule(parseInt(params.id, 10), siteId, courseId, undefined,
|
CoreCourseHelper.instance.navigateToModule(
|
||||||
this.useModNameToGetModule ? this.modName : undefined, pageParams);
|
parseInt(params.id, 10),
|
||||||
|
siteId,
|
||||||
|
courseId,
|
||||||
|
undefined,
|
||||||
|
this.useModNameToGetModule ? this.modName : undefined,
|
||||||
|
pageParams,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
}];
|
}];
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
|
|
||||||
import { CoreContentLinksHandlerBase } from './base-handler';
|
import { CoreContentLinksHandlerBase } from './base-handler';
|
||||||
import { Translate } from '@singletons';
|
import { Translate } from '@singletons';
|
||||||
import { Params } from '@angular/router';
|
|
||||||
import { CoreContentLinksAction } from '../services/contentlinks-delegate';
|
import { CoreContentLinksAction } from '../services/contentlinks-delegate';
|
||||||
import { CoreNavigator } from '@services/navigator';
|
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}
|
* @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.
|
* @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 [{
|
return [{
|
||||||
action: (siteId): void => {
|
action: (siteId): void => {
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
<ion-list>
|
<ion-list>
|
||||||
<ion-item class="ion-text-wrap">
|
<ion-item class="ion-text-wrap">
|
||||||
<ion-label>
|
<ion-label>
|
||||||
<p class="item-heading">{{ 'core.contentlinks.chooseaccounttoopenlink' | translate }}</p>
|
<h3 class="item-heading">{{ 'core.contentlinks.chooseaccounttoopenlink' | translate }}</h3>
|
||||||
<p>{{ url }}</p>
|
<p>{{ url }}</p>
|
||||||
</ion-label>
|
</ion-label>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
|
|
|
@ -17,7 +17,6 @@ import { CoreLogger } from '@singletons/logger';
|
||||||
import { CoreSites } from '@services/sites';
|
import { CoreSites } from '@services/sites';
|
||||||
import { CoreUrlUtils } from '@services/utils/url';
|
import { CoreUrlUtils } from '@services/utils/url';
|
||||||
import { CoreUtils } from '@services/utils/utils';
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
import { Params } from '@angular/router';
|
|
||||||
import { makeSingleton } from '@singletons';
|
import { makeSingleton } from '@singletons';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -56,8 +55,13 @@ export interface CoreContentLinksHandler {
|
||||||
* @param data Extra data to handle the URL.
|
* @param data Extra data to handle the URL.
|
||||||
* @return List of (or promise resolved with list of) actions.
|
* @return List of (or promise resolved with list of) actions.
|
||||||
*/
|
*/
|
||||||
getActions(siteIds: string[], url: string, params: Params, courseId?: number, data?: unknown):
|
getActions(
|
||||||
CoreContentLinksAction[] | Promise<CoreContentLinksAction[]>;
|
siteIds: string[],
|
||||||
|
url: string,
|
||||||
|
params: Record<string, string>,
|
||||||
|
courseId?: number,
|
||||||
|
data?: unknown,
|
||||||
|
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a URL is handled by this handler.
|
* 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.
|
* @param courseId Course ID related to the URL. Optional but recommended.
|
||||||
* @return Whether the handler is enabled for the URL and site.
|
* @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(
|
protected async isHandlerEnabled(
|
||||||
handler: CoreContentLinksHandler,
|
handler: CoreContentLinksHandler,
|
||||||
url: string,
|
url: string,
|
||||||
params: Params,
|
params: Record<string, string>,
|
||||||
courseId: number,
|
courseId: number,
|
||||||
siteId: string,
|
siteId: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
@ -264,7 +268,7 @@ export class CoreContentLinksDelegateService {
|
||||||
return true;
|
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.
|
* @param showErrors If show errors to the user of hide them.
|
||||||
* @return Promise resolved when done.
|
* @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) {
|
if (!this.loaded || !this.module) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -95,7 +95,7 @@
|
||||||
|
|
||||||
<ion-buttons class="ion-padding core-course-section-nav-buttons safe-padding-horizontal"
|
<ion-buttons class="ion-padding core-course-section-nav-buttons safe-padding-horizontal"
|
||||||
*ngIf="displaySectionSelector && sections?.length">
|
*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 }}">
|
title="{{ 'core.previous' | translate }}">
|
||||||
<ion-icon name="fas-chevron-left" slot="icon-only"></ion-icon>
|
<ion-icon name="fas-chevron-left" slot="icon-only"></ion-icon>
|
||||||
<core-format-text class="accesshide" [text]="previousSection.name" contextLevel="course"
|
<core-format-text class="accesshide" [text]="previousSection.name" contextLevel="course"
|
||||||
|
|
|
@ -11,5 +11,5 @@
|
||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
</ion-header>
|
</ion-header>
|
||||||
<ion-content>
|
<ion-content>
|
||||||
<core-tabs [tabs]="tabs" [hideUntil]="loaded"></core-tabs>
|
<core-tabs-outlet [tabs]="tabs" [hideUntil]="loaded"></core-tabs-outlet>
|
||||||
</ion-content>
|
</ion-content>
|
|
@ -15,7 +15,7 @@
|
||||||
import { Component, ViewChild, OnDestroy, OnInit } from '@angular/core';
|
import { Component, ViewChild, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { Params } from '@angular/router';
|
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 { CoreCourseFormatDelegate } from '../../services/format-delegate';
|
||||||
import { CoreCourseOptionsDelegate } from '../../services/course-options-delegate';
|
import { CoreCourseOptionsDelegate } from '../../services/course-options-delegate';
|
||||||
import { CoreCourseAnyCourseData } from '@features/courses/services/courses';
|
import { CoreCourseAnyCourseData } from '@features/courses/services/courses';
|
||||||
|
@ -35,7 +35,7 @@ import { CoreNavigator } from '@services/navigator';
|
||||||
})
|
})
|
||||||
export class CoreCourseIndexPage implements OnInit, OnDestroy {
|
export class CoreCourseIndexPage implements OnInit, OnDestroy {
|
||||||
|
|
||||||
@ViewChild(CoreTabsComponent) tabsComponent?: CoreTabsComponent;
|
@ViewChild(CoreTabsOutletComponent) tabsComponent?: CoreTabsOutletComponent;
|
||||||
|
|
||||||
title?: string;
|
title?: string;
|
||||||
course?: CoreCourseAnyCourseData;
|
course?: CoreCourseAnyCourseData;
|
||||||
|
@ -45,7 +45,7 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy {
|
||||||
protected currentPagePath = '';
|
protected currentPagePath = '';
|
||||||
protected selectTabObserver: CoreEventObserver;
|
protected selectTabObserver: CoreEventObserver;
|
||||||
protected firstTabName?: string;
|
protected firstTabName?: string;
|
||||||
protected contentsTab: CoreTab = {
|
protected contentsTab: CoreTabsOutletTab = {
|
||||||
page: 'contents',
|
page: 'contents',
|
||||||
title: 'core.course.contents',
|
title: 'core.course.contents',
|
||||||
pageParams: {},
|
pageParams: {},
|
||||||
|
@ -183,6 +183,6 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CourseTab = CoreTab & {
|
type CourseTab = CoreTabsOutletTab & {
|
||||||
name?: string;
|
name?: string;
|
||||||
};
|
};
|
||||||
|
|
|
@ -64,6 +64,9 @@ import { CoreTimeUtils } from '@services/utils/time';
|
||||||
import { CoreEventObserver, CoreEventPackageStatusChanged, CoreEvents } from '@singletons/events';
|
import { CoreEventObserver, CoreEventPackageStatusChanged, CoreEvents } from '@singletons/events';
|
||||||
import { CoreFilterHelper } from '@features/filter/services/filter-helper';
|
import { CoreFilterHelper } from '@features/filter/services/filter-helper';
|
||||||
import { CoreNetworkError } from '@classes/errors/network-error';
|
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.
|
* Prefetch info of a module.
|
||||||
|
@ -1392,8 +1395,35 @@ export class CoreCourseHelperProvider {
|
||||||
* @param modParams Params to pass to the module
|
* @param modParams Params to pass to the module
|
||||||
* @return Promise resolved when done.
|
* @return Promise resolved when done.
|
||||||
*/
|
*/
|
||||||
navigateToModuleByInstance(): void {
|
async navigateToModuleByInstance(
|
||||||
// @todo params and logic
|
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
|
* @param modParams Params to pass to the module
|
||||||
* @return Promise resolved when done.
|
* @return Promise resolved when done.
|
||||||
*/
|
*/
|
||||||
navigateToModule(): void {
|
async navigateToModule(
|
||||||
// @todo params and logic
|
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) {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -1744,9 +1848,7 @@ export class CoreCourseHelperProvider {
|
||||||
params = params || {};
|
params = params || {};
|
||||||
Object.assign(params, { course: course });
|
Object.assign(params, { course: course });
|
||||||
|
|
||||||
// @todo implement open course.
|
await CoreNavigator.instance.navigateToSitePath('course', { siteId, params });
|
||||||
// await CoreNavigator.instance.navigateToSitePath('/course/.../...', { siteId, queryParams: params });
|
|
||||||
// return CoreNavigator.instance.openInSiteMainMenu(CoreNavigatorService.OPEN_COURSE, params, siteId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -133,7 +133,7 @@ export class CoreCourseProvider {
|
||||||
* @param courseId Course ID.
|
* @param courseId Course ID.
|
||||||
* @param completion Completion status of the module.
|
* @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) {
|
if (completion && completion.tracking === 2 && completion.state === 0) {
|
||||||
this.invalidateSections(courseId).finally(() => {
|
this.invalidateSections(courseId).finally(() => {
|
||||||
CoreEvents.trigger(CoreEvents.COMPLETION_MODULE_VIEWED, { courseId: courseId });
|
CoreEvents.trigger(CoreEvents.COMPLETION_MODULE_VIEWED, { courseId: courseId });
|
||||||
|
|
|
@ -16,7 +16,7 @@ import { Injectable, Type } from '@angular/core';
|
||||||
|
|
||||||
import { CoreSites } from '@services/sites';
|
import { CoreSites } from '@services/sites';
|
||||||
import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '../module-delegate';
|
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 { CoreCourseAnyCourseData } from '@features/courses/services/courses';
|
||||||
import { CoreCourseModule } from '../course-helper';
|
import { CoreCourseModule } from '../course-helper';
|
||||||
import { CoreCourseUnsupportedModuleComponent } from '@features/course/components/unsupported-module/unsupported-module';
|
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.
|
* @return Data to render the module.
|
||||||
*/
|
*/
|
||||||
getData(
|
getData(
|
||||||
module: CoreCourseWSModule | CoreCourseModuleBasicInfo,
|
module: CoreCourseAnyModuleData,
|
||||||
courseId: number, // eslint-disable-line @typescript-eslint/no-unused-vars
|
courseId: number, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
sectionId?: 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
|
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),
|
icon: CoreCourse.instance.getModuleIconSrc(module.modname, 'modicon' in module ? module.modicon : undefined),
|
||||||
title: module.name,
|
title: module.name,
|
||||||
class: 'core-course-default-handler core-course-module-' + module.modname + '-handler',
|
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.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
|
|
|
@ -14,18 +14,17 @@
|
||||||
|
|
||||||
import { Injectable, Type } from '@angular/core';
|
import { Injectable, Type } from '@angular/core';
|
||||||
import { SafeUrl } from '@angular/platform-browser';
|
import { SafeUrl } from '@angular/platform-browser';
|
||||||
import { Params } from '@angular/router';
|
|
||||||
import { IonRefresher } from '@ionic/angular';
|
import { IonRefresher } from '@ionic/angular';
|
||||||
|
|
||||||
import { CoreSite } from '@classes/site';
|
import { CoreSite } from '@classes/site';
|
||||||
import { CoreCourseModuleDefaultHandler } from './handlers/default-module';
|
import { CoreCourseModuleDefaultHandler } from './handlers/default-module';
|
||||||
import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate';
|
import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate';
|
||||||
import { CoreCourseAnyCourseData } from '@features/courses/services/courses';
|
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 { CoreSites } from '@services/sites';
|
||||||
import { NavigationOptions } from '@ionic/angular/providers/nav-controller';
|
|
||||||
import { makeSingleton } from '@singletons';
|
import { makeSingleton } from '@singletons';
|
||||||
import { CoreCourseModule } from './course-helper';
|
import { CoreCourseModule } from './course-helper';
|
||||||
|
import { CoreNavigationOptions } from '@services/navigator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface that all course module handlers must implement.
|
* Interface that all course module handlers must implement.
|
||||||
|
@ -53,7 +52,7 @@ export interface CoreCourseModuleHandler extends CoreDelegateHandler {
|
||||||
* @return Data to render the module.
|
* @return Data to render the module.
|
||||||
*/
|
*/
|
||||||
getData(
|
getData(
|
||||||
module: CoreCourseWSModule | CoreCourseModuleBasicInfo,
|
module: CoreCourseAnyModuleData,
|
||||||
courseId: number,
|
courseId: number,
|
||||||
sectionId?: number,
|
sectionId?: number,
|
||||||
forCoursePage?: boolean,
|
forCoursePage?: boolean,
|
||||||
|
@ -158,9 +157,8 @@ export interface CoreCourseModuleHandlerData {
|
||||||
* @param module The module object.
|
* @param module The module object.
|
||||||
* @param courseId The course ID.
|
* @param courseId The course ID.
|
||||||
* @param options Options for the navigation.
|
* @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.
|
* Updates the status of the module.
|
||||||
|
@ -272,7 +270,7 @@ export class CoreCourseModuleDelegateService extends CoreDelegate<CoreCourseModu
|
||||||
*/
|
*/
|
||||||
getModuleDataFor(
|
getModuleDataFor(
|
||||||
modname: string,
|
modname: string,
|
||||||
module: CoreCourseWSModule | CoreCourseModuleBasicInfo,
|
module: CoreCourseAnyModuleData,
|
||||||
courseId: number,
|
courseId: number,
|
||||||
sectionId?: number,
|
sectionId?: number,
|
||||||
forCoursePage?: boolean,
|
forCoursePage?: boolean,
|
||||||
|
|
|
@ -48,7 +48,7 @@ export class CoreCoursesDashboardLinkHandlerService extends CoreContentLinksHand
|
||||||
* @param siteId The site ID.
|
* @param siteId The site ID.
|
||||||
* @return Whether the handler is enabled for the URL and site.
|
* @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);
|
return CoreDashboardHomeHandler.instance.isEnabledForSite(siteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -48,7 +48,7 @@ export class CoreGradesOverviewLinkHandlerService extends CoreContentLinksHandle
|
||||||
* @param siteId The site ID.
|
* @param siteId The site ID.
|
||||||
* @return Whether the handler is enabled for the URL and site.
|
* @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);
|
return CoreGrades.instance.isCourseGradesEnabled(siteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Params } from '@angular/router';
|
|
||||||
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
|
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
|
||||||
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
|
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
|
||||||
import { CoreGrades } from '@features/grades/services/grades';
|
import { CoreGrades } from '@features/grades/services/grades';
|
||||||
|
@ -42,16 +42,16 @@ export class CoreGradesUserLinkHandlerService extends CoreContentLinksHandlerBas
|
||||||
getActions(
|
getActions(
|
||||||
siteIds: string[],
|
siteIds: string[],
|
||||||
url: string,
|
url: string,
|
||||||
params: Params,
|
params: Record<string, string>,
|
||||||
courseId?: number,
|
courseId?: number,
|
||||||
data?: { cmid?: string },
|
data?: { cmid?: string },
|
||||||
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
|
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
|
||||||
courseId = courseId || params.id;
|
courseId = courseId || Number(params.id);
|
||||||
data = data || {};
|
data = data || {};
|
||||||
|
|
||||||
return [{
|
return [{
|
||||||
action: (siteId): void => {
|
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;
|
const moduleId = data?.cmid && parseInt(data.cmid, 10) || undefined;
|
||||||
|
|
||||||
CoreGradesHelper.instance.goToGrades(courseId!, userId, moduleId, siteId);
|
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.
|
* @param courseId Course ID related to the URL. Optional but recommended.
|
||||||
* @return Whether the handler is enabled for the URL and site.
|
* @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) {
|
if (!courseId && !params.id) {
|
||||||
return false;
|
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-item class="ion-text-wrap">
|
||||||
<ion-label>
|
<ion-label>
|
||||||
<p class="item-heading">{{ 'core.whyisthisrequired' | translate }}</p>
|
<h3 class="item-heading">{{ 'core.whyisthisrequired' | translate }}</h3>
|
||||||
<p>{{ 'core.explanationdigitalminor' | translate }}</p>
|
<p>{{ 'core.explanationdigitalminor' | translate }}</p>
|
||||||
</ion-label>
|
</ion-label>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
|
@ -225,7 +225,7 @@
|
||||||
</ion-item-divider>
|
</ion-item-divider>
|
||||||
<ion-item class="ion-text-wrap" lines="none">
|
<ion-item class="ion-text-wrap" lines="none">
|
||||||
<ion-label>
|
<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>{{ 'core.digitalminor_desc' | translate }}</p>
|
||||||
<p *ngIf="supportName">{{ supportName }}</p>
|
<p *ngIf="supportName">{{ supportName }}</p>
|
||||||
<p *ngIf="supportEmail">{{ supportEmail }}</p>
|
<p *ngIf="supportEmail">{{ supportEmail }}</p>
|
||||||
|
|
|
@ -21,11 +21,11 @@
|
||||||
<ion-radio-group formControlName="field">
|
<ion-radio-group formControlName="field">
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-label>{{ 'core.login.username' | translate }}</ion-label>
|
<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-item>
|
<ion-item>
|
||||||
<ion-label>{{ 'core.user.email' | translate }}</ion-label>
|
<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-item>
|
||||||
</ion-radio-group>
|
</ion-radio-group>
|
||||||
<ion-item>
|
<ion-item>
|
||||||
|
|
|
@ -35,6 +35,9 @@ export function buildTabMainRoutes(injector: Injector, mainRoute: Route): Routes
|
||||||
@NgModule()
|
@NgModule()
|
||||||
export class CoreMainMenuTabRoutingModule {
|
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> {
|
static forChild(routes: ModuleRoutesConfig): ModuleWithProviders<CoreMainMenuTabRoutingModule> {
|
||||||
return {
|
return {
|
||||||
ngModule: CoreMainMenuTabRoutingModule,
|
ngModule: CoreMainMenuTabRoutingModule,
|
||||||
|
|
|
@ -15,7 +15,8 @@
|
||||||
<ion-content>
|
<ion-content>
|
||||||
<!-- @todo -->
|
<!-- @todo -->
|
||||||
<core-loading [hideUntil]="loaded">
|
<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">
|
<ng-container *ngIf="tabs.length == 0">
|
||||||
<core-empty-box icon="fas-home" [message]="'core.courses.nocourses' | translate"></core-empty-box>
|
<core-empty-box icon="fas-home" [message]="'core.courses.nocourses' | translate"></core-empty-box>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
|
@ -17,7 +17,7 @@ import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
import { CoreSites } from '@services/sites';
|
import { CoreSites } from '@services/sites';
|
||||||
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
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';
|
import { CoreMainMenuHomeDelegate, CoreMainMenuHomeHandlerToDisplay } from '../../services/home-delegate';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -30,10 +30,10 @@ import { CoreMainMenuHomeDelegate, CoreMainMenuHomeHandlerToDisplay } from '../.
|
||||||
})
|
})
|
||||||
export class CoreMainMenuHomePage implements OnInit {
|
export class CoreMainMenuHomePage implements OnInit {
|
||||||
|
|
||||||
@ViewChild(CoreTabsComponent) tabsComponent?: CoreTabsComponent;
|
@ViewChild(CoreTabsOutletComponent) tabsComponent?: CoreTabsOutletComponent;
|
||||||
|
|
||||||
siteName!: string;
|
siteName!: string;
|
||||||
tabs: CoreTab[] = [];
|
tabs: CoreTabsOutletTab[] = [];
|
||||||
loaded = false;
|
loaded = false;
|
||||||
selectedTab?: number;
|
selectedTab?: number;
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,8 @@
|
||||||
import { APP_INITIALIZER, NgModule } from '@angular/core';
|
import { APP_INITIALIZER, NgModule } from '@angular/core';
|
||||||
|
|
||||||
import { CoreCronDelegate } from '@services/cron';
|
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 { CorePushNotificationsRegisterCronHandler } from './services/handlers/register-cron';
|
||||||
import { CorePushNotificationsUnregisterCronHandler } from './services/handlers/unregister-cron';
|
import { CorePushNotificationsUnregisterCronHandler } from './services/handlers/unregister-cron';
|
||||||
import { CorePushNotifications } from './services/pushnotifications';
|
import { CorePushNotifications } from './services/pushnotifications';
|
||||||
|
@ -25,6 +27,11 @@ import { CorePushNotifications } from './services/pushnotifications';
|
||||||
imports: [
|
imports: [
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
{
|
||||||
|
provide: CORE_SITE_SCHEMAS,
|
||||||
|
useValue: [SITE_SCHEMA],
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: APP_INITIALIZER,
|
provide: APP_INITIALIZER,
|
||||||
multi: true,
|
multi: true,
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Params } from '@angular/router';
|
|
||||||
import { CoreSites } from '@services/sites';
|
import { CoreSites } from '@services/sites';
|
||||||
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
|
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
|
||||||
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
|
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.
|
* @param courseId Course ID related to the URL. Optional but recommended.
|
||||||
* @return Whether the handler is enabled for the URL and site.
|
* @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);
|
courseId = parseInt(params.id, 10);
|
||||||
if (!courseId) {
|
if (!courseId) {
|
||||||
return false;
|
return false;
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Params } from '@angular/router';
|
|
||||||
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
|
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
|
||||||
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
|
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
|
||||||
import { CoreNavigator } from '@services/navigator';
|
import { CoreNavigator } from '@services/navigator';
|
||||||
|
@ -42,7 +42,7 @@ export class CoreTagIndexLinkHandlerService extends CoreContentLinksHandlerBase
|
||||||
getActions(
|
getActions(
|
||||||
siteIds: string[],
|
siteIds: string[],
|
||||||
url: string,
|
url: string,
|
||||||
params: Params,
|
params: Record<string, string>,
|
||||||
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
|
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
|
||||||
return [{
|
return [{
|
||||||
action: (siteId): void => {
|
action: (siteId): void => {
|
||||||
|
@ -77,7 +77,7 @@ export class CoreTagIndexLinkHandlerService extends CoreContentLinksHandlerBase
|
||||||
* @param courseId Course ID related to the URL. Optional but recommended.
|
* @param courseId Course ID related to the URL. Optional but recommended.
|
||||||
* @return Whether the handler is enabled for the URL and site.
|
* @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);
|
return CoreTag.instance.areTagsAvailable(siteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Params } from '@angular/router';
|
|
||||||
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
|
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
|
||||||
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
|
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
|
||||||
import { CoreNavigator } from '@services/navigator';
|
import { CoreNavigator } from '@services/navigator';
|
||||||
|
@ -39,7 +39,11 @@ export class CoreTagSearchLinkHandlerService extends CoreContentLinksHandlerBase
|
||||||
* @param data Extra data to handle the URL.
|
* @param data Extra data to handle the URL.
|
||||||
* @return List of (or promise resolved with list of) actions.
|
* @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 [{
|
return [{
|
||||||
action: (siteId): void => {
|
action: (siteId): void => {
|
||||||
const pageParams = {
|
const pageParams = {
|
||||||
|
@ -59,7 +63,7 @@ export class CoreTagSearchLinkHandlerService extends CoreContentLinksHandlerBase
|
||||||
* @param siteId The site ID.
|
* @param siteId The site ID.
|
||||||
* @return Whether the handler is enabled for the URL and site.
|
* @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);
|
return CoreTag.instance.areTagsAvailable(siteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,6 @@
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Params } from '@angular/router';
|
|
||||||
|
|
||||||
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
|
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
|
||||||
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
|
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
|
||||||
|
@ -43,7 +42,7 @@ export class CoreUserProfileLinkHandlerService extends CoreContentLinksHandlerBa
|
||||||
getActions(
|
getActions(
|
||||||
siteIds: string[],
|
siteIds: string[],
|
||||||
url: string,
|
url: string,
|
||||||
params: Params,
|
params: Record<string, string>,
|
||||||
courseId?: number, // eslint-disable-line @typescript-eslint/no-unused-vars
|
courseId?: number, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
data?: unknown, // eslint-disable-line @typescript-eslint/no-unused-vars
|
data?: unknown, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
|
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
|
||||||
|
@ -70,7 +69,7 @@ export class CoreUserProfileLinkHandlerService extends CoreContentLinksHandlerBa
|
||||||
* @return Whether the handler is enabled for the URL and site.
|
* @return Whether the handler is enabled for the URL and site.
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// 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;
|
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 { makeSingleton, NavController, Router } from '@singletons';
|
||||||
import { CoreScreen } from './screen';
|
import { CoreScreen } from './screen';
|
||||||
import { filter } from 'rxjs/operators';
|
import { filter } from 'rxjs/operators';
|
||||||
|
import { CoreApp } from './app';
|
||||||
|
|
||||||
const DEFAULT_MAIN_MENU_TAB = CoreMainMenuHomeHandlerService.PAGE_NAME;
|
const DEFAULT_MAIN_MENU_TAB = CoreMainMenuHomeHandlerService.PAGE_NAME;
|
||||||
|
|
||||||
|
@ -255,10 +256,19 @@ export class CoreNavigatorService {
|
||||||
value = params[name];
|
value = params[name];
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedParam = this.storedParams[value];
|
let storedParam = this.storedParams[value];
|
||||||
|
|
||||||
// Remove the parameter from our map if it's in there.
|
// Remove the parameter from our map if it's in there.
|
||||||
delete this.storedParams[value];
|
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;
|
return <T> storedParam ?? value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -368,6 +378,11 @@ export class CoreNavigatorService {
|
||||||
const id = this.getNewParamId();
|
const id = this.getNewParamId();
|
||||||
this.storedParams[id] = value;
|
this.storedParams[id] = value;
|
||||||
queryParams[name] = id;
|
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.
|
// Common styles.
|
||||||
.text-left { text-align: left; }
|
.text-left { text-align: left; }
|
||||||
.text-right { text-align: right; }
|
.text-right { text-align: right; }
|
||||||
|
@ -31,6 +33,16 @@ ion-item.ion-text-wrap ion-label {
|
||||||
white-space: normal !important;
|
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.
|
// Ionic toolbar.
|
||||||
ion-toolbar ion-back-button,
|
ion-toolbar ion-back-button,
|
||||||
|
@ -139,6 +151,25 @@ ion-toolbar {
|
||||||
z-index: 100000 !important;
|
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.
|
// Hidden submit button.
|
||||||
.core-submit-hidden-enter {
|
.core-submit-hidden-enter {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
@ -351,6 +382,10 @@ ion-toolbar ion-title .core-bar-button-image img {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select.
|
// Select.
|
||||||
|
ion-select::part(text) {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
ion-select.core-button-select,
|
ion-select.core-button-select,
|
||||||
.core-button-select {
|
.core-button-select {
|
||||||
--background: var(--core-button-select-background);
|
--background: var(--core-button-select-background);
|
||||||
|
|
|
@ -17,6 +17,8 @@
|
||||||
--white: #{$white};
|
--white: #{$white};
|
||||||
|
|
||||||
--blue: #{$blue};
|
--blue: #{$blue};
|
||||||
|
--blue-dark: #{$blue-dark};
|
||||||
|
--blue-light: #{$blue-light};
|
||||||
--turquoise: #{$turquoise};
|
--turquoise: #{$turquoise};
|
||||||
--green: #{$green};
|
--green: #{$green};
|
||||||
--red: #{$red};
|
--red: #{$red};
|
||||||
|
@ -156,7 +158,7 @@
|
||||||
--core-tab-color-active: var(--custom-tab-color-active, var(--core-color));
|
--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-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);
|
--background: var(--core-tabs-background);
|
||||||
ion-slide {
|
ion-slide {
|
||||||
--background: var(--core-tab-background);
|
--background: var(--core-tab-background);
|
||||||
|
@ -185,6 +187,7 @@
|
||||||
|
|
||||||
ion-item-divider {
|
ion-item-divider {
|
||||||
--background: var(--gray-lighter);
|
--background: var(--gray-lighter);
|
||||||
|
--color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
--core-button-select-background: var(--custom-button-select-background, var(--ion-color-primary-contrast));
|
--core-button-select-background: var(--custom-button-select-background, var(--ion-color-primary-contrast));
|
||||||
|
|
Loading…
Reference in New Issue