Merge pull request #1326 from dpalou/MOBILE-2345

Mobile 2345
main
Juan Leyva 2018-05-30 13:35:28 +02:00 committed by GitHub
commit b1a1892be3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 8468 additions and 22 deletions

View File

@ -18,7 +18,7 @@ import { ModalController } from 'ionic-angular';
/**
* Base class for component to render a feedback plugin.
*/
export class AddonModAssignFeedbackPluginComponent {
export class AddonModAssignFeedbackPluginComponentBase {
@Input() assign: any; // The assignment.
@Input() submission: any; // The submission.
@Input() plugin: any; // The plugin object.

View File

@ -76,7 +76,7 @@
<!-- Ungrouped users. -->
<div *ngIf="assign.teamsubmission && summary && summary.warnofungroupedusers" class="core-info-card" icon-start>
<ion-icon name="information"></ion-icon>
<ion-icon name="information-circle"></ion-icon>
{{ 'addon.mod_assign.ungroupedusers' | translate }}
</div>
</ion-card>

View File

@ -20,7 +20,7 @@ import { CoreTextUtilsProvider } from '@providers/utils/text';
import { AddonModAssignProvider } from '../../../providers/assign';
import { AddonModAssignOfflineProvider } from '../../../providers/assign-offline';
import { AddonModAssignFeedbackDelegate } from '../../../providers/feedback-delegate';
import { AddonModAssignFeedbackPluginComponent } from '../../../classes/feedback-plugin-component';
import { AddonModAssignFeedbackPluginComponentBase } from '../../../classes/feedback-plugin-component';
import { AddonModAssignFeedbackCommentsHandler } from '../providers/handler';
/**
@ -30,7 +30,7 @@ import { AddonModAssignFeedbackCommentsHandler } from '../providers/handler';
selector: 'addon-mod-assign-feedback-comments',
templateUrl: 'comments.html'
})
export class AddonModAssignFeedbackCommentsComponent extends AddonModAssignFeedbackPluginComponent implements OnInit {
export class AddonModAssignFeedbackCommentsComponent extends AddonModAssignFeedbackPluginComponentBase implements OnInit {
control: FormControl;
component = AddonModAssignProvider.COMPONENT;

View File

@ -15,7 +15,7 @@
import { Component, OnInit } from '@angular/core';
import { ModalController } from 'ionic-angular';
import { AddonModAssignProvider } from '../../../providers/assign';
import { AddonModAssignFeedbackPluginComponent } from '../../../classes/feedback-plugin-component';
import { AddonModAssignFeedbackPluginComponentBase } from '../../../classes/feedback-plugin-component';
/**
* Component to render a edit pdf feedback plugin.
@ -24,7 +24,7 @@ import { AddonModAssignFeedbackPluginComponent } from '../../../classes/feedback
selector: 'addon-mod-assign-feedback-edit-pdf',
templateUrl: 'editpdf.html'
})
export class AddonModAssignFeedbackEditPdfComponent extends AddonModAssignFeedbackPluginComponent implements OnInit {
export class AddonModAssignFeedbackEditPdfComponent extends AddonModAssignFeedbackPluginComponentBase implements OnInit {
component = AddonModAssignProvider.COMPONENT;
files: any[];

View File

@ -15,7 +15,7 @@
import { Component, OnInit } from '@angular/core';
import { ModalController } from 'ionic-angular';
import { AddonModAssignProvider } from '../../../providers/assign';
import { AddonModAssignFeedbackPluginComponent } from '../../../classes/feedback-plugin-component';
import { AddonModAssignFeedbackPluginComponentBase } from '../../../classes/feedback-plugin-component';
/**
* Component to render a file feedback plugin.
@ -24,7 +24,7 @@ import { AddonModAssignFeedbackPluginComponent } from '../../../classes/feedback
selector: 'addon-mod-assign-feedback-file',
templateUrl: 'file.html'
})
export class AddonModAssignFeedbackFileComponent extends AddonModAssignFeedbackPluginComponent implements OnInit {
export class AddonModAssignFeedbackFileComponent extends AddonModAssignFeedbackPluginComponentBase implements OnInit {
component = AddonModAssignProvider.COMPONENT;
files: any[];

View File

@ -34,7 +34,7 @@
<!-- Inform what will happen with the choices. -->
<div *ngIf="canEdit && publishInfo && options && options.length" class="core-info-card" icon-start>
<ion-icon name="information"></ion-icon>
<ion-icon name="information-circle"></ion-icon>
{{ publishInfo | translate }}
</div>

View File

@ -0,0 +1,45 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 { IonicModule } from 'ionic-angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CoreCourseComponentsModule } from '@core/course/components/components.module';
import { AddonModLessonIndexComponent } from './index/index';
@NgModule({
declarations: [
AddonModLessonIndexComponent
],
imports: [
CommonModule,
IonicModule,
TranslateModule.forChild(),
CoreComponentsModule,
CoreDirectivesModule,
CoreCourseComponentsModule
],
providers: [
],
exports: [
AddonModLessonIndexComponent
],
entryComponents: [
AddonModLessonIndexComponent
]
})
export class AddonModLessonComponentsModule {}

View File

@ -0,0 +1,247 @@
<!-- Buttons to add to the header. -->
<core-navbar-buttons end>
<core-context-menu>
<core-context-menu-item *ngIf="externalUrl" [priority]="900" [content]="'core.openinbrowser' | translate" [href]="externalUrl" [iconAction]="'open'"></core-context-menu-item>
<core-context-menu-item *ngIf="description" [priority]="800" [content]="'core.moduleintro' | translate" (action)="expandDescription()" [iconAction]="'arrow-forward'"></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" [content]="'core.settings.synchronizenow' | translate" (action)="doRefresh(null, $event, true)" [iconAction]="syncIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="prefetchStatusIcon" [priority]="500" [content]="prefetchText" (action)="prefetch()" [iconAction]="prefetchStatusIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="size" [priority]="400" [content]="size" [iconDescription]="'cube'" (action)="removeFiles()" [iconAction]="'trash'"></core-context-menu-item>
</core-context-menu>
</core-navbar-buttons>
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="core-loading-center">
<core-tabs [selectedIndex]="selectedTab">
<!-- Index/Preview tab. -->
<core-tab [title]="'addon.mod_lesson.preview' | translate">
<ng-template>
<core-course-module-description [description]="description" [component]="component" [componentId]="componentId"></core-course-module-description>
<!-- Prevent access messages. Only show the first one. -->
<div class="core-info-card" icon-start *ngIf="lesson && preventMessages && preventMessages.length">
<ion-icon name="information-circle"></ion-icon>
<core-format-text [component]="component" [componentId]="componentId" [text]="preventMessages[0].message"></core-format-text>
</div>
<!-- Lesson has data to be synchronized -->
<div class="core-warning-card" icon-start *ngIf="hasOffline">
<ion-icon name="warning"></ion-icon>
{{ 'core.hasdatatosync' | translate: {$a: moduleName} }}
</div>
<!-- Input password for protected lessons. -->
<ion-card *ngIf="askPassword">
<form ion-list (ngSubmit)="submitPassword(passwordinput)">
<ion-item>
<core-show-password item-content [name]="'password'">
<ion-label>{{ 'addon.mod_lesson.enterpassword' | translate }}</ion-label>
<ion-input name="password" type="password" placeholder="{{ 'core.login.password' | translate }}" core-auto-focus #passwordinput></ion-input>
</core-show-password>
</ion-item>
<ion-item>
<button ion-button block type="submit" icon-end>
{{ 'addon.mod_lesson.continue' | translate }}
<ion-icon name="arrow-forward" md="ios-arrow-forward"></ion-icon>
</button>
</ion-item>
</form>
</ion-card>
<core-loading [hideUntil]="!showSpinner">
<ion-list *ngIf="lesson && (!preventMessages || !preventMessages.length)">
<ion-item text-wrap *ngIf="retakeToReview">
<!-- A retake was finished in a synchronization, allow reviewing it. -->
<p>{{ 'addon.mod_lesson.retakefinishedinsync' | translate }}</p>
<a ion-button block (click)="review()">{{ 'addon.mod_lesson.review' | translate }}</a>
</ion-item>
<ion-item text-wrap *ngIf="leftDuringTimed && !lesson.timelimit">
<!-- User left during the session and there is no time limit, ask to continue. -->
<p [innerHTML]="'addon.mod_lesson.youhaveseen' | translate"></p>
<ion-grid>
<ion-row>
<ion-col>
<a ion-button block color="light" (click)="start(false)">{{ 'core.no' | translate }}</a>
</ion-col>
<ion-col>
<a ion-button block (click)="start(true)">{{ 'core.yes' | translate }}</a>
</ion-col>
</ion-row>
</ion-grid>
</ion-item>
<ion-item text-wrap *ngIf="leftDuringTimed && lesson.timelimit && lesson.retake">
<!-- User left during the session with time limit and retakes allowed, ask to continue. -->
<p [innerHTML]="'addon.mod_lesson.leftduringtimed' | translate"></p>
<a ion-button block icon-end (click)="start(false)">
{{ 'addon.mod_lesson.continue' | translate }}
<ion-icon name="arrow-forward" md="ios-arrow-forward"></ion-icon>
</a>
</ion-item>
<ion-item 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 -->
<p [innerHTML]="'addon.mod_lesson.leftduringtimednoretake' | translate"></p>
</ion-item>
<ion-item text-wrap *ngIf="!leftDuringTimed">
<!-- User hasn't left during the session, show a start button. -->
<a ion-button block *ngIf="!canManage" icon-end (click)="start(false)">
{{ 'core.start' | translate }}
<ion-icon name="arrow-forward" md="ios-arrow-forward"></ion-icon>
</a>
<a ion-button block *ngIf="canManage" icon-end (click)="start(false)">
{{ 'addon.mod_lesson.preview' | translate }}
<ion-icon name="search"></ion-icon>
</a>
</ion-item>
</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 text-wrap *ngIf="groupInfo && (groupInfo.separateGroups || groupInfo.visibleGroups)">
<ion-label id="addon-mod_lesson-groupslabel" *ngIf="groupInfo.separateGroups">{{ 'core.groupsseparate' | translate }}</ion-label>
<ion-label id="addon-mod_lesson-groupslabel" *ngIf="groupInfo.visibleGroups">{{ 'core.groupsvisible' | translate }}</ion-label>
<ion-select [(ngModel)]="group" (ionChange)="setGroup(group)" aria-labelledby="addon-mod_lesson-groupslabel" interface="popover">
<ion-option *ngFor="let groupOpt of groupInfo.groups" [value]="groupOpt.id">{{groupOpt.name}}</ion-option>
</ion-select>
</ion-item>
<!-- No lesson retakes. -->
<core-empty-box *ngIf="!overview && selectedGroupName" icon="stats" [message]="'addon.mod_lesson.nolessonattemptsgroup' | translate:{$a: selectedGroupName}">
</core-empty-box>
<core-empty-box *ngIf="!overview && !selectedGroupName" icon="stats" [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 text-wrap>
{{ 'addon.mod_lesson.lessonstats' | translate }}
</ion-card-header>
<!-- In tablet, max 2 rows with 3 columns. -->
<ion-list class="hidden-phone">
<ion-item text-wrap *ngIf="overview.lessonscored">
<ion-row>
<ion-col text-center>
<p class="item-heading">{{ 'addon.mod_lesson.averagescore' | translate }}</p>
<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 text-center>
<p class="item-heading">{{ 'addon.mod_lesson.highscore' | translate }}</p>
<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 text-center>
<p class="item-heading">{{ 'addon.mod_lesson.lowscore' | translate }}</p>
<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-item>
<ion-item text-wrap>
<ion-row>
<ion-col text-center>
<p class="item-heading">{{ 'addon.mod_lesson.averagetime' | translate }}</p>
<p *ngIf="overview.avetime != null && overview.numofattempts">{{ overview.avetimeReadable }}</p>
<p *ngIf="overview.avetime == null || !overview.numofattempts">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col>
<ion-col text-center>
<p class="item-heading">{{ 'addon.mod_lesson.hightime' | translate }}</p>
<p *ngIf="overview.hightime != null">{{ overview.hightimeReadable }}</p>
<p *ngIf="overview.hightime == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col>
<ion-col text-center>
<p class="item-heading">{{ 'addon.mod_lesson.lowtime' | translate }}</p>
<p *ngIf="overview.lowtime != null">{{ overview.lowtimeReadable }}</p>
<p *ngIf="overview.lowtime == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col>
</ion-row>
</ion-item>
</ion-list>
<!-- In phone, 3 rows with 1 or 2 columns. -->
<ion-list class="hidden-tablet">
<ion-item text-wrap>
<ion-row>
<ion-col text-center *ngIf="overview.lessonscored">
<p class="item-heading">{{ 'addon.mod_lesson.averagescore' | translate }}</p>
<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 [attr.text-center]="overview.lessonscored ? true : null">
<p class="item-heading">{{ 'addon.mod_lesson.averagetime' | translate }}</p>
<p *ngIf="overview.avetime != null && overview.numofattempts">{{ overview.avetimeReadable }}</p>
<p *ngIf="overview.avetime == null || !overview.numofattempts">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col>
</ion-row>
</ion-item>
<ion-item text-wrap>
<ion-row>
<ion-col text-center *ngIf="overview.lessonscored">
<p class="item-heading">{{ 'addon.mod_lesson.highscore' | translate }}</p>
<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 [attr.text-center]="overview.lessonscored ? true : null">
<p class="item-heading">{{ 'addon.mod_lesson.hightime' | translate }}</p>
<p *ngIf="overview.hightime != null">{{ overview.hightimeReadable }}</p>
<p *ngIf="overview.hightime == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col>
</ion-row>
</ion-item>
<ion-item text-wrap>
<ion-row>
<ion-col text-center *ngIf="overview.lessonscored">
<p class="item-heading">{{ 'addon.mod_lesson.lowscore' | translate }}</p>
<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 [attr.text-center]="overview.lessonscored ? true : null">
<p class="item-heading">{{ 'addon.mod_lesson.lowtime' | translate }}</p>
<p *ngIf="overview.lowtime != null">{{ overview.lowtimeReadable }}</p>
<p *ngIf="overview.lowtime == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col>
</ion-row>
</ion-item>
</ion-list>
</ion-card>
<!-- List of students that have retakes. -->
<ion-card *ngIf="overview">
<ion-card-header text-wrap>
{{ 'addon.mod_lesson.overview' | translate }}
</ion-card-header>
<a ion-item text-wrap *ngFor="let student of overview.students" [navPush]="'AddonModLessonUserRetakePage'" [navParams]="{courseId: courseId, lessonId: lesson.id, userId: student.id}">
<ion-avatar item-start *ngIf="student.profileimageurl" core-user-link [userId]="student.id" [courseId]="courseId">
<img [src]="student.profileimageurl" [alt]="'core.pictureof' | translate:{$a: student.fullname}" core-external-content onError="this.src='assets/img/user-avatar.png'" role="presentation">
</ion-avatar>
<h2>{{ student.fullname }}</h2>
<core-progress-bar [progress]="student.bestgrade"></core-progress-bar>
</a>
</ion-card>
</core-loading>
</ng-template>
</core-tab>
</core-tabs>
</core-loading>

View File

@ -0,0 +1,2 @@
addon-mod-lesson-index {
}

View File

@ -0,0 +1,552 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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, Optional, Injector, Input } from '@angular/core';
import { Content, NavController } from 'ionic-angular';
import { CoreGroupsProvider, CoreGroupInfo } from '@providers/groups';
import { CoreTimeUtilsProvider } from '@providers/utils/time';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main-activity-component';
import { CoreUserProvider } from '@core/user/providers/user';
import { AddonModLessonProvider } from '../../providers/lesson';
import { AddonModLessonOfflineProvider } from '../../providers/lesson-offline';
import { AddonModLessonSyncProvider } from '../../providers/lesson-sync';
import { AddonModLessonPrefetchHandler } from '../../providers/prefetch-handler';
import { CoreConstants } from '@core/constants';
/**
* Component that displays a lesson entry page.
*/
@Component({
selector: 'addon-mod-lesson-index',
templateUrl: 'index.html',
})
export class AddonModLessonIndexComponent extends CoreCourseModuleMainActivityComponent {
@Input() group: number; // The group to display.
@Input() action: string; // The "action" to display first.
component = AddonModLessonProvider.COMPONENT;
moduleName = 'lesson';
lesson: any; // 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: any; // A retake to review.
preventMessages: string[]; // List of messages 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: any; // Reports overview data.
protected syncEventName = AddonModLessonSyncProvider.AUTO_SYNCED;
protected accessInfo: any; // Lesson access info.
protected password: string; // The password for the lesson.
protected hasPlayed: boolean; // Whether the user has gone to the lesson player (attempted).
constructor(injector: Injector, protected lessonProvider: AddonModLessonProvider, @Optional() content: Content,
protected groupsProvider: CoreGroupsProvider, protected lessonOffline: AddonModLessonOfflineProvider,
protected lessonSync: AddonModLessonSyncProvider, protected utils: CoreUtilsProvider,
protected prefetchHandler: AddonModLessonPrefetchHandler, protected navCtrl: NavController,
protected timeUtils: CoreTimeUtilsProvider, protected userProvider: CoreUserProvider) {
super(injector, content);
}
/**
* Component being initialized.
*/
ngOnInit(): void {
super.ngOnInit();
this.selectedTab = this.action == 'report' ? 1 : 0;
this.loadContent(false, true).then(() => {
if (!this.lesson || (this.preventMessages && this.preventMessages.length)) {
return;
}
this.logView();
});
}
/**
* Change the group displayed.
*
* @param {number} groupId Group ID to display.
*/
changeGroup(groupId: number): void {
this.reportLoaded = false;
this.setGroup(groupId).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error getting report.');
}).finally(() => {
this.reportLoaded = true;
});
}
/**
* Get the lesson data.
*
* @param {boolean} [refresh=false] If it's refreshing content.
* @param {boolean} [sync=false] If the refresh is needs syncing.
* @param {boolean} [showErrors=false] If show errors to the user of hide them.
* @return {Promise<any>} Promise resolved when done.
*/
protected fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<any> {
let lessonReady = true;
this.askPassword = false;
return this.lessonProvider.getLesson(this.courseId, this.module.id).then((lessonData) => {
this.lesson = lessonData;
this.dataRetrieved.emit(this.lesson);
this.description = this.lesson.intro; // Show description only if intro is present.
if (sync) {
// Try to synchronize the lesson.
return this.syncActivity(showErrors);
}
}).then(() => {
return this.lessonProvider.getAccessInformation(this.lesson.id);
}).then((info) => {
const promises = [];
this.accessInfo = info;
this.canManage = info.canmanage;
this.canViewReports = info.canviewreports;
if (this.lessonProvider.isLessonOffline(this.lesson)) {
// Handle status.
this.setStatusListener();
// Check if there is offline data.
promises.push(this.lessonSync.hasDataToSync(this.lesson.id, info.attemptscount).then((hasOffline) => {
this.hasOffline = hasOffline;
}));
// Check if there is a retake finished in a synchronization.
promises.push(this.lessonSync.getRetakeFinishedInSync(this.lesson.id).then((retake) => {
if (retake && retake.retake == info.attemptscount - 1) {
// The retake finished is still the last retake. Allow reviewing it.
this.retakeToReview = retake;
} else {
this.retakeToReview = undefined;
if (retake) {
this.lessonSync.deleteRetakeFinishedInSync(this.lesson.id);
}
}
}));
// Update the list of content pages viewed and question attempts.
promises.push(this.lessonProvider.getContentPagesViewedOnline(this.lesson.id, info.attemptscount));
promises.push(this.lessonProvider.getQuestionsAttemptsOnline(this.lesson.id, info.attemptscount));
}
if (info.preventaccessreasons && info.preventaccessreasons.length) {
const askPassword = info.preventaccessreasons.length == 1 && this.lessonProvider.isPasswordProtected(info);
if (askPassword) {
// The lesson requires a password. Check if there is one in memory or DB.
const promise = this.password ? Promise.resolve(this.password) :
this.lessonProvider.getStoredPassword(this.lesson.id);
promises.push(promise.then((password) => {
return this.validatePassword(password);
}).catch(() => {
// No password or the validation failed. Show password form.
this.askPassword = true;
this.preventMessages = info.preventaccessreasons;
lessonReady = false;
}));
} else {
// Lesson cannot be started.
this.preventMessages = info.preventaccessreasons;
lessonReady = false;
}
}
if (this.selectedTab == 1 && this.canViewReports) {
// Only fetch the report data if the tab is selected.
promises.push(this.fetchReportData());
}
return Promise.all(promises).then(() => {
if (lessonReady) {
// Lesson can be started, don't ask the password and don't show prevent messages.
this.lessonReady(refresh);
}
});
});
}
/**
* Fetch the reports data.
*
* @return {Promise<any>} Promise resolved when done.
*/
protected fetchReportData(): Promise<any> {
return this.groupsProvider.getActivityGroupInfo(this.module.id).then((groupInfo) => {
this.groupInfo = groupInfo;
return this.setGroup(this.group || 0);
}).finally(() => {
this.reportLoaded = true;
});
}
/**
* Checks if sync has succeed from result sync data.
*
* @param {any} result Data returned on the sync function.
* @return {boolean} If suceed or not.
*/
protected hasSyncSucceed(result: any): boolean {
return result.updated;
}
/**
* User entered the page that contains the component.
*/
ionViewDidEnter(): void {
super.ionViewDidEnter();
// Update data when we come back from the player since the status could have changed.
if (this.hasPlayed) {
this.hasPlayed = false;
this.showLoadingAndRefresh(true, false);
}
}
/**
* User left the page that contains the component.
*/
ionViewDidLeave(): void {
super.ionViewDidLeave();
if (this.navCtrl.getActive().component.name == 'AddonModLessonPlayerPage') {
this.hasPlayed = true;
}
}
/**
* Perform the invalidate content function.
*
* @return {Promise<any>} Resolved when done.
*/
protected invalidateContent(): Promise<any> {
const promises = [];
promises.push(this.lessonProvider.invalidateLessonData(this.courseId));
if (this.lesson) {
promises.push(this.lessonProvider.invalidateAccessInformation(this.lesson.id));
promises.push(this.lessonProvider.invalidatePages(this.lesson.id));
promises.push(this.lessonProvider.invalidateLessonWithPassword(this.lesson.id));
promises.push(this.lessonProvider.invalidateTimers(this.lesson.id));
promises.push(this.lessonProvider.invalidateContentPagesViewed(this.lesson.id));
promises.push(this.lessonProvider.invalidateQuestionsAttempts(this.lesson.id));
promises.push(this.lessonProvider.invalidateRetakesOverview(this.lesson.id));
promises.push(this.groupsProvider.invalidateActivityGroupInfo(this.module.id));
}
return Promise.all(promises);
}
/**
* Compares sync event data with current data to check if refresh content is needed.
*
* @param {any} syncEventData Data receiven on sync observer.
* @return {boolean} True if refresh is needed, false otherwise.
*/
protected isRefreshSyncNeeded(syncEventData: any): boolean {
return this.lesson && syncEventData.lessonId == this.lesson.id;
}
/**
* Function called when the lesson is ready to be seen (no pending prevent access reasons).
*
* @param {boolean} [refresh=false] If it's refreshing content.
*/
protected lessonReady(refresh?: boolean): void {
this.askPassword = false;
this.preventMessages = [];
this.leftDuringTimed = this.hasOffline || this.lessonProvider.leftDuringTimed(this.accessInfo);
if (this.password) {
// Store the password in DB.
this.lessonProvider.storePassword(this.lesson.id, this.password);
}
// All data obtained, now fill the context menu.
this.fillContextMenu(refresh);
}
/**
* Log viewing the lesson.
*/
protected logView(): void {
this.lessonProvider.logViewLesson(this.lesson.id, this.password).then(() => {
this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus);
}).catch((error) => {
// Ignore errors.
});
}
/**
* Open the lesson player.
*
* @param {boolean} continueLast Whether to continue the last retake.
* @return {Promise<any>} Promise resolved when done.
*/
protected playLesson(continueLast: boolean): Promise<any> {
// Calculate the pageId to load. If there is timelimit, lesson is always restarted from the start.
let promise;
if (this.hasOffline) {
if (continueLast) {
promise = this.lessonProvider.getLastPageSeen(this.lesson.id, this.accessInfo.attemptscount);
} else {
promise = Promise.resolve(this.accessInfo.firstpageid);
}
} else if (this.leftDuringTimed && !this.lesson.timelimit) {
promise = Promise.resolve(continueLast ? this.accessInfo.lastpageseen : this.accessInfo.firstpageid);
} else {
promise = Promise.resolve();
}
return promise.then((pageId) => {
this.navCtrl.push('AddonModLessonPlayerPage', {
courseId: this.courseId,
lessonId: this.lesson.id,
pageId: pageId,
password: this.password
});
});
}
/**
* Reports tab selected.
*/
reportsSelected(): void {
if (!this.groupInfo) {
this.fetchReportData().catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error getting report.');
});
}
}
/**
* Review the lesson.
*/
review(): void {
if (!this.retakeToReview) {
// No retake to review, stop.
return;
}
this.navCtrl.push('AddonModLessonPlayerPage', {
courseId: this.courseId,
lessonId: this.lesson.id,
pageId: this.retakeToReview.pageId,
password: this.password,
review: true,
retake: this.retakeToReview.retake
});
}
/**
* Set a group to view the reports.
*
* @param {number} groupId Group ID.
* @return {Promise<any>} Promise resolved when done.
*/
protected setGroup(groupId: number): Promise<any> {
this.group = groupId;
this.selectedGroupName = '';
// Search the name of the group if it isn't all participants.
if (groupId && this.groupInfo && this.groupInfo.groups) {
for (let i = 0; i < this.groupInfo.groups.length; i++) {
const group = this.groupInfo.groups[i];
if (groupId == group.id) {
this.selectedGroupName = group.name;
break;
}
}
}
// Get the overview of retakes for the group.
return this.lessonProvider.getRetakesOverview(this.lesson.id, groupId).then((data) => {
const promises = [];
// Format times and grades.
if (data && data.avetime != null && data.numofattempts) {
data.avetime = Math.floor(data.avetime / data.numofattempts);
data.avetimeReadable = this.timeUtils.formatTime(data.avetime);
}
if (data && data.hightime != null) {
data.hightimeReadable = this.timeUtils.formatTime(data.hightime);
}
if (data && data.lowtime != null) {
data.lowtimeReadable = this.timeUtils.formatTime(data.lowtime);
}
if (data && data.lessonscored) {
if (data.numofattempts) {
data.avescore = this.textUtils.roundToDecimals(data.avescore, 2);
}
if (data.highscore != null) {
data.highscore = this.textUtils.roundToDecimals(data.highscore, 2);
}
if (data.lowscore != null) {
data.lowscore = this.textUtils.roundToDecimals(data.lowscore, 2);
}
}
if (data && data.students) {
// Get the user data for each student returned.
data.students.forEach((student) => {
student.bestgrade = this.textUtils.roundToDecimals(student.bestgrade, 2);
promises.push(this.userProvider.getProfile(student.id, this.courseId, true).then((user) => {
student.profileimageurl = user.profileimageurl;
}).catch(() => {
// Error getting profile, resolve promise without adding any extra data.
}));
});
}
return this.utils.allPromises(promises).catch(() => {
// Shouldn't happen.
}).then(() => {
this.overview = data;
});
});
}
/**
* Displays some data based on the current status.
*
* @param {string} status The current status.
* @param {string} [previousStatus] The previous status. If not defined, there is no previous status.
*/
protected showStatus(status: string, previousStatus?: string): void {
this.showSpinner = status == CoreConstants.DOWNLOADING;
}
/**
* Start the lesson.
*
* @param {boolean} [continueLast] Whether to continue the last attempt.
*/
start(continueLast?: boolean): void {
if (this.showSpinner) {
// Lesson is being downloaded, abort.
return;
}
if (this.lessonProvider.isLessonOffline(this.lesson)) {
// Lesson supports offline, check if it needs to be downloaded.
if (this.currentStatus != CoreConstants.DOWNLOADED) {
// Prefetch the lesson.
this.showSpinner = true;
this.prefetchHandler.prefetch(this.module, this.courseId, true).then(() => {
// 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 {
this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true);
}
}).finally(() => {
this.showSpinner = false;
});
} else {
// Already downloaded, open it.
this.playLesson(continueLast);
}
} else {
this.playLesson(continueLast);
}
}
/**
* Submit password for password protected lessons.
*
* @param {HTMLInputElement} passwordEl The password input.
*/
submitPassword(passwordEl: HTMLInputElement): void {
const password = passwordEl && passwordEl.value;
if (!password) {
this.domUtils.showErrorModal('addon.mod_lesson.emptypassword', true);
return;
}
this.loaded = false;
this.refreshIcon = 'spinner';
this.syncIcon = 'spinner';
this.validatePassword(password).then(() => {
// Password validated.
this.lessonReady(false);
// Log view now that we have the password.
this.logView();
}).catch((error) => {
this.domUtils.showErrorModal(error);
}).finally(() => {
this.loaded = true;
this.refreshIcon = 'refresh';
this.syncIcon = 'sync';
});
}
/**
* Performs the sync of the activity.
*
* @return {Promise<any>} Promise resolved when done.
*/
protected sync(): Promise<any> {
return this.lessonSync.syncLesson(this.lesson.id, true);
}
/**
* Validate a password and retrieve extra data.
*
* @param {string} password The password to validate.
* @return {Promise<any>} Promise resolved when done.
*/
protected validatePassword(password: string): Promise<any> {
return this.lessonProvider.getLessonWithPassword(this.lesson.id, password).then((lessonData) => {
this.lesson = lessonData;
this.password = password;
}).catch((error) => {
this.password = '';
return Promise.reject(error);
});
}
}

View File

@ -0,0 +1,85 @@
{
"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><p>Your {{$a.essayquestions}} essay question(s) will be graded and added into your final score at a later date.</p><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.",
"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": "An {{$a.cluster}} jump or an {{$a.unseen}} jump is being used in this lesson. The next page jump will be used instead. Login as a student to test these jumps.",
"teacherongoingwarning": "Ongoing score is only displayed for student. Login as a student to test ongoing score",
"teachertimerwarning": "Timer only works for students. Test the timer by logging in as a student.",
"thatsthecorrectanswer": "That's the correct answer",
"thatsthewronganswer": "That's the wrong answer",
"timeremaining": "Time remaining",
"timetaken": "Time taken",
"unseenpageinbranch": "Unseen question within a content page",
"warningretakefinished": "The attempt was finished on the site.",
"welldone": "Well done!",
"youhaveseen": "You have seen more than one page of this lesson already.<br />Do you want to start at the last page you saw?",
"youranswer": "Your answer",
"yourcurrentgradeisoutof": "Your current grade is {{$a.grade}} out of {{$a.total}}",
"youshouldview": "You should answer at least: {{$a}}"
}

View File

@ -0,0 +1,65 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 { CoreCronDelegate } from '@providers/cron';
import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate';
import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate';
import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate';
import { AddonModLessonComponentsModule } from './components/components.module';
import { AddonModLessonProvider } from './providers/lesson';
import { AddonModLessonOfflineProvider } from './providers/lesson-offline';
import { AddonModLessonSyncProvider } from './providers/lesson-sync';
import { AddonModLessonHelperProvider } from './providers/helper';
import { AddonModLessonModuleHandler } from './providers/module-handler';
import { AddonModLessonPrefetchHandler } from './providers/prefetch-handler';
import { AddonModLessonSyncCronHandler } from './providers/sync-cron-handler';
import { AddonModLessonIndexLinkHandler } from './providers/index-link-handler';
import { AddonModLessonGradeLinkHandler } from './providers/grade-link-handler';
import { AddonModLessonReportLinkHandler } from './providers/report-link-handler';
@NgModule({
declarations: [
],
imports: [
AddonModLessonComponentsModule
],
providers: [
AddonModLessonProvider,
AddonModLessonOfflineProvider,
AddonModLessonSyncProvider,
AddonModLessonHelperProvider,
AddonModLessonModuleHandler,
AddonModLessonPrefetchHandler,
AddonModLessonSyncCronHandler,
AddonModLessonIndexLinkHandler,
AddonModLessonGradeLinkHandler,
AddonModLessonReportLinkHandler
]
})
export class AddonModLessonModule {
constructor(moduleDelegate: CoreCourseModuleDelegate, moduleHandler: AddonModLessonModuleHandler,
prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModLessonPrefetchHandler,
cronDelegate: CoreCronDelegate, syncHandler: AddonModLessonSyncCronHandler, linksDelegate: CoreContentLinksDelegate,
indexHandler: AddonModLessonIndexLinkHandler, gradeHandler: AddonModLessonGradeLinkHandler,
reportHandler: AddonModLessonReportLinkHandler) {
moduleDelegate.registerHandler(moduleHandler);
prefetchDelegate.registerHandler(prefetchHandler);
cronDelegate.register(syncHandler);
linksDelegate.registerHandler(indexHandler);
linksDelegate.registerHandler(gradeHandler);
linksDelegate.registerHandler(reportHandler);
}
}

View File

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

View File

@ -0,0 +1,33 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 { IonicPageModule } from 'ionic-angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreDirectivesModule } from '@directives/directives.module';
import { AddonModLessonComponentsModule } from '../../components/components.module';
import { AddonModLessonIndexPage } from './index';
@NgModule({
declarations: [
AddonModLessonIndexPage,
],
imports: [
CoreDirectivesModule,
AddonModLessonComponentsModule,
IonicPageModule.forChild(AddonModLessonIndexPage),
TranslateModule.forChild()
],
})
export class AddonModLessonIndexPageModule {}

View File

@ -0,0 +1,66 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 } from '@angular/core';
import { IonicPage, NavParams } from 'ionic-angular';
import { AddonModLessonIndexComponent } from '../../components/index/index';
/**
* Page that displays the lesson entry page.
*/
@IonicPage({ segment: 'addon-mod-lesson-index' })
@Component({
selector: 'page-addon-mod-lesson-index',
templateUrl: 'index.html',
})
export class AddonModLessonIndexPage {
@ViewChild(AddonModLessonIndexComponent) lessonComponent: AddonModLessonIndexComponent;
title: string;
module: any;
courseId: number;
group: number; // The group to display.
action: string; // The "action" to display first.
constructor(navParams: NavParams) {
this.module = navParams.get('module') || {};
this.courseId = navParams.get('courseId');
this.group = navParams.get('group');
this.action = navParams.get('action');
this.title = this.module.name;
}
/**
* Update some data based on the lesson instance.
*
* @param {any} lesson Lesson instance.
*/
updateData(lesson: any): void {
this.title = lesson.name || this.title;
}
/**
* User entered the page.
*/
ionViewDidEnter(): void {
this.lessonComponent.ionViewDidEnter();
}
/**
* User left the page.
*/
ionViewDidLeave(): void {
this.lessonComponent.ionViewDidLeave();
}
}

View File

@ -0,0 +1,36 @@
<ion-header>
<ion-navbar>
<ion-title>{{ pageInstance.lesson.name }}</ion-title>
<ion-buttons end>
<button ion-button icon-only (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon name="close"></ion-icon>
</button>
</ion-buttons>
</ion-navbar>
</ion-header>
<ion-content class="addon-mod_lesson-menu-modal">
<nav>
<ion-list>
<!-- Media file. -->
<ng-container *ngIf="pageInstance.mediaFile">
<ion-item-divider color="light"><h2>{{ 'addon.mod_lesson.linkedmedia' | translate }}</h2></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 color="light"><h2>{{ 'addon.mod_lesson.lessonmenu' | translate }}</h2></ion-item-divider>
<ion-item text-center *ngIf="pageInstance.loadingMenu">
<ion-spinner></ion-spinner>
</ion-item>
<div *ngIf="!pageInstance.loadingMenu">
<ng-container *ngFor="let page of pageInstance.lessonPages">
<a ion-item text-wrap *ngIf="page.display && page.displayinmenublock" (click)="loadPage(page.id)" [ngClass]='{"addon-mod_lesson-selected core-white-push-arrow": !pageInstance.eolData && pageInstance.currentPage == page.id}'>
<p><core-format-text [text]="page.title"></core-format-text></p>
</a>
</ng-container>
</div>
</ng-container>
</ion-list>
</nav>
</ion-content>

View File

@ -0,0 +1,33 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 { IonicPageModule } from 'ionic-angular';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { AddonModLessonMenuModalPage } from './menu-modal';
import { TranslateModule } from '@ngx-translate/core';
@NgModule({
declarations: [
AddonModLessonMenuModalPage
],
imports: [
CoreComponentsModule,
CoreDirectivesModule,
IonicPageModule.forChild(AddonModLessonMenuModalPage),
TranslateModule.forChild()
]
})
export class AddonModLessonMenuModalPageModule {}

View File

@ -0,0 +1,5 @@
page-addon-mod-lesson-menu-modal {
.addon-mod_lesson-selected, .item.addon-mod_lesson-selected {
background: $blue-light;
}
}

View File

@ -0,0 +1,58 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 } from '@angular/core';
import { IonicPage, ViewController, NavParams } from 'ionic-angular';
/**
* Modal that renders the lesson menu and media file.
*/
@IonicPage({ segment: 'addon-mod-lesson-menu-modal' })
@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.
* @type {any}
*/
pageInstance: any;
constructor(params: NavParams, protected viewCtrl: ViewController) {
this.pageInstance = params.get('page');
}
/**
* Close modal.
*/
closeModal(): void {
this.viewCtrl.dismiss();
}
/**
* Load a certain page.
*
* @param {number} pageId The page ID to load.
*/
loadPage(pageId: number): void {
this.pageInstance.changePage && this.pageInstance.changePage(pageId);
this.closeModal();
}
}

View File

@ -0,0 +1,26 @@
<ion-header>
<ion-navbar>
<ion-title>{{ 'core.login.password' | translate }}</ion-title>
<ion-buttons end>
<button ion-button icon-only (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon name="close"></ion-icon>
</button>
</ion-buttons>
</ion-navbar>
</ion-header>
<ion-content padding class="addon-mod_lesson-password-modal">
<form ion-list (ngSubmit)="submitPassword(passwordinput)">
<ion-item>
<core-show-password item-content [name]="'password'">
<ion-label>{{ 'addon.mod_lesson.enterpassword' | translate }}</ion-label>
<ion-input name="password" type="password" placeholder="{{ 'core.login.password' | translate }}" core-auto-focus #passwordinput></ion-input>
</core-show-password>
</ion-item>
<ion-item>
<button ion-button block type="submit" icon-end>
{{ 'addon.mod_lesson.continue' | translate }}
<ion-icon name="arrow-forward" md="ios-arrow-forward"></ion-icon>
</button>
</ion-item>
</form>
</ion-content>

View File

@ -0,0 +1,31 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 { IonicPageModule } from 'ionic-angular';
import { AddonModLessonPasswordModalPage } from './password-modal';
import { TranslateModule } from '@ngx-translate/core';
import { CoreComponentsModule } from '@components/components.module';
@NgModule({
declarations: [
AddonModLessonPasswordModalPage
],
imports: [
CoreComponentsModule,
IonicPageModule.forChild(AddonModLessonPasswordModalPage),
TranslateModule.forChild()
]
})
export class AddonModLessonPasswordModalPageModule {}

View File

@ -0,0 +1,43 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 } from '@angular/core';
import { IonicPage, ViewController } from 'ionic-angular';
/**
* Modal that asks the password for a lesson.
*/
@IonicPage({ segment: 'addon-mod-lesson-password-modal' })
@Component({
selector: 'page-addon-mod-lesson-password-modal',
templateUrl: 'password-modal.html',
})
export class AddonModLessonPasswordModalPage {
constructor(protected viewCtrl: ViewController) { }
/**
* Send the password back.
*/
submitPassword(password: HTMLInputElement): void {
this.viewCtrl.dismiss(password.value);
}
/**
* Close modal.
*/
closeModal(): void {
this.viewCtrl.dismiss();
}
}

View File

@ -0,0 +1,220 @@
<ion-header>
<ion-navbar>
<ion-title><core-format-text [text]="title"></core-format-text></ion-title>
<ion-buttons end>
<button *ngIf="displayMenu || mediaFile" ion-button icon-only [attr.aria-label]="'addon.mod_lesson.lessonmenu' | translate" (click)="menuModal.present()">
<ion-icon name="bookmark"></ion-icon>
</button>
</ion-buttons>
</ion-navbar>
</ion-header>
<ion-content>
<core-loading [hideUntil]="loaded">
<!-- Info messages. Only show the first one. -->
<div class="core-info-card" icon-start *ngIf="lesson && messages && messages.length">
<ion-icon name="information-circle"></ion-icon>
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="messages[0].message"></core-format-text>
</div>
<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 text-wrap *ngIf="showRetake && !eolData && !processData">
<p>{{ 'addon.mod_lesson.attempt' | translate:{$a: retake} }}</p>
</ion-item>
<ion-item text-wrap *ngIf="pageData && pageData.ongoingscore && !eolData && !processData" class="addon-mod_lesson-ongoingscore">
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="pageData.ongoingscore"></core-format-text>
</ion-item>
<!-- Page content. -->
<ion-card *ngIf="!eolData && !processData">
<!-- Content page. -->
<ion-item text-wrap *ngIf="!question">
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="pageContent"></core-format-text>
</ion-item>
<!-- Question page. -->
<form *ngIf="question" ion-list [formGroup]="questionForm">
<ion-item-divider text-wrap color="light">
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="pageContent"></core-format-text>
</ion-item-divider>
<input *ngFor="let input of question.hiddenInputs" type="hidden" [name]="input.name" [value]="input.value" />
<!-- Render a different input depending on the type of the question. -->
<ng-container [ngSwitch]="question.template">
<!-- Short answer. -->
<ng-container *ngSwitchCase="'shortanswer'">
<ion-input padding-left [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>
</ng-container>
<!-- Essay. -->
<ng-container *ngSwitchCase="'essay'">
<ion-item *ngIf="question.textarea">
<core-rich-text-editor item-content placeholder="{{ 'addon.mod_lesson.youranswer' | translate }}" [control]="question.control" [component]="component" [componentId]="lesson.coursemodule"></core-rich-text-editor>
</ion-item>
<ion-item text-wrap *ngIf="!question.textarea && question.useranswer">
<p class="item-heading">{{ 'addon.mod_lesson.youranswer' | translate }}</p>
<p><core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="question.useranswer"></core-format-text></p>
</ion-item>
</ng-container>
<!-- Multichoice. -->
<ng-container *ngSwitchCase="'multichoice'">
<!-- Single choice. -->
<div *ngIf="!question.multi" radio-group [formControlName]="question.controlName">
<ion-item text-wrap *ngFor="let option of question.options">
<ion-label>
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="option.text"></core-format-text>
</ion-label>
<ion-radio [id]="option.id" [value]="option.value" [disabled]="option.disabled"></ion-radio>
</ion-item>
</div>
<!-- Multiple choice. -->
<ng-container *ngIf="question.multi">
<ion-item text-wrap *ngFor="let option of question.options">
<ion-label>
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="option.text"></core-format-text>
</ion-label>
<ion-checkbox [id]="option.id" [formControlName]="option.name" item-end></ion-checkbox>
</ion-item>
</ng-container>
</ng-container>
<!-- Matching. -->
<ng-container *ngSwitchCase="'matching'">
<ion-item text-wrap *ngFor="let row of question.rows">
<ion-grid item-content>
<ion-row>
<ion-col>
<p><core-format-text id="addon-mod_lesson-matching-{{row.id}}" [component]="component" [componentId]="lesson.coursemodule" [text]="row.text"></core-format-text></p>
</ion-col>
<ion-col>
<ion-select [id]="row.id" [formControlName]="row.name" [attr.aria-labelledby]="'addon-mod_lesson-matching-' + row.id" interface="popover">
<ion-option *ngFor="let option of row.options" [value]="option.value">{{option.label}}</ion-option>
</ion-select>
</ion-col>
</ion-row>
</ion-grid>
</ion-item>
</ng-container>
</ng-container>
<ion-item>
<button ion-button block (click)="submitQuestion()">{{ question.submitLabel }}</button>
</ion-item>
</form>
</ion-card>
<!-- Page buttons and progress. -->
<ion-list *ngIf="!eolData && !processData">
<ion-grid text-wrap *ngIf="pageButtons && pageButtons.length" class="addon-mod_lesson-pagebuttons">
<ion-row align-items-center>
<ion-col *ngFor="let button of pageButtons" col-12 col-md-6 col-lg-3 col-xl>
<a ion-button block outline text-wrap [id]="button.id" (click)="buttonClicked(button.data)">{{ button.content }}</a>
</ion-col>
</ion-row>
</ion-grid>
<ion-item text-wrap *ngIf="lesson && lesson.progressbar && !canManage && pageData">
<p>{{ 'addon.mod_lesson.progresscompleted' | translate:{$a: pageData.progress} }}</p>
<core-progress-bar [progress]="pageData.progress"></core-progress-bar>
</ion-item>
<div class="core-info-card" icon-start *ngIf="lesson && lesson.progressbar && canManage">
<ion-icon name="information-circle"></ion-icon>
{{ 'addon.mod_lesson.progressbarteacherwarning2' | translate }}
</div>
</ion-list>
<!-- End of lesson reached. -->
<ion-card *ngIf="eolData && !processData">
<div class="core-warning-card" icon-start *ngIf="eolData.offline && eolData.offline.value">
<ion-icon name="warning"></ion-icon>
{{ 'addon.mod_lesson.finishretakeoffline' | translate }}
</div>
<h3 padding *ngIf="eolData.gradelesson">{{ 'addon.mod_lesson.congratulations' | translate }}</h3>
<ion-item text-wrap *ngIf="eolData.notenoughtimespent">
<core-format-text [text]="eolData.notenoughtimespent.message"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="eolData.numberofpagesviewed">
<core-format-text [text]="eolData.numberofpagesviewed.message"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="eolData.youshouldview">
<core-format-text [text]="eolData.youshouldview.message"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="eolData.numberofcorrectanswers">
<core-format-text [text]="eolData.numberofcorrectanswers.message"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="eolData.displayscorewithessays">
<core-format-text [text]="eolData.displayscorewithessays.message"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="!eolData.displayscorewithessays && eolData.displayscorewithoutessays">
<core-format-text [text]="eolData.displayscorewithoutessays.message"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="eolData.yourcurrentgradeisoutof">
<core-format-text [text]="eolData.yourcurrentgradeisoutof.message"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="eolData.eolstudentoutoftimenoanswers">
<core-format-text [text]="eolData.eolstudentoutoftimenoanswers.message"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="eolData.welldone">
<core-format-text [text]="eolData.welldone.message"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="lesson.progressbar && eolData.progresscompleted">
<p>{{ 'addon.mod_lesson.progresscompleted' | translate:{$a: eolData.progresscompleted.value} }}</p>
<core-progress-bar [progress]="eolData.progresscompleted.value"></core-progress-bar>
</ion-item>
<ion-item text-wrap *ngIf="eolData.displayofgrade">
<core-format-text [text]="eolData.displayofgrade.message"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="eolData.reviewlesson">
<a ion-button block (click)="reviewLesson(eolData.reviewlesson.pageid)">
<core-format-text [text]="'addon.mod_lesson.reviewlesson' | translate"></core-format-text>
</a>
</ion-item>
<ion-item text-wrap *ngIf="eolData.modattemptsnoteacher">
<core-format-text [text]="eolData.modattemptsnoteacher.message"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="eolData.activitylink && eolData.activitylink.value">
<ng-container *ngIf="eolData.activitylink.value.formatted">
<!-- Activity link was successfully formatted, render the button. -->
<a ion-button block color="light" [href]="eolData.activitylink.value.href" core-link [capture]="true">
<core-format-text [text]="eolData.activitylink.value.label"></core-format-text>
</a>
</ng-container>
<ng-container *ngIf="!eolData.activitylink.value.formatted">
<!-- Activity link wasn't formatted, render the original link. -->
<core-format-text [text]="eolData.activitylink.value.label"></core-format-text>
</ng-container>
</ion-item>
</ion-card>
<!-- Feedback returned when processing an action. -->
<ion-list *ngIf="processData">
<ion-item text-wrap *ngIf="processData.ongoingscore && !processData.reviewmode" >
<core-format-text class="addon-mod_lesson-ongoingscore" [text]="processData.ongoingscore"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="!processData.reviewmode || review">
<p *ngIf="!processData.reviewmode">
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="processData.feedback"></core-format-text>
</p>
<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-item>
<ion-item text-wrap *ngIf="review || (processData.buttons && processData.buttons.length)">
<a ion-button block color="light" *ngIf="review" (click)="changePage(LESSON_EOL)">{{ 'addon.mod_lesson.finish' | translate }}</a>
<a ion-button block color="light" *ngFor="let button of processData.buttons" (click)="changePage(button.pageId, true)">{{ button.label | translate }}</a>
</ion-item>
</ion-list>
</div>
</core-loading>
</ion-content>

View File

@ -0,0 +1,33 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 { IonicPageModule } from 'ionic-angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { AddonModLessonPlayerPage } from './player';
@NgModule({
declarations: [
AddonModLessonPlayerPage,
],
imports: [
CoreComponentsModule,
CoreDirectivesModule,
IonicPageModule.forChild(AddonModLessonPlayerPage),
TranslateModule.forChild()
],
})
export class AddonModLessonPlayerPageModule {}

View File

@ -0,0 +1,21 @@
page-addon-mod-lesson-player {
.addon-mod_lesson-slideshow {
max-width: 100%;
max-height: 100%;
margin: 0 auto;
}
ion-input[padding-left] input[padding-left] {
// Applying padding-left to the ion-input applies it twice since it's replicated in the inner input.
padding-left: 0;
}
.addon-mod_lesson-pagebuttons .button-block {
contain: content;
height: auto;
.button-inner {
height: auto;
}
}
}

View File

@ -0,0 +1,635 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { IonicPage, NavParams, Content, PopoverController, ModalController, Modal, NavController } from 'ionic-angular';
import { TranslateService } from '@ngx-translate/core';
import { CoreAppProvider } from '@providers/app';
import { CoreEventsProvider } from '@providers/events';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSyncProvider } from '@providers/sync';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreTimeUtilsProvider } from '@providers/utils/time';
import { CoreUrlUtilsProvider } from '@providers/utils/url';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { AddonModLessonProvider } from '../../providers/lesson';
import { AddonModLessonOfflineProvider } from '../../providers/lesson-offline';
import { AddonModLessonSyncProvider } from '../../providers/lesson-sync';
import { AddonModLessonHelperProvider } from '../../providers/helper';
/**
* Page that allows attempting and reviewing a lesson.
*/
@IonicPage({ segment: 'addon-mod-lesson-player' })
@Component({
selector: 'page-addon-mod-lesson-player',
templateUrl: 'player.html',
})
export class AddonModLessonPlayerPage implements OnInit, OnDestroy {
@ViewChild(Content) content: Content;
component = AddonModLessonProvider.COMPONENT;
LESSON_EOL = AddonModLessonProvider.LESSON_EOL;
questionForm: FormGroup; // The FormGroup for question pages.
title: string; // The page title.
lesson: any; // The lesson object.
currentPage: number; // Current page being viewed.
review: boolean; // Whether the user is reviewing.
messages: any[]; // Messages to display to the user.
menuModal: Modal; // Modal to navigate through the pages.
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: any; // Current page data.
pageContent: string; // Current page contents.
pageButtons: any[]; // List of buttons of the current page.
question: any; // Question of the current page (if it's a question page).
eolData: any; // Data for EOL page (if current page is EOL).
processData: any; // Data to display after processing a page.
loaded: boolean; // Whether data has been loaded.
displayMenu: boolean; // Whether the lesson menu should be displayed.
originalData: any; // Original question data. It is used to check if data has changed.
protected courseId: number; // The course ID the lesson belongs to.
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: any; // Lesson access info.
protected jumps: any; // All possible jumps.
protected mediaFile: any; // Media file of the lesson.
protected firstPageLoaded: boolean; // Whether the first page has been loaded.
protected loadingMenu: boolean; // Whether the lesson menu is being loaded.
protected lessonPages: any[]; // Lesson pages (for the lesson menu).
constructor(protected navParams: NavParams, logger: CoreLoggerProvider, protected translate: TranslateService,
protected eventsProvider: CoreEventsProvider, protected sitesProvider: CoreSitesProvider,
protected syncProvider: CoreSyncProvider, protected domUtils: CoreDomUtilsProvider, popoverCtrl: PopoverController,
protected timeUtils: CoreTimeUtilsProvider, protected lessonProvider: AddonModLessonProvider,
protected lessonHelper: AddonModLessonHelperProvider, protected lessonSync: AddonModLessonSyncProvider,
protected lessonOfflineProvider: AddonModLessonOfflineProvider, protected cdr: ChangeDetectorRef,
modalCtrl: ModalController, protected navCtrl: NavController, protected appProvider: CoreAppProvider,
protected utils: CoreUtilsProvider, protected urlUtils: CoreUrlUtilsProvider, protected fb: FormBuilder) {
this.lessonId = navParams.get('lessonId');
this.courseId = navParams.get('courseId');
this.password = navParams.get('password');
this.review = !!navParams.get('review');
this.currentPage = navParams.get('pageId');
// Block the lesson so it cannot be synced.
this.syncProvider.blockOperation(this.component, this.lessonId);
// Create the navigation modal.
this.menuModal = modalCtrl.create('AddonModLessonMenuModalPage', {
page: this
});
}
/**
* Component being initialized.
*/
ngOnInit(): void {
// Fetch the Lesson data.
this.fetchLessonData().then((success) => {
if (success) {
// Review data loaded or new retake started, remove any retake being finished in sync.
this.lessonSync.deleteRetakeFinishedInSync(this.lessonId);
}
}).finally(() => {
this.loaded = true;
});
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
// Unblock the lesson so it can be synced.
this.syncProvider.unblockOperation(this.component, this.lessonId);
}
/**
* Check if we can leave the page or not.
*
* @return {boolean|Promise<void>} Resolved if we can leave it, rejected if not.
*/
ionViewCanLeave(): boolean | Promise<void> {
if (this.forceLeave) {
return true;
}
if (this.question && !this.eolData && !this.processData && this.originalData) {
// Question shown. Check if there is any change.
if (!this.utils.basicLeftCompare(this.questionForm.getRawValue(), this.originalData, 3)) {
return this.domUtils.showConfirm(this.translate.instant('core.confirmcanceledit'));
}
}
return Promise.resolve();
}
/**
* A button was clicked.
*
* @param {any} data Button data.
*/
buttonClicked(data: any): void {
this.processPage(data);
}
/**
* Call a function and go offline if allowed and the call fails.
*
* @param {Function} func Function to call.
* @param {any[]} args Arguments to pass to the function.
* @param {number} offlineParamPos Position of the offline parameter in the args.
* @param {number} [jumpsParamPos] Position of the jumps parameter in the args.
* @return {Promise<any>} Promise resolved in success, rejected otherwise.
*/
protected callFunction(func: Function, args: any[], offlineParamPos: number, jumpsParamPos?: number): Promise<any> {
return func.apply(func, args).catch((error) => {
if (!this.offline && !this.review && this.lessonProvider.isLessonOffline(this.lesson) &&
!this.utils.isWebServiceError(error)) {
// If it fails, go offline.
this.offline = true;
// Get the possible jumps now.
return this.lessonProvider.getPagesPossibleJumps(this.lesson.id, true).then((jumpList) => {
this.jumps = jumpList;
// Call the function again with offline set to true and the new jumps.
args[offlineParamPos] = true;
if (typeof jumpsParamPos != 'undefined') {
args[jumpsParamPos] = this.jumps;
}
return func.apply(func, args);
});
}
return Promise.reject(error);
});
}
/**
* Change the page from menu or when continuing from a feedback page.
*
* @param {number} pageId Page to load.
* @param {boolean} [ignoreCurrent] If true, allow loading current page.
*/
changePage(pageId: number, ignoreCurrent?: boolean): void {
if (!ignoreCurrent && !this.eolData && this.currentPage == pageId) {
// Page already loaded, stop.
return;
}
this.loaded = true;
this.messages = [];
this.loadPage(pageId).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error loading page');
}).finally(() => {
this.loaded = true;
});
}
/**
* Get the lesson data and load the page.
*
* @return {Promise<boolean>} Promise resolved with true if success, resolved with false otherwise.
*/
protected fetchLessonData(): Promise<boolean> {
// Wait for any ongoing sync to finish. We won't sync a lesson while it's being played.
return this.lessonSync.waitForSync(this.lessonId).then(() => {
return this.lessonProvider.getLessonById(this.courseId, this.lessonId);
}).then((lessonData) => {
this.lesson = lessonData;
this.title = this.lesson.name; // Temporary title.
// If lesson has offline data already, use offline mode.
return this.lessonOfflineProvider.hasOfflineData(this.lessonId);
}).then((offlineMode) => {
this.offline = offlineMode;
if (!offlineMode && !this.appProvider.isOnline() && this.lessonProvider.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;
}
return this.callFunction(this.lessonProvider.getAccessInformation.bind(this.lessonProvider),
[this.lesson.id, this.offline, true], 1);
}).then((info) => {
const promises = [];
this.accessInfo = info;
this.canManage = info.canmanage;
this.retake = info.attemptscount;
this.showRetake = !this.currentPage && this.retake > 0; // Only show it in first page if it isn't the first retake.
if (info.preventaccessreasons && info.preventaccessreasons.length) {
// If it's a password protected lesson and we have the password, allow playing it.
if (!this.password || info.preventaccessreasons.length > 1 || !this.lessonProvider.isPasswordProtected(info)) {
// Lesson cannot be played, show message and go back.
return Promise.reject(info.preventaccessreasons[0].message);
}
}
if (this.review && this.navParams.get('retake') != info.attemptscount - 1) {
// Reviewing a retake that isn't the last one. Error.
return Promise.reject(this.translate.instant('addon.mod_lesson.errorreviewretakenotlast'));
}
if (this.password) {
// Lesson uses password, get the whole lesson object.
promises.push(this.callFunction(this.lessonProvider.getLessonWithPassword.bind(this.lessonProvider),
[this.lesson.id, this.password, true, this.offline, true], 3).then((lesson) => {
this.lesson = lesson;
}));
}
if (this.offline) {
// Offline mode, get the list of possible jumps to allow navigation.
promises.push(this.lessonProvider.getPagesPossibleJumps(this.lesson.id, true).then((jumpList) => {
this.jumps = jumpList;
}));
}
return Promise.all(promises);
}).then(() => {
this.mediaFile = this.lesson.mediafiles && this.lesson.mediafiles[0];
this.lessonWidth = this.lesson.slideshow ? this.domUtils.formatPixelsSize(this.lesson.mediawidth) : '';
this.lessonHeight = this.lesson.slideshow ? this.domUtils.formatPixelsSize(this.lesson.mediaheight) : '';
return this.launchRetake(this.currentPage);
}).then(() => {
return true;
}).catch((error) => {
// An error occurred.
let promise;
if (this.review && this.navParams.get('retake') && this.utils.isWebServiceError(error)) {
// The user cannot review the retake. Unmark the retake as being finished in sync.
promise = this.lessonSync.deleteRetakeFinishedInSync(this.lessonId);
} else {
promise = Promise.resolve();
}
return promise.then(() => {
this.domUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true);
this.forceLeave = true;
this.navCtrl.pop();
return false;
});
});
}
/**
* Finish the retake.
*
* @param {boolean} [outOfTime] Whether the retake is finished because the user ran out of time.
* @return {Promise<any>} Promise resolved when done.
*/
protected finishRetake(outOfTime?: boolean): Promise<any> {
let promise;
this.messages = [];
if (this.offline && this.appProvider.isOnline()) {
// Offline mode but the app is online. Try to sync the data.
promise = this.lessonSync.syncLesson(this.lesson.id, true, true).then((result) => {
if (result.warnings && result.warnings.length) {
const error = result.warnings[0];
// Some data was deleted. Check if the retake has changed.
return this.lessonProvider.getAccessInformation(this.lesson.id).then((info) => {
if (info.attemptscount != this.accessInfo.attemptscount) {
// The retake has changed. Leave the view and show the error.
this.forceLeave = true;
this.navCtrl.pop();
return Promise.reject(error);
}
// Retake hasn't changed, show the warning and finish the retake in offline.
this.offline = false;
this.domUtils.showErrorModal(error);
});
}
this.offline = false;
}, () => {
// Ignore errors.
});
} else {
promise = Promise.resolve();
}
return promise.then(() => {
// Now finish the retake.
const args = [this.lesson, this.courseId, this.password, outOfTime, this.review, this.offline, this.accessInfo];
return this.callFunction(this.lessonProvider.finishRetake.bind(this.lessonProvider), args, 5);
}).then((data) => {
this.title = this.lesson.name;
this.eolData = data.data;
this.messages = this.messages.concat(data.messages);
this.processData = undefined;
// Format activity link if present.
if (this.eolData && this.eolData.activitylink) {
this.eolData.activitylink.value = this.lessonHelper.formatActivityLink(this.eolData.activitylink.value);
}
// Format review lesson if present.
if (this.eolData && this.eolData.reviewlesson) {
const params = this.urlUtils.extractUrlParams(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.eolData.reviewlesson.pageid = params.pageid;
}
}
});
}
/**
* Jump to a certain page after performing an action.
*
* @param {number} pageId The page to load.
* @return {Promise<any>} Promise resolved when done.
*/
protected jumpToPage(pageId: number): Promise<any> {
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;
this.navCtrl.pop();
return Promise.resolve();
} 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 {number} pageId The page to load.
* @return {Promise<any>} Promise resolved when done.
*/
protected launchRetake(pageId: number): Promise<any> {
let promise;
if (this.review) {
// Review mode, no need to launch the retake.
promise = Promise.resolve({});
} else if (!this.offline) {
// Not in offline mode, launch the retake.
promise = this.lessonProvider.launchRetake(this.lesson.id, this.password, pageId);
} else {
// Check if there is a finished offline retake.
promise = this.lessonOfflineProvider.hasFinishedRetake(this.lesson.id).then((finished) => {
if (finished) {
// Always show EOL page.
pageId = AddonModLessonProvider.LESSON_EOL;
}
return {};
});
}
return promise.then((data) => {
this.currentPage = pageId || this.accessInfo.firstpageid;
this.messages = data.messages || [];
if (this.lesson.timelimit && !this.accessInfo.canmanage) {
// Get the last lesson timer.
return this.lessonProvider.getTimers(this.lesson.id, false, true).then((timers) => {
this.endTime = timers[timers.length - 1].starttime + this.lesson.timelimit;
});
}
}).then(() => {
return this.loadPage(this.currentPage);
});
}
/**
* Load the lesson menu.
*
* @return {Promise<any>} Promise resolved when done.
*/
protected loadMenu(): Promise<any> {
if (this.loadingMenu) {
// Already loading.
return;
}
this.loadingMenu = true;
const args = [this.lessonId, this.password, this.offline, true];
return this.callFunction(this.lessonProvider.getPages.bind(this.lessonProvider), args, 2).then((pages) => {
this.lessonPages = pages.map((entry) => {
return entry.page;
});
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error loading menu.');
}).finally(() => {
this.loadingMenu = false;
});
}
/**
* Load a certain page.
*
* @param {number} pageId The page to load.
* @return {Promise<any>} Promise resolved when done.
*/
protected loadPage(pageId: number): Promise<any> {
if (pageId == AddonModLessonProvider.LESSON_EOL) {
// End of lesson reached.
return this.finishRetake();
}
const args = [this.lesson, pageId, this.password, this.review, true, this.offline, true, this.accessInfo, this.jumps];
return this.callFunction(this.lessonProvider.getPageData.bind(this.lessonProvider), args, 5, 8).then((data) => {
if (data.newpageid == AddonModLessonProvider.LESSON_EOL) {
// End of lesson reached.
return this.finishRetake();
}
this.pageData = data;
this.title = data.page.title;
this.pageContent = this.lessonHelper.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 (this.lessonProvider.isQuestionPage(data.page.type)) {
// Create an empty FormGroup without controls, they will be added in getQuestionFromPageData.
this.questionForm = this.fb.group({});
this.pageButtons = [];
this.question = this.lessonHelper.getQuestionFromPageData(this.questionForm, data);
this.originalData = this.questionForm.getRawValue(); // Use getRawValue to include disabled values.
} else {
this.pageButtons = this.lessonHelper.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 {any} data The data to send.
* @return {Promise<any>} Promise resolved when done.
*/
protected processPage(data: any): Promise<any> {
this.loaded = false;
const args = [this.lesson, this.courseId, this.pageData, data, this.password, this.review, this.offline, this.accessInfo,
this.jumps];
return this.callFunction(this.lessonProvider.processPage.bind(this.lessonProvider), args, 6, 8).then((result) => {
if (!this.offline && !this.review && this.lessonProvider.isLessonOffline(this.lesson)) {
// Lesson allows offline and the user changed some data in server. Update cached data.
const retake = this.accessInfo.attemptscount;
if (this.lessonProvider.isQuestionPage(this.pageData.page.type)) {
this.lessonProvider.getQuestionsAttemptsOnline(this.lessonId, retake, false, undefined, false, true);
} else {
this.lessonProvider.getContentPagesViewedOnline(this.lessonId, retake, false, true);
}
}
if (result.nodefaultresponse || result.inmediatejump) {
// Don't display feedback or force a redirect to a new page. Load the new page.
return this.jumpToPage(result.newpageid);
} else {
// Not inmediate jump, show the feedback.
result.feedback = this.lessonHelper.removeQuestionFromFeedback(result.feedback);
this.messages = result.messages;
this.processData = result;
this.processData.buttons = [];
if (this.lesson.review && !result.correctanswer && !result.noanswer && !result.isessayquestion &&
!result.maxattemptsreached && !result.reviewmode) {
// User can try again, show button to do so.
this.processData.buttons.push({
label: 'addon.mod_lesson.reviewquestionback',
pageId: this.currentPage
});
}
// Button to continue.
if (this.lesson.review && !result.correctanswer && !result.noanswer && !result.isessayquestion &&
!result.maxattemptsreached) {
this.processData.buttons.push({
label: 'addon.mod_lesson.reviewquestioncontinue',
pageId: result.newpageid
});
} else {
this.processData.buttons.push({
label: 'addon.mod_lesson.continue',
pageId: result.newpageid
});
}
}
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error processing page');
}).finally(() => {
this.loaded = true;
});
}
/**
* Review the lesson.
*
* @param {number} pageId Page to load.
*/
reviewLesson(pageId: number): void {
this.loaded = false;
this.review = true;
this.offline = false; // Don't allow offline mode in review.
this.loadPage(pageId).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error loading page');
}).finally(() => {
this.loaded = true;
});
}
/**
* Submit a question.
*/
submitQuestion(): void {
this.loaded = false;
// Use getRawValue to include disabled values.
this.lessonHelper.prepareQuestionData(this.question, this.questionForm.getRawValue()).then((data) => {
return this.processPage(data);
}).finally(() => {
this.loaded = true;
});
}
/**
* Time up.
*/
timeUp(): void {
// Time up called, hide the timer.
this.endTime = undefined;
this.loaded = false;
this.finishRetake(true).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error finishing attempt');
}).finally(() => {
this.loaded = true;
});
}
}

View File

@ -0,0 +1,159 @@
<ion-header>
<ion-navbar>
<ion-title>{{ 'addon.mod_lesson.detailedstats' | translate }}</ion-title>
</ion-navbar>
</ion-header>
<ion-content>
<ion-refresher [enabled]="loaded" (ionRefresh)="doRefresh($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="loaded">
<ion-list *ngIf="student">
<!-- Student data. -->
<a ion-item text-wrap core-user-link [userId]="student.id" [courseId]="courseId" [title]="student.fullname">
<ion-avatar *ngIf="student.profileimageurl" item-start>
<img [src]="student.profileimageurl" [alt]="'core.pictureof' | translate:{$a: student.fullname}" core-external-content role="presentation">
</ion-avatar>
<h2>{{student.fullname}}</h2>
<core-progress-bar [progress]="student.bestgrade"></core-progress-bar>
</a>
<!-- Retake selector if there is more than one retake. -->
<ion-item 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="popover">
<ion-option *ngFor="let retake of student.attempts" [value]="retake.try">{{retake.label}}</ion-option>
</ion-select>
</ion-item>
<!-- Retake stats. -->
<div *ngIf="retake && retake.userstats && retake.userstats.gradeinfo" class="addon-mod_lesson-userstats">
<ion-item text-wrap>
<ion-row>
<ion-col>
<p class="item-heading">{{ 'addon.mod_lesson.grade' | translate }}</p>
<p>{{ 'core.percentagenumber' | translate:{$a: retake.userstats.grade} }}</p>
</ion-col>
<ion-col>
<p class="item-heading">{{ 'addon.mod_lesson.rawgrade' | translate }}</p>
<p>{{ retake.userstats.gradeinfo.earned }} / {{ retake.userstats.gradeinfo.total }}</p>
</ion-col>
</ion-row>
</ion-item>
<ion-item text-wrap>
<p class="item-heading">{{ 'addon.mod_lesson.timetaken' | translate }}</p>
<p>{{ retake.userstats.timetakenReadable }}</p>
</ion-item>
<ion-item text-wrap>
<p class="item-heading">{{ 'addon.mod_lesson.completed' | translate }}</p>
<p>{{ retake.userstats.completed * 1000 | coreFormatDate:"dfmediumdate" }}</p>
</ion-item>
</div>
<!-- Not completed, no stats. -->
<ion-item text-wrap *ngIf="retake && (!retake.userstats || !retake.userstats.gradeinfo)">
{{ 'addon.mod_lesson.notcompleted' | translate }}
</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 text-wrap>
<h2>{{page.qtype}}: {{page.title}}</h2>
</ion-card-header>
<ion-item text-wrap>
<p class="item-heading">{{ 'addon.mod_lesson.question' | translate }}</p>
<p><core-format-text [component]="component" [componentId]="lesson.coursemodule" [maxHeight]="50" [text]="page.contents"></core-format-text></p>
</ion-item>
<ion-item text-wrap>
<p class="item-heading">{{ 'addon.mod_lesson.answer' | translate }}</p>
</ion-item>
<ion-item text-wrap *ngIf="!page.answerdata || !page.answerdata.answers || !page.answerdata.answers.length">
<p>{{ 'addon.mod_lesson.didnotanswerquestion' | translate }}</p>
</ion-item>
<div *ngIf="page.answerdata && page.answerdata.answers && page.answerdata.answers.length" class="addon-mod_lesson-answer">
<div *ngFor="let answer of page.answerdata.answers">
<ion-item text-wrap *ngIf="page.isContent">
<!-- Content page, display a button and the content. -->
<ion-row>
<ion-col>
<button ion-button block color="light" [disabled]="true">{{ answer[0].buttonText }}</button>
</ion-col>
<ion-col>
<p [innerHTML]="answer[0].content"></p>
</ion-col>
</ion-row>
</ion-item>
<div *ngIf="page.isQuestion">
<!-- Question page, show the right input for the answer. -->
<!-- Truefalse or matching. -->
<ion-item 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"></core-format-text></p>
<ion-badge *ngIf="answer[1]" color="dark">
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="answer[1]"></core-format-text>
</ion-badge>
</ion-label>
<ion-checkbox [attr.name]="answer[0].name" [ngModel]="answer[0].checked" [disabled]="true" item-end>
</ion-checkbox>
</ion-item>
<!-- Short answer or numeric. -->
<ion-item text-wrap *ngIf="answer[0].isText">
<p>{{ answer[0].value }}</p>
<ion-badge *ngIf="answer[1]" color="dark">
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="answer[1]"></core-format-text>
</ion-badge>
</ion-item>
<!-- Matching. -->
<ion-item text-wrap *ngIf="answer[0].isSelect">
<ion-row>
<ion-col>
<p><core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]=" answer[0].content"></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]"></core-format-text>
</ion-badge>
</ion-col>
</ion-row>
</ion-item>
<!-- Essay or couldn't determine. -->
<ion-item text-wrap *ngIf="!answer[0].isCheckbox && !answer[0].isText && !answer[0].isSelect">
<p><core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="answer[0]"></core-format-text></p>
<ion-badge *ngIf="answer[1]" color="dark">
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="answer[1]"></core-format-text>
</ion-badge>
</ion-item>
</div>
<ion-item text-wrap *ngIf="!page.isContent && !page.isQuestion">
<!-- Another page (end of branch, ...). -->
<p><core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="answer[0]"></core-format-text></p>
<ion-badge *ngIf="answer[1]" color="dark">
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="answer[1]"></core-format-text>
</ion-badge>
</ion-item>
</div>
<ion-item text-wrap *ngIf="page.answerdata.response">
<p class="item-heading">{{ 'addon.mod_lesson.response' | translate }}</p>
<p><core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="page.answerdata.response"></core-format-text></p>
</ion-item>
<ion-item text-wrap *ngIf="page.answerdata.score">
<p>{{page.answerdata.score}}</p>
</ion-item>
</div>
</ion-card>
</ng-container>
</ion-list>
</core-loading>
</ion-content>

View File

@ -0,0 +1,35 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 { IonicPageModule } from 'ionic-angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CorePipesModule } from '@pipes/pipes.module';
import { AddonModLessonUserRetakePage } from './user-retake';
@NgModule({
declarations: [
AddonModLessonUserRetakePage,
],
imports: [
CoreComponentsModule,
CoreDirectivesModule,
CorePipesModule,
IonicPageModule.forChild(AddonModLessonUserRetakePage),
TranslateModule.forChild()
],
})
export class AddonModLessonUserRetakePageModule {}

View File

@ -0,0 +1,8 @@
page-addon-mod-lesson-user-retake {
.addon-mod_lesson-highlight {
background: $blue-light;
.label, .label p {
color: $blue-dark;
}
}
}

View File

@ -0,0 +1,228 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 { IonicPage, NavParams } from 'ionic-angular';
import { TranslateService } from '@ngx-translate/core';
import { CoreSitesProvider } from '@providers/sites';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreTimeUtilsProvider } from '@providers/utils/time';
import { CoreUserProvider } from '@core/user/providers/user';
import { AddonModLessonProvider } from '../../providers/lesson';
import { AddonModLessonHelperProvider } from '../../providers/helper';
/**
* Page that displays a retake made by a certain user.
*/
@IonicPage({ segment: 'addon-mod-lesson-user-retake' })
@Component({
selector: 'page-addon-mod-lesson-user-retake',
templateUrl: 'user-retake.html',
})
export class AddonModLessonUserRetakePage implements OnInit {
component = AddonModLessonProvider.COMPONENT;
lesson: any; // The lesson the retake belongs to.
courseId: number; // Course ID the lesson belongs to.
selectedRetake: number; // The retake to see.
student: any; // Data about the student and his retakes.
retake: any; // Data about the retake.
loaded: boolean; // Whether the data has been loaded.
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.
constructor(navParams: NavParams, sitesProvider: CoreSitesProvider, protected textUtils: CoreTextUtilsProvider,
protected translate: TranslateService, protected domUtils: CoreDomUtilsProvider,
protected userProvider: CoreUserProvider, protected timeUtils: CoreTimeUtilsProvider,
protected lessonProvider: AddonModLessonProvider, protected lessonHelper: AddonModLessonHelperProvider) {
this.lessonId = navParams.get('lessonId');
this.courseId = navParams.get('courseId');
this.userId = navParams.get('userId') || sitesProvider.getCurrentSiteUserId();
this.retakeNumber = navParams.get('retake');
}
/**
* Component being initialized.
*/
ngOnInit(): void {
// Fetch the data.
this.fetchData().finally(() => {
this.loaded = true;
});
}
/**
* Change the retake displayed.
*
* @param {number} retakeNumber The new retake number.
*/
changeRetake(retakeNumber: number): void {
this.loaded = false;
this.setRetake(retakeNumber).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error getting attempt.');
}).finally(() => {
this.loaded = true;
});
}
/**
* Pull to refresh.
*
* @param {any} refresher Refresher.
*/
doRefresh(refresher: any): void {
this.refreshData().finally(() => {
refresher.complete();
});
}
/**
* Get lesson and retake data.
*
* @return {Promise<any>} Promise resolved when done.
*/
protected fetchData(): Promise<any> {
return this.lessonProvider.getLessonById(this.courseId, this.lessonId).then((lessonData) => {
this.lesson = lessonData;
// Get the retakes overview for all participants.
return this.lessonProvider.getRetakesOverview(this.lesson.id);
}).then((data) => {
// Search the student.
let student;
if (data && data.students) {
for (let i = 0; i < data.students.length; i++) {
if (data.students[i].id == this.userId) {
student = data.students[i];
break;
}
}
}
if (!student) {
// Student not found.
return Promise.reject(this.translate.instant('addon.mod_lesson.cannotfinduser'));
}
if (!student.attempts || !student.attempts.length) {
// No retakes.
return Promise.reject(this.translate.instant('addon.mod_lesson.cannotfindattempt'));
}
student.bestgrade = this.textUtils.roundToDecimals(student.bestgrade, 2);
student.attempts.forEach((retake) => {
if (this.retakeNumber == retake.try) {
// The retake specified as parameter exists. Use it.
this.selectedRetake = this.retakeNumber;
}
retake.label = this.lessonHelper.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.
return this.userProvider.getProfile(student.id, this.courseId, true).then((user) => {
student.profileimageurl = user.profileimageurl;
return student;
}).catch(() => {
// Error getting profile, resolve promise without adding any extra data.
return student;
});
}).then((student) => {
this.student = student;
return this.setRetake(this.selectedRetake);
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error getting data.', true);
});
}
/**
* Refreshes data.
*
* @return {Promise<any>} Promise resolved when done.
*/
protected refreshData(): Promise<any> {
const promises = [];
promises.push(this.lessonProvider.invalidateLessonData(this.courseId));
if (this.lesson) {
promises.push(this.lessonProvider.invalidateRetakesOverview(this.lesson.id));
promises.push(this.lessonProvider.invalidateUserRetakesForUser(this.lesson.id, this.userId));
}
return Promise.all(promises).catch(() => {
// Ignore errors.
}).then(() => {
return this.fetchData();
});
}
/**
* Set the retake to view and load its data.
*
* @param {number}retakeNumber Retake number to set.
* @return {Promise<any>} Promise resolved when done.
*/
protected setRetake(retakeNumber: number): Promise<any> {
this.selectedRetake = retakeNumber;
return this.lessonProvider.getUserRetake(this.lessonId, retakeNumber, this.userId).then((data) => {
if (data && data.completed != -1) {
// Completed.
data.userstats.grade = this.textUtils.roundToDecimals(data.userstats.grade, 2);
data.userstats.timetakenReadable = this.timeUtils.formatTime(data.userstats.timetotake);
}
if (data && data.answerpages) {
// Format pages data.
data.answerpages.forEach((page) => {
if (this.lessonProvider.answerPageIsContent(page)) {
page.isContent = true;
if (page.answerdata && page.answerdata.answers) {
page.answerdata.answers.forEach((answer) => {
// Content pages only have 1 valid field in the answer array.
answer[0] = this.lessonHelper.getContentPageAnswerDataFromHtml(answer[0]);
});
}
} else if (this.lessonProvider.answerPageIsQuestion(page)) {
page.isQuestion = true;
if (page.answerdata && page.answerdata.answers) {
page.answerdata.answers.forEach((answer) => {
// Only the first field of the answer array requires to be parsed.
answer[0] = this.lessonHelper.getQuestionPageAnswerDataFromHtml(answer[0]);
});
}
}
});
}
this.retake = data;
});
}
}

View File

@ -0,0 +1,95 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 { NavController } from 'ionic-angular';
import { CoreSitesProvider } from '@providers/sites';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreContentLinksModuleGradeHandler } from '@core/contentlinks/classes/module-grade-handler';
import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper';
import { CoreCourseProvider } from '@core/course/providers/course';
import { CoreCourseHelperProvider } from '@core/course/providers/helper';
import { AddonModLessonProvider } from './lesson';
/**
* Handler to treat links to lesson grade.
*/
@Injectable()
export class AddonModLessonGradeLinkHandler extends CoreContentLinksModuleGradeHandler {
name = 'AddonModLessonGradeLinkHandler';
canReview = true;
constructor(courseHelper: CoreCourseHelperProvider, domUtils: CoreDomUtilsProvider, sitesProvider: CoreSitesProvider,
protected lessonProvider: AddonModLessonProvider, protected courseProvider: CoreCourseProvider,
protected linkHelper: CoreContentLinksHelperProvider) {
super(courseHelper, domUtils, sitesProvider, 'AddonModLesson', 'lesson');
}
/**
* Go to the page to review.
*
* @param {string} url The URL to treat.
* @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param {number} courseId Course ID related to the URL.
* @param {string} siteId Site to use.
* @param {NavController} [navCtrl] Nav Controller to use to navigate.
* @return {Promise<any>} Promise resolved when done.
*/
protected goToReview(url: string, params: any, courseId: number, siteId: string, navCtrl?: NavController): Promise<any> {
const moduleId = parseInt(params.id, 10),
modal = this.domUtils.showModalLoading();
let module;
return this.courseProvider.getModuleBasicInfo(moduleId, siteId).then((mod) => {
module = mod;
courseId = module.course || courseId || params.courseid || params.cid;
// Check if the user can see the user reports in the lesson.
return this.lessonProvider.getAccessInformation(module.instance);
}).then((info) => {
if (info.canviewreports) {
// User can view reports, go to view the report.
const pageParams = {
courseId: Number(courseId),
lessonId: module.instance,
userId: parseInt(params.userid, 10)
};
this.linkHelper.goInSite(navCtrl, 'AddonModLessonUserRetakePage', pageParams, siteId);
} else {
// User cannot view the report, go to lesson index.
this.courseHelper.navigateToModule(moduleId, siteId, courseId, module.section);
}
}).catch((error) => {
this.domUtils.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 {string} siteId The site ID.
* @param {string} url The URL to treat.
* @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param {number} [courseId] Course ID related to the URL. Optional but recommended.
* @return {boolean|Promise<boolean>} Whether the handler is enabled for the URL and site.
*/
isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise<boolean> {
return this.lessonProvider.isPluginEnabled();
}
}

View File

@ -0,0 +1,494 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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, FormGroup } from '@angular/forms';
import { TranslateService } from '@ngx-translate/core';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreTimeUtilsProvider } from '@providers/utils/time';
import { AddonModLessonProvider } from './lesson';
import * as moment from 'moment';
/**
* Helper service that provides some features for quiz.
*/
@Injectable()
export class AddonModLessonHelperProvider {
protected div = document.createElement('div'); // A div element to search in HTML code.
constructor(private domUtils: CoreDomUtilsProvider, private fb: FormBuilder, private translate: TranslateService,
private textUtils: CoreTextUtilsProvider, private timeUtils: CoreTimeUtilsProvider) { }
/**
* Given the HTML of next activity link, format it to extract the href and the text.
*
* @param {string} activityLink HTML of the activity link.
* @return {{formatted: boolean, label: string, href: string}} Formatted data.
*/
formatActivityLink(activityLink: string): {formatted: boolean, label: string, href: string} {
this.div.innerHTML = activityLink;
const anchor = this.div.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 {String} html Answer's HTML.
* @return {{buttonText: string, content: string}} Data to render the answer.
*/
getContentPageAnswerDataFromHtml(html: string): {buttonText: string, content: string} {
const data = {
buttonText: '',
content: ''
};
// Search the input button.
this.div.innerHTML = html;
const button = <HTMLInputElement> this.div.querySelector('input[type="button"]');
if (button) {
// Extract the button content and remove it from the HTML.
data.buttonText = button.value;
button.remove();
}
data.content = this.div.innerHTML.trim();
return data;
}
/**
* Get the buttons to change pages.
*
* @param {string} html Page's HTML.
* @return {any[]} List of buttons.
*/
getPageButtonsFromHtml(html: string): any[] {
const buttons = [];
// Get the container of the buttons if it exists.
this.div.innerHTML = html;
let buttonsContainer = this.div.querySelector('.branchbuttoncontainer');
if (!buttonsContainer) {
// Button container not found, might be a legacy lesson (from 1.9).
if (!this.div.querySelector('form input[type="submit"]')) {
// No buttons found.
return buttons;
}
buttonsContainer = this.div;
}
const forms = Array.from(buttonsContainer.querySelectorAll('form'));
forms.forEach((form) => {
const buttonSelector = 'input[type="submit"], button[type="submit"]',
buttonEl = <HTMLInputElement | HTMLButtonElement> form.querySelector(buttonSelector),
inputs = Array.from(form.querySelectorAll('input'));
if (!buttonEl || !inputs || !inputs.length) {
// Button not found or no inputs, ignore it.
return;
}
const button = {
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 (result of getPageData), get the page contents.
*
* @param {any} data Page data.
* @return {string} Page contents.
*/
getPageContentsFromPageData(data: any): string {
// Search the page contents inside the whole page HTML. Use data.pagecontent because it's filtered.
this.div.innerHTML = data.pagecontent;
const contents = this.div.querySelector('.contents');
if (contents) {
return contents.innerHTML.trim();
}
// Cannot find contents element, return the page.contents (some elements like videos might not work).
return data.page.contents;
}
/**
* Get a question and all the data required to render it from the page data (result of AddonModLessonProvider.getPageData).
*
* @param {FormGroup} questionForm The form group where to add the controls.
* @param {any} pageData Page data (result of $mmaModLesson#getPageData).
* @return {any} Question data.
*/
getQuestionFromPageData(questionForm: FormGroup, pageData: any): any {
const question: any = {};
// Get the container of the question answers if it exists.
this.div.innerHTML = pageData.pagecontent;
const fieldContainer = this.div.querySelector('.fcontainer');
// Get hidden inputs and add their data to the form group.
const hiddenInputs = <HTMLInputElement[]> Array.from(this.div.querySelectorAll('input[type="hidden"]'));
hiddenInputs.forEach((input) => {
questionForm.addControl(input.name, this.fb.control(input.value));
});
// Get the submit button and extract its value.
const submitButton = <HTMLInputElement> this.div.querySelector('input[type="submit"]');
question.submitLabel = submitButton ? submitButton.value : this.translate.instant('addon.mod_lesson.submit');
if (!fieldContainer) {
// Element not found, return.
return question;
}
let type;
switch (pageData.page.qtype) {
case AddonModLessonProvider.LESSON_PAGE_TRUEFALSE:
case AddonModLessonProvider.LESSON_PAGE_MULTICHOICE:
question.template = 'multichoice';
question.options = [];
// 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.
question.multi = true;
inputs = <HTMLInputElement[]> Array.from(fieldContainer.querySelectorAll('input[type="checkbox"]'));
if (!inputs || !inputs.length) {
// No checkbox found either. Stop.
return question;
}
}
let controlAdded = false;
inputs.forEach((input) => {
const option: any = {
id: input.id,
name: input.name,
value: input.value,
checked: !!input.checked,
disabled: !!input.disabled
},
parent = input.parentElement;
if (option.checked || question.multi) {
// Add the control.
const value = question.multi ? {value: option.checked, disabled: option.disabled} : option.value;
questionForm.addControl(option.name, this.fb.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();
question.options.push(option);
});
if (!question.multi) {
question.controlName = inputs[0].name; // All option have the same name in single choice.
if (!controlAdded) {
// No checked option for single choice, add the control with an empty value.
questionForm.addControl(question.controlName, this.fb.control(''));
}
}
break;
case AddonModLessonProvider.LESSON_PAGE_NUMERICAL:
type = 'number';
case AddonModLessonProvider.LESSON_PAGE_SHORTANSWER:
question.template = 'shortanswer';
// Get the input.
const input = <HTMLInputElement> fieldContainer.querySelector('input[type="text"], input[type="number"]');
if (!input) {
return question;
}
question.input = {
id: input.id,
name: input.name,
maxlength: input.maxLength,
type: type || 'text'
};
// Init the control.
questionForm.addControl(input.name, this.fb.control({value: input.value, disabled: input.readOnly}));
break;
case AddonModLessonProvider.LESSON_PAGE_ESSAY:
question.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 question;
}
question.useranswer = answerEl.innerHTML;
} else {
question.textarea = {
id: textarea.id,
name: textarea.name || 'answer[text]'
};
// Init the control.
question.control = this.fb.control('');
questionForm.addControl(question.textarea.name, question.control);
}
break;
case AddonModLessonProvider.LESSON_PAGE_MATCHING:
question.template = 'matching';
const rows = Array.from(fieldContainer.querySelectorAll('.answeroption'));
question.rows = [];
rows.forEach((row) => {
const label = row.querySelector('label'),
select = row.querySelector('select'),
options = Array.from(row.querySelectorAll('option')),
rowData: any = {};
if (!label || !select || !options || !options.length) {
return;
}
// Get the row's text (label).
rowData.text = label.innerHTML.trim();
rowData.id = select.id;
rowData.name = select.name;
rowData.options = [];
// Treat each option.
let controlAdded = false;
options.forEach((option) => {
if (typeof option.value == 'undefined') {
// Option not valid, ignore it.
return;
}
const opt = {
value: option.value,
label: option.innerHTML.trim(),
selected: option.selected
};
if (opt.selected) {
controlAdded = true;
questionForm.addControl(rowData.name, this.fb.control({value: opt.value, disabled: !!select.disabled}));
}
rowData.options.push(opt);
});
if (!controlAdded) {
// No selected option, add the control with an empty value.
questionForm.addControl(rowData.name, this.fb.control({value: '', disabled: !!select.disabled}));
}
question.rows.push(rowData);
});
break;
default:
// Nothing to do.
}
return question;
}
/**
* Given the HTML of an answer from a question page, extract the data to render the answer.
*
* @param {string} html Answer's HTML.
* @return {any} 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): any {
const data: any = {};
this.div.innerHTML = html;
// Check if it has a checkbox.
let input = <HTMLInputElement> this.div.querySelector('input[type="checkbox"][name*="answer"]');
if (input) {
// Truefalse or multichoice.
data.isCheckbox = true;
data.checked = !!input.checked;
data.name = input.name;
data.highlight = !!this.div.querySelector('.highlight');
input.remove();
data.content = this.div.innerHTML.trim();
return data;
}
// Check if it has an input text or number.
input = <HTMLInputElement> this.div.querySelector('input[type="number"],input[type="text"]');
if (input) {
// Short answer or numeric.
data.isText = true;
data.value = input.value;
return data;
}
// Check if it has a select.
const select = <HTMLSelectElement> this.div.querySelector('select');
if (select && select.options) {
// Matching.
const selectedOption = select.options[select.selectedIndex];
data.isSelect = true;
data.id = select.id;
if (selectedOption) {
data.value = selectedOption.value;
} else {
data.value = '';
}
select.remove();
data.content = this.div.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 {any} retake Retake object.
* @param {boolean} [includeDuration] Whether to include the duration of the retake.
* @return {string} Retake label.
*/
getRetakeLabel(retake: any, includeDuration?: boolean): string {
const data = {
retake: retake.try + 1,
grade: '',
timestart: '',
duration: ''
},
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 = this.translate.instant('core.percentagenumber', {$a: retake.grade});
}
data.timestart = moment(retake.timestart * 1000).format('LLL');
if (includeDuration) {
data.duration = this.timeUtils.formatTime(retake.timeend - retake.timestart);
}
} else {
// The user has not completed the retake.
data.grade = this.translate.instant('addon.mod_lesson.notcompleted');
if (retake.timestart) {
data.timestart = moment(retake.timestart * 1000).format('LLL');
}
}
return this.translate.instant('addon.mod_lesson.retakelabel' + (includeDuration ? 'full' : 'short'), data);
}
/**
* Prepare the question data to be sent to server.
*
* @param {any} question Question to prepare.
* @param {any} data Data to prepare.
* @return {Promise<any>} Promise resolved with the data to send when done.
*/
prepareQuestionData(question: any, data: any): Promise<any> {
if (question.template == 'essay' && question.textarea) {
// The answer might need formatting. Check if rich text editor is enabled or not.
return this.domUtils.isRichTextEditorEnabled().then((enabled) => {
if (!enabled) {
// Rich text editor not enabled, add some HTML to the answer if needed.
data[question.textarea.property] = this.textUtils.formatHtmlLines(data[question.textarea.property]);
}
return data;
});
} else if (question.template == 'multichoice' && 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 Promise.resolve(data);
}
/**
* Given the feedback of a process page in HTML, remove the question text.
*
* @param {string} html Feedback's HTML.
* @return {string} Feedback without the question text.
*/
removeQuestionFromFeedback(html: string): string {
this.div.innerHTML = html;
// Remove the question text.
this.domUtils.removeElement(this.div, '.generalbox:not(.feedback):not(.correctanswer)');
return this.div.innerHTML.trim();
}
}

View File

@ -0,0 +1,105 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreContentLinksModuleIndexHandler } from '@core/contentlinks/classes/module-index-handler';
import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate';
import { CoreCourseProvider } from '@core/course/providers/course';
import { CoreCourseHelperProvider } from '@core/course/providers/helper';
import { AddonModLessonProvider } from './lesson';
/**
* Handler to treat links to lesson index.
*/
@Injectable()
export class AddonModLessonIndexLinkHandler extends CoreContentLinksModuleIndexHandler {
name = 'AddonModLessonIndexLinkHandler';
constructor(courseHelper: CoreCourseHelperProvider, protected lessonProvider: AddonModLessonProvider,
protected domUtils: CoreDomUtilsProvider, protected courseProvider: CoreCourseProvider) {
super(courseHelper, 'AddonModLesson', 'lesson');
}
/**
* Get the list of actions for a link (url).
*
* @param {string[]} siteIds List of sites the URL belongs to.
* @param {string} url The URL to treat.
* @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param {number} [courseId] Course ID related to the URL. Optional but recommended.
* @return {CoreContentLinksAction[]|Promise<CoreContentLinksAction[]>} List of (or promise resolved with list of) actions.
*/
getActions(siteIds: string[], url: string, params: any, courseId?: number):
CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
courseId = courseId || params.courseid || params.cid;
return [{
action: (siteId, navCtrl?): 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 {
this.courseHelper.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 {string} siteId The site ID.
* @param {string} url The URL to treat.
* @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param {number} [courseId] Course ID related to the URL. Optional but recommended.
* @return {boolean|Promise<boolean>} Whether the handler is enabled for the URL and site.
*/
isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise<boolean> {
return this.lessonProvider.isPluginEnabled();
}
/**
* Navigate to a lesson module (index page) with a fixed password.
*
* @param {number} moduleId Module ID.
* @param {number} courseId Course ID.
* @param {string} password Password.
* @param {string} siteId Site ID.
* @return {Promise<any>} Promise resolved when navigated.
*/
protected navigateToModuleWithPassword(moduleId: number, courseId: number, password: string, siteId: string): Promise<any> {
const modal = this.domUtils.showModalLoading();
// Get the module.
return this.courseProvider.getModuleBasicInfo(moduleId, siteId).then((module) => {
courseId = courseId || module.course;
// Store the password so it's automatically used.
return this.lessonProvider.storePassword(parseInt(module.instance, 10), password, siteId).catch(() => {
// Ignore errors.
}).then(() => {
return this.courseHelper.navigateToModule(moduleId, siteId, courseId, module.section);
});
}).catch(() => {
// Error, go to index page.
return this.courseHelper.navigateToModule(moduleId, siteId, courseId);
}).finally(() => {
modal.dismiss();
});
}
}

View File

@ -0,0 +1,598 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 { CoreLoggerProvider } from '@providers/logger';
import { CoreSitesProvider } from '@providers/sites';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreTimeUtilsProvider } from '@providers/utils/time';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { AddonModLessonProvider } from './lesson';
/**
* Service to handle offline lesson.
*/
@Injectable()
export class AddonModLessonOfflineProvider {
protected logger;
// Variables for database. We use lowercase in the names to match the WS responses.
protected RETAKES_TABLE = 'addon_mod_lesson_retakes';
protected PAGE_ATTEMPTS_TABLE = 'addon_mod_lesson_page_attempts';
protected tablesSchema = [
{
name: this.RETAKES_TABLE,
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: this.PAGE_ATTEMPTS_TABLE,
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'
},
],
primaryKeys: ['lessonid', 'retake', 'pageid', 'timemodified'] // A user can attempt several times per page and retake.
}
];
constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private timeUtils: CoreTimeUtilsProvider,
private textUtils: CoreTextUtilsProvider, private utils: CoreUtilsProvider) {
this.logger = logger.getInstance('AddonModLessonOfflineProvider');
this.sitesProvider.createTablesFromSchema(this.tablesSchema);
}
/**
* Delete an offline attempt.
*
* @param {number} lessonId Lesson ID.
* @param {number} retake Lesson retake number.
* @param {number} pageId Page ID.
* @param {number} timemodified The timemodified of the attempt.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when done.
*/
deleteAttempt(lessonId: number, retake: number, pageId: number, timemodified: number, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
return site.getDb().deleteRecords(this.PAGE_ATTEMPTS_TABLE, {
lessonid: lessonId,
retake: retake,
pageid: pageId,
timemodified: timemodified
});
});
}
/**
* Delete offline lesson retake.
*
* @param {number} lessonId Lesson ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when done.
*/
deleteRetake(lessonId: number, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
return site.getDb().deleteRecords(this.RETAKES_TABLE, {lessonid: lessonId});
});
}
/**
* Delete offline attempts for a retake and page.
*
* @param {number} lessonId Lesson ID.
* @param {number} retake Lesson retake number.
* @param {number} pageId Page ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when done.
*/
deleteRetakeAttemptsForPage(lessonId: number, retake: number, pageId: number, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
return site.getDb().deleteRecords(this.PAGE_ATTEMPTS_TABLE, {lessonid: lessonId, retake: retake, pageid: pageId});
});
}
/**
* Mark a retake as finished.
*
* @param {number} lessonId Lesson ID.
* @param {number} courseId Course ID the lesson belongs to.
* @param {number} retake Retake number.
* @param {boolean} finished Whether retake is finished.
* @param {boolean} outOfTime If the user ran out of time.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved in success, rejected otherwise.
*/
finishRetake(lessonId: number, courseId: number, retake: number, finished?: boolean, outOfTime?: boolean, siteId?: string)
: Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
// Get current stored retake (if any). If not found, it will create a new one.
return this.getRetakeWithFallback(lessonId, courseId, retake, site.id).then((entry) => {
entry.finished = finished ? 1 : 0;
entry.outoftime = outOfTime ? 1 : 0;
entry.timemodified = this.timeUtils.timestamp();
return site.getDb().insertRecord(this.RETAKES_TABLE, entry);
});
});
}
/**
* Get all the offline page attempts in a certain site.
*
* @param {string} [siteId] Site ID. If not set, use current site.
* @return {Promise<any>} Promise resolved when the offline attempts are retrieved.
*/
getAllAttempts(siteId?: string): Promise<any> {
return this.sitesProvider.getSiteDb(siteId).then((db) => {
return db.getAllRecords(this.PAGE_ATTEMPTS_TABLE);
}).then((attempts) => {
return this.parsePageAttempts(attempts);
});
}
/**
* Get all the lessons that have offline data in a certain site.
*
* @param {string} [siteId] Site ID. If not set, use current site.
* @return {Promise<any>} Promise resolved with an object containing the lessons.
*/
getAllLessonsWithData(siteId?: string): Promise<any> {
const promises = [],
lessons = {};
// Get the lessons from page attempts.
promises.push(this.getAllAttempts(siteId).then((entries) => {
this.getLessonsFromEntries(lessons, entries);
}).catch(() => {
// Ignore errors.
}));
// Get the lessons from retakes.
promises.push(this.getAllRetakes(siteId).then((entries) => {
this.getLessonsFromEntries(lessons, entries);
}).catch(() => {
// Ignore errors.
}));
return Promise.all(promises).then(() => {
return this.utils.objectToArray(lessons);
});
}
/**
* Get all the offline retakes in a certain site.
*
* @param {string} [siteId] Site ID. If not set, use current site.
* @return {Promise<any>} Promise resolved when the offline retakes are retrieved.
*/
getAllRetakes(siteId?: string): Promise<any> {
return this.sitesProvider.getSiteDb(siteId).then((db) => {
return db.getAllRecords(this.RETAKES_TABLE);
});
}
/**
* Retrieve the last offline attempt stored in a retake.
*
* @param {number} lessonId Lesson ID.
* @param {number} retake Retake number.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved with the attempt (undefined if no attempts).
*/
getLastQuestionPageAttempt(lessonId: number, retake: number, siteId?: string): Promise<any> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
return this.getRetakeWithFallback(lessonId, 0, retake, siteId).then((retakeData) => {
if (!retakeData.lastquestionpage) {
// No question page attempted.
return;
}
return this.getRetakeAttemptsForPage(lessonId, retake, retakeData.lastquestionpage, siteId).then((attempts) => {
// Return the attempt with highest timemodified.
return attempts.reduce((a, b) => {
return a.timemodified > b.timemodified ? a : b;
});
});
}).catch(() => {
// Error, return undefined.
});
}
/**
* Retrieve all offline attempts for a lesson.
*
* @param {number} lessonId Lesson ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any[]>} Promise resolved with the attempts.
*/
getLessonAttempts(lessonId: number, siteId?: string): Promise<any[]> {
return this.sitesProvider.getSite(siteId).then((site) => {
return site.getDb().getRecords(this.PAGE_ATTEMPTS_TABLE, {lessonid: lessonId});
}).then((attempts) => {
return this.parsePageAttempts(attempts);
});
}
/**
* Given a list of DB entries (either retakes or page attempts), get the list of lessons.
*
* @param {any} lessons Object where to store the lessons.
* @param {any[]} entries List of DB entries.
*/
protected getLessonsFromEntries(lessons: any, entries: any[]): 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 {number} lessonId Lesson ID.
* @param {number} retake Retake number.
* @param {boolean} [correct] True to only fetch correct attempts, false to get them all.
* @param {number} [pageId] If defined, only get attempts on this page.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any[]>} Promise resolved with the attempts.
*/
getQuestionsAttempts(lessonId: number, retake: number, correct?: boolean, pageId?: number, siteId?: string): Promise<any[]> {
let promise;
if (pageId) {
// Page ID is set, only get the attempts for that page.
promise = this.getRetakeAttemptsForPage(lessonId, retake, pageId, siteId);
} else {
// Page ID not specified, get all the attempts.
promise = this.getRetakeAttemptsForType(lessonId, retake, AddonModLessonProvider.TYPE_QUESTION, siteId);
}
return promise.then((attempts) => {
if (correct) {
return attempts.filter((attempt) => {
return !!attempt.correct;
});
}
return attempts;
});
}
/**
* Retrieve a retake from site DB.
*
* @param {number} lessonId Lesson ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved with the retake.
*/
getRetake(lessonId: number, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
return site.getDb().getRecord(this.RETAKES_TABLE, {lessonid: lessonId});
});
}
/**
* Retrieve all offline attempts for a retake.
*
* @param {number} lessonId Lesson ID.
* @param {number} retake Retake number.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any[]>} Promise resolved with the retake attempts.
*/
getRetakeAttempts(lessonId: number, retake: number, siteId?: string): Promise<any[]> {
return this.sitesProvider.getSite(siteId).then((site) => {
return site.getDb().getRecords(this.PAGE_ATTEMPTS_TABLE, {lessonid: lessonId, retake: retake});
}).then((attempts) => {
return this.parsePageAttempts(attempts);
});
}
/**
* Retrieve offline attempts for a retake and page.
*
* @param {number} lessonId Lesson ID.
* @param {number} retake Lesson retake number.
* @param {number} pageId Page ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved with the retake attempts.
*/
getRetakeAttemptsForPage(lessonId: number, retake: number, pageId: number, siteId?: string): Promise<any[]> {
return this.sitesProvider.getSite(siteId).then((site) => {
return site.getDb().getRecords(this.PAGE_ATTEMPTS_TABLE, {lessonid: lessonId, retake: retake, pageid: pageId});
}).then((attempts) => {
return this.parsePageAttempts(attempts);
});
}
/**
* Retrieve offline attempts for certain pages for a retake.
*
* @param {number} lessonId Lesson ID.
* @param {number} retake Retake number.
* @param {number} type Type of the pages to get: TYPE_QUESTION or TYPE_STRUCTURE.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved with the retake attempts.
*/
getRetakeAttemptsForType(lessonId: number, retake: number, type: number, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
return site.getDb().getRecords(this.PAGE_ATTEMPTS_TABLE, {lessonid: lessonId, retake: retake, type: type});
}).then((attempts) => {
return this.parsePageAttempts(attempts);
});
}
/**
* Get stored retake. If not found or doesn't match the retake number, return a new one.
*
* @param {number} lessonId Lesson ID.
* @param {number} courseId Course ID the lesson belongs to.
* @param {number} retake Retake number.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved with the retake.
*/
protected getRetakeWithFallback(lessonId: number, courseId: number, retake: number, siteId?: string): Promise<any> {
// Get current stored retake.
return this.getRetake(lessonId, siteId).then((retakeData) => {
if (retakeData.retake != retake) {
// The stored retake doesn't match the retake number, create a new one.
return Promise.reject(null);
}
return retakeData;
}).catch(() => {
// No retake, create a new one.
return {
lessonid: lessonId,
retake: retake,
courseid: courseId,
finished: 0
};
});
}
/**
* Check if there is a finished retake for a certain lesson.
*
* @param {number} lessonId Lesson ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<boolean>} Promise resolved with boolean.
*/
hasFinishedRetake(lessonId: number, siteId?: string): Promise<boolean> {
return this.getRetake(lessonId, siteId).then((retake) => {
return !!retake.finished;
}).catch(() => {
return false;
});
}
/**
* Check if a lesson has offline data.
*
* @param {number} lessonId Lesson ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<boolean>} Promise resolved with boolean.
*/
hasOfflineData(lessonId: number, siteId?: string): Promise<boolean> {
const promises = [];
let hasData = false;
promises.push(this.getRetake(lessonId, siteId).then(() => {
hasData = true;
}).catch(() => {
// Ignore errors.
}));
promises.push(this.getLessonAttempts(lessonId, siteId).then((attempts) => {
hasData = hasData || !!attempts.length;
}).catch(() => {
// Ignore errors.
}));
return Promise.all(promises).then(() => {
return hasData;
});
}
/**
* Check if there are offline attempts for a retake.
*
* @param {number} lessonId Lesson ID.
* @param {number} retake Retake number.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<boolean>} Promise resolved with a boolean.
*/
hasRetakeAttempts(lessonId: number, retake: number, siteId?: string): Promise<boolean> {
return this.getRetakeAttempts(lessonId, retake, siteId).then((list) => {
return !!list.length;
}).catch(() => {
return false;
});
}
/**
* Parse some properties of a page attempt.
*
* @param {any} attempt The attempt to treat.
* @return {any} The treated attempt.
*/
protected parsePageAttempt(attempt: any): any {
attempt.data = this.textUtils.parseJSON(attempt.data);
attempt.useranswer = this.textUtils.parseJSON(attempt.useranswer);
return attempt;
}
/**
* Parse some properties of some page attempts.
*
* @param {any[]} attempts The attempts to treat.
* @return {any[]} The treated attempts.
*/
protected parsePageAttempts(attempts: any[]): any[] {
attempts.forEach((attempt) => {
this.parsePageAttempt(attempt);
});
return attempts;
}
/**
* Process a lesson page, saving its data.
*
* @param {number} lessonId Lesson ID.
* @param {number} courseId Course ID the lesson belongs to.
* @param {number} retake Retake number.
* @param {any} page Page.
* @param {any} data Data to save.
* @param {number} newPageId New page ID (calculated).
* @param {number} [answerId] The answer ID that the user answered.
* @param {boolean} [correct] If answer is correct. Only for question pages.
* @param {any} [userAnswer] The user's answer (userresponse from checkAnswer).
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved in success, rejected otherwise.
*/
processPage(lessonId: number, courseId: number, retake: number, page: any, data: any, newPageId: number, answerId?: number,
correct?: boolean, userAnswer?: any, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
const entry = {
lessonid: lessonId,
retake: retake,
pageid: page.id,
timemodified: this.timeUtils.timestamp(),
courseid: courseId,
data: data ? JSON.stringify(data) : null,
type: page.type,
newpageid: newPageId,
correct: correct ? 1 : 0,
answerid: Number(answerId),
useranswer: userAnswer ? JSON.stringify(userAnswer) : null,
};
return site.getDb().insertRecord(this.PAGE_ATTEMPTS_TABLE, entry);
}).then(() => {
if (page.type == AddonModLessonProvider.TYPE_QUESTION) {
// It's a question page, set it as last question page attempted.
return this.setLastQuestionPageAttempted(lessonId, courseId, retake, page.id, siteId);
}
});
}
/**
* Set the last question page attempted in a retake.
*
* @param {number} lessonId Lesson ID.
* @param {number} courseId Course ID the lesson belongs to.
* @param {number} retake Retake number.
* @param {number} lastPage ID of the last question page attempted.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved in success, rejected otherwise.
*/
setLastQuestionPageAttempted(lessonId: number, courseId: number, retake: number, lastPage: number, siteId?: string)
: Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
// Get current stored retake (if any). If not found, it will create a new one.
return this.getRetakeWithFallback(lessonId, courseId, retake, site.id).then((entry) => {
entry.lastquestionpage = lastPage;
entry.timemodified = this.timeUtils.timestamp();
return site.getDb().insertRecord(this.RETAKES_TABLE, entry);
});
});
}
}

View File

@ -0,0 +1,498 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 { TranslateService } from '@ngx-translate/core';
import { CoreAppProvider } from '@providers/app';
import { CoreEventsProvider } from '@providers/events';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSyncProvider } from '@providers/sync';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreTimeUtilsProvider } from '@providers/utils/time';
import { CoreUrlUtilsProvider } from '@providers/utils/url';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreCourseProvider } from '@core/course/providers/course';
import { CoreSyncBaseProvider } from '@classes/base-sync';
import { AddonModLessonProvider } from './lesson';
import { AddonModLessonOfflineProvider } from './lesson-offline';
import { AddonModLessonPrefetchHandler } from './prefetch-handler';
/**
* Data returned by a lesson sync.
*/
export interface AddonModLessonSyncResult {
/**
* List of warnings.
* @type {string[]}
*/
warnings: string[];
/**
* Whether some data was sent to the server or offline data was updated.
* @type {boolean}
*/
updated: boolean;
}
/**
* Service to sync lesson.
*/
@Injectable()
export class AddonModLessonSyncProvider extends CoreSyncBaseProvider {
static AUTO_SYNCED = 'addon_mod_lesson_autom_synced';
static SYNC_TIME = 300000;
protected componentTranslate: string;
// Variables for database.
protected RETAKES_FINISHED_TABLE = 'addon_mod_lesson_retakes_finished_sync';
protected tablesSchema = {
name: this.RETAKES_FINISHED_TABLE,
columns: [
{
name: 'lessonId',
type: 'INTEGER',
primaryKey: true
},
{
name: 'retake',
type: 'INTEGER'
},
{
name: 'pageId',
type: 'INTEGER'
},
{
name: 'timefinished',
type: 'INTEGER'
}
]
};
constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider,
syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider, translate: TranslateService,
courseProvider: CoreCourseProvider, private eventsProvider: CoreEventsProvider,
private lessonProvider: AddonModLessonProvider, private lessonOfflineProvider: AddonModLessonOfflineProvider,
private prefetchHandler: AddonModLessonPrefetchHandler, private timeUtils: CoreTimeUtilsProvider,
private utils: CoreUtilsProvider, private urlUtils: CoreUrlUtilsProvider) {
super('AddonModLessonSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate);
this.componentTranslate = courseProvider.translateModuleName('lesson');
this.sitesProvider.createTableFromSchema(this.tablesSchema);
}
/**
* Unmark a retake as finished in a synchronization.
*
* @param {number} lessonId Lesson ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when done.
*/
deleteRetakeFinishedInSync(lessonId: number, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
return site.getDb().deleteRecords(this.RETAKES_FINISHED_TABLE, {lessonId});
}).catch(() => {
// Ignore errors, maybe there is none.
});
}
/**
* Get a retake finished in a synchronization for a certain lesson (if any).
*
* @param {number} lessonId Lesson ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved with the retake entry (undefined if no retake).
*/
getRetakeFinishedInSync(lessonId: number, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
return site.getDb().getRecord(this.RETAKES_FINISHED_TABLE, {lessonId});
}).catch(() => {
// Ignore errors, return undefined.
});
}
/**
* Check if a lesson has data to synchronize.
*
* @param {number} lessonId Lesson ID.
* @param {number} retake Retake number.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<boolean>} Promise resolved with boolean: whether it has data to sync.
*/
hasDataToSync(lessonId: number, retake: number, siteId?: string): Promise<boolean> {
const promises = [];
let hasDataToSync = false;
promises.push(this.lessonOfflineProvider.hasRetakeAttempts(lessonId, retake, siteId).then((hasAttempts) => {
hasDataToSync = hasDataToSync || hasAttempts;
}).catch(() => {
// Ignore errors.
}));
promises.push(this.lessonOfflineProvider.hasFinishedRetake(lessonId, siteId).then((hasFinished) => {
hasDataToSync = hasDataToSync || hasFinished;
}));
return Promise.all(promises).then(() => {
return hasDataToSync;
});
}
/**
* Mark a retake as finished in a synchronization.
*
* @param {number} lessonId Lesson ID.
* @param {number} retake The retake number.
* @param {number} pageId The page ID to start reviewing from.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when done.
*/
setRetakeFinishedInSync(lessonId: number, retake: number, pageId: number, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
return site.getDb().insertRecord(this.RETAKES_FINISHED_TABLE, {
lessonId: lessonId,
retake: Number(retake),
pageId: Number(pageId),
timefinished: this.timeUtils.timestamp()
});
});
}
/**
* Try to synchronize all the lessons in a certain site or in all sites.
*
* @param {string} [siteId] Site ID to sync. If not defined, sync all sites.
* @return {Promise<any>} Promise resolved if sync is successful, rejected if sync fails.
*/
syncAllLessons(siteId?: string): Promise<any> {
return this.syncOnSites('all lessons', this.syncAllLessonsFunc.bind(this), [], siteId);
}
/**
* Sync all lessons on a site.
*
* @param {string} [siteId] Site ID to sync. If not defined, sync all sites.
* @param {Promise<any>} Promise resolved if sync is successful, rejected if sync fails.
*/
protected syncAllLessonsFunc(siteId?: string): Promise<any> {
// Get all the lessons that have something to be synchronized.
return this.lessonOfflineProvider.getAllLessonsWithData(siteId).then((lessons) => {
// Sync all lessons that haven't been synced for a while.
const promises = [];
lessons.forEach((lesson) => {
promises.push(this.syncLessonIfNeeded(lesson.id, false, siteId).then((result) => {
if (result && result.updated) {
// Sync successful, send event.
this.eventsProvider.trigger(AddonModLessonSyncProvider.AUTO_SYNCED, {
lessonId: lesson.id,
warnings: result.warnings
}, siteId);
}
}));
});
return Promise.all(promises);
});
}
/**
* Sync a lesson only if a certain time has passed since the last time.
*
* @param {any} lessonId Lesson ID.
* @param {boolean} [askPreflight] Whether we should ask for password if needed.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when the lesson is synced or if it doesn't need to be synced.
*/
syncLessonIfNeeded(lessonId: number, askPassword?: boolean, siteId?: string): Promise<any> {
return this.isSyncNeeded(lessonId, siteId).then((needed) => {
if (needed) {
return this.syncLesson(lessonId, askPassword, false, siteId);
}
});
}
/**
* Try to synchronize a lesson.
*
* @param {number} lessonId Lesson ID.
* @param {boolean} askPassword True if we should ask for password if needed, false otherwise.
* @param {boolean} ignoreBlock True to ignore the sync block setting.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<AddonModLessonSyncResult>} Promise resolved in success.
*/
syncLesson(lessonId: number, askPassword?: boolean, ignoreBlock?: boolean, siteId?: string): Promise<AddonModLessonSyncResult> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
const result: AddonModLessonSyncResult = {
warnings: [],
updated: false
};
let syncPromise,
lesson,
courseId,
password,
accessInfo;
if (this.isSyncing(lessonId, siteId)) {
// There's already a sync ongoing for this lesson, return the promise.
return this.getOngoingSync(lessonId, siteId);
}
// Verify that lesson isn't blocked.
if (!ignoreBlock && this.syncProvider.isBlocked(AddonModLessonProvider.COMPONENT, lessonId, siteId)) {
this.logger.debug('Cannot sync lesson ' + lessonId + ' because it is blocked.');
return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate}));
}
this.logger.debug('Try to sync lesson ' + lessonId + ' in site ' + siteId);
// Try to synchronize the attempts first.
syncPromise = this.lessonOfflineProvider.getLessonAttempts(lessonId, siteId).then((attempts) => {
if (!attempts.length) {
return;
} else if (!this.appProvider.isOnline()) {
// Cannot sync in offline.
return Promise.reject(null);
}
courseId = attempts[0].courseid;
// Get the info, access info and the lesson password if needed.
return this.lessonProvider.getLessonById(courseId, lessonId, false, siteId).then((lessonData) => {
lesson = lessonData;
return this.prefetchHandler.getLessonPassword(lessonId, false, true, askPassword, siteId);
}).then((data) => {
const attemptsLength = attempts.length,
promises = [];
accessInfo = data.accessInfo;
password = data.password;
lesson = data.lesson || lesson;
// Filter the attempts, get only the ones that belong to the current retake.
attempts = attempts.filter((attempt) => {
if (attempt.retake != accessInfo.attemptscount) {
// Attempt doesn't belong to current retake, delete.
promises.push(this.lessonOfflineProvider.deleteAttempt(lesson.id, attempt.retake, attempt.pageid,
attempt.timemodified, siteId).catch(() => {
// Ignore errors.
}));
return false;
}
return true;
});
if (attempts.length != attemptsLength) {
// Some attempts won't be sent, add a warning.
result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', {
component: this.componentTranslate,
name: lesson.name,
error: this.translate.instant('addon.mod_lesson.warningretakefinished')
}));
}
return Promise.all(promises);
}).then(() => {
if (!attempts.length) {
return;
}
// Send the attempts in the same order they were answered.
attempts.sort((a, b) => {
return a.timemodified - b.timemodified;
});
attempts = attempts.map((attempt) => {
return {
func: this.sendAttempt.bind(this),
params: [lesson, password, attempt, result, siteId],
blocking: true
};
});
return this.utils.executeOrderedPromises(attempts);
});
}).then(() => {
// Attempts sent or there was none. If there is a finished retake, send it.
return this.lessonOfflineProvider.getRetake(lessonId, siteId).then((retake) => {
if (!retake.finished) {
// The retake isn't marked as finished, nothing to send. Delete the retake.
return this.lessonOfflineProvider.deleteRetake(lessonId, siteId);
} else if (!this.appProvider.isOnline()) {
// Cannot sync in offline.
return Promise.reject(null);
}
let promise;
courseId = retake.courseid || courseId;
if (lesson) {
// Data already retrieved when syncing attempts.
promise = Promise.resolve();
} else {
promise = this.lessonProvider.getLessonById(courseId, lessonId, false, siteId).then((lessonData) => {
lesson = lessonData;
return this.prefetchHandler.getLessonPassword(lessonId, false, true, askPassword, siteId);
}).then((data) => {
accessInfo = data.accessInfo;
password = data.password;
lesson = data.lesson || lesson;
});
}
return promise.then(() => {
if (retake.retake != accessInfo.attemptscount) {
// The retake changed, add a warning if it isn't there already.
if (!result.warnings.length) {
result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', {
component: this.componentTranslate,
name: lesson.name,
error: this.translate.instant('addon.mod_lesson.warningretakefinished')
}));
}
return this.lessonOfflineProvider.deleteRetake(lessonId, siteId);
}
// All good, finish the retake.
return this.lessonProvider.finishRetakeOnline(lessonId, password, false, false, siteId).then((response) => {
result.updated = true;
if (!ignoreBlock) {
// Mark the retake as finished in a sync if it can be reviewed.
if (response.data && response.data.reviewlesson) {
const params = this.urlUtils.extractUrlParams(response.data.reviewlesson.value);
if (params && params.pageid) {
// The retake can be reviewed, mark it as finished. Don't block the user for this.
this.setRetakeFinishedInSync(lessonId, retake.retake, params.pageid, siteId);
}
}
}
return this.lessonOfflineProvider.deleteRetake(lessonId, siteId);
}).catch((error) => {
if (error && this.utils.isWebServiceError(error)) {
// The WebService has thrown an error, this means that responses cannot be submitted. Delete them.
result.updated = true;
return this.lessonOfflineProvider.deleteRetake(lessonId, siteId).then(() => {
// Retake deleted, add a warning.
result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', {
component: this.componentTranslate,
name: lesson.name,
error: error
}));
});
} else {
// Couldn't connect to server, reject.
return Promise.reject(error);
}
});
});
}, () => {
// No retake stored, nothing to do.
});
}).then(() => {
if (result.updated && courseId) {
// Data has been sent to server. Now invalidate the WS calls.
const promises = [];
promises.push(this.lessonProvider.invalidateAccessInformation(lessonId, siteId));
promises.push(this.lessonProvider.invalidateContentPagesViewed(lessonId, siteId));
promises.push(this.lessonProvider.invalidateQuestionsAttempts(lessonId, siteId));
promises.push(this.lessonProvider.invalidatePagesPossibleJumps(lessonId, siteId));
promises.push(this.lessonProvider.invalidateTimers(lessonId, siteId));
return this.utils.allPromises(promises).catch(() => {
// Ignore errors.
}).then(() => {
// Sync successful, update some data that might have been modified.
return this.lessonProvider.getAccessInformation(lessonId, false, false, siteId).then((info) => {
const promises = [],
retake = info.attemptscount;
promises.push(this.lessonProvider.getContentPagesViewedOnline(lessonId, retake, false, false, siteId));
promises.push(this.lessonProvider.getQuestionsAttemptsOnline(lessonId, retake, false, undefined, false,
false, siteId));
return Promise.all(promises);
}).catch(() => {
// Ignore errors.
});
});
}
}).then(() => {
// Sync finished, set sync time.
return this.setSyncTime(lessonId, siteId).catch(() => {
// Ignore errors.
});
}).then(() => {
// All done, return the result.
return result;
});
return this.addOngoingSync(lessonId, syncPromise, siteId);
}
/**
* Send an attempt to the site and delete it afterwards.
*
* @param {any} lesson Lesson.
* @param {string} password Password (if any).
* @param {any} attempt Attempt to send.
* @param {AddonModLessonSyncResult} result Result where to store the data.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when done.
*/
protected sendAttempt(lesson: any, password: string, attempt: any, result: AddonModLessonSyncResult, siteId?: string)
: Promise<any> {
return this.lessonProvider.processPageOnline(lesson.id, attempt.pageid, attempt.data, password, false, siteId).then(() => {
result.updated = true;
return this.lessonOfflineProvider.deleteAttempt(lesson.id, attempt.retake, attempt.pageid, attempt.timemodified,
siteId);
}).catch((error) => {
if (error && this.utils.isWebServiceError(error)) {
// The WebService has thrown an error, this means that the attempt cannot be submitted. Delete it.
result.updated = true;
return this.lessonOfflineProvider.deleteAttempt(lesson.id, attempt.retake, attempt.pageid, attempt.timemodified,
siteId).then(() => {
// Attempt deleted, add a warning.
result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', {
component: this.componentTranslate,
name: lesson.name,
error: error
}));
});
} else {
// Couldn't connect to server, reject.
return Promise.reject(error);
}
});
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,72 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 { NavController, NavOptions } from 'ionic-angular';
import { AddonModLessonIndexComponent } from '../components/index/index';
import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@core/course/providers/module-delegate';
import { CoreCourseProvider } from '@core/course/providers/course';
import { AddonModLessonProvider } from './lesson';
/**
* Handler to support quiz modules.
*/
@Injectable()
export class AddonModLessonModuleHandler implements CoreCourseModuleHandler {
name = 'AddonModLesson';
modName = 'lesson';
constructor(private courseProvider: CoreCourseProvider, private lessonProvider: AddonModLessonProvider) { }
/**
* Check if the handler is enabled on a site level.
*
* @return {Promise<boolean>} Promise resolved with boolean: whether or not the handler is enabled on a site level.
*/
isEnabled(): Promise<boolean> {
return this.lessonProvider.isPluginEnabled();
}
/**
* Get the data required to display the module in the course contents view.
*
* @param {any} module The module object.
* @param {number} courseId The course ID.
* @param {number} sectionId The section ID.
* @return {CoreCourseModuleHandlerData} Data to render the module.
*/
getData(module: any, courseId: number, sectionId: number): CoreCourseModuleHandlerData {
return {
icon: this.courseProvider.getModuleIconSrc('lesson'),
title: module.name,
class: 'addon-mod_lesson-handler',
showDownloadButton: true,
action(event: Event, navCtrl: NavController, module: any, courseId: number, options: NavOptions): void {
navCtrl.push('AddonModLessonIndexPage', {module: module, courseId: courseId}, options);
}
};
}
/**
* Get the component to render the module. This is needed to support singleactivity course format.
* The component returned must implement CoreCourseModuleMainComponent.
*
* @param {any} course The course object.
* @param {any} module The module object.
* @return {any} The component to use, undefined if not found.
*/
getMainComponent(course: any, module: any): any {
return AddonModLessonIndexComponent;
}
}

View File

@ -0,0 +1,449 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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, Injector } from '@angular/core';
import { ModalController } from 'ionic-angular';
import { CoreGroupsProvider } from '@providers/groups';
import { CoreCourseModulePrefetchHandlerBase } from '@core/course/classes/module-prefetch-handler';
import { AddonModLessonProvider } from './lesson';
/**
* Handler to prefetch lessons.
*/
@Injectable()
export class AddonModLessonPrefetchHandler extends CoreCourseModulePrefetchHandlerBase {
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$/;
constructor(protected injector: Injector, protected modalCtrl: ModalController, protected groupsProvider: CoreGroupsProvider,
protected lessonProvider: AddonModLessonProvider) {
super(injector);
}
/**
* Ask password.
*
* @param {any} info Lesson access info.
* @return {Promise<string>} Promise resolved with the password.
*/
protected askUserPassword(info: any): Promise<string> {
// Create and show the modal.
const modal = this.modalCtrl.create('AddonModLessonPasswordModalPage');
modal.present();
// Wait for modal to be dismissed.
return new Promise((resolve, reject): void => {
modal.onDidDismiss((password) => {
if (typeof password != 'undefined') {
resolve(password);
} else {
reject(this.domUtils.createCanceledError());
}
});
});
}
/**
* Download the module.
*
* @param {any} module The module object returned by WS.
* @param {number} courseId Course ID.
* @param {string} [dirPath] Path of the directory where to store all the content files. @see downloadOrPrefetch.
* @return {Promise<any>} Promise resolved when all content is downloaded.
*/
download(module: any, courseId: number, dirPath?: string): Promise<any> {
// Same implementation for download and prefetch.
return this.prefetch(module, courseId, false, dirPath);
}
/**
* Get the download size of a module.
*
* @param {any} module Module.
* @param {Number} courseId Course ID the module belongs to.
* @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section.
* @return {Promise<{size: number, total: boolean}>} Promise resolved with the size and a boolean indicating if it was able
* to calculate the total size.
*/
getDownloadSize(module: any, courseId: any, single?: boolean): Promise<{ size: number, total: boolean }> {
const siteId = this.sitesProvider.getCurrentSiteId();
let lesson,
password,
result;
return this.lessonProvider.getLesson(courseId, module.id, false, siteId).then((lessonData) => {
lesson = lessonData;
// Get the lesson password if it's needed.
return this.getLessonPassword(lesson.id, false, true, single, siteId);
}).then((data) => {
password = data.password;
lesson = data.lesson || lesson;
// Get intro files and media files.
let files = lesson.mediafiles || [];
files = files.concat(this.getIntroFilesFromInstance(module, lesson));
result = this.utils.sumFileSizes(files);
// Get the pages to calculate the size.
return this.lessonProvider.getPages(lesson.id, password, false, false, siteId);
}).then((pages) => {
pages.forEach((page) => {
result.size += page.filessizetotal;
});
return result;
});
}
/**
* Get list of files. If not defined, we'll assume they're in module.contents.
*
* @param {any} module Module.
* @param {Number} courseId Course ID the module belongs to.
* @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section.
* @return {Promise<any[]>} Promise resolved with the list of files.
*/
getFiles(module: any, courseId: number, single?: boolean): Promise<any[]> {
return Promise.resolve([]);
}
/**
* Get the lesson password if needed. If not stored, it can ask the user to enter it.
*
* @param {number} lessonId Lesson ID.
* @param {boolean} [forceCache] Whether it should return cached data. Has priority over ignoreCache.
* @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down).
* @param {boolean} [askPassword] True if we should ask for password if needed, false otherwise.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<{password?: string, lesson?: any, accessInfo: any}>} Promise resolved when done.
*/
getLessonPassword(lessonId: number, forceCache?: boolean, ignoreCache?: boolean, askPassword?: boolean, siteId?: string)
: Promise<{password?: string, lesson?: any, accessInfo: any}> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
// Get access information to check if password is needed.
return this.lessonProvider.getAccessInformation(lessonId, forceCache, ignoreCache, siteId).then((info): any => {
if (info.preventaccessreasons && info.preventaccessreasons.length) {
const passwordNeeded = info.preventaccessreasons.length == 1 && this.lessonProvider.isPasswordProtected(info);
if (passwordNeeded) {
// The lesson requires a password. Check if there is one in DB.
return this.lessonProvider.getStoredPassword(lessonId).catch(() => {
// No password found.
}).then((password) => {
if (password) {
return this.validatePassword(lessonId, info, password, forceCache, ignoreCache, siteId);
} else {
return Promise.reject(null);
}
}).catch(() => {
// No password or error validating it. Ask for it if allowed.
if (askPassword) {
return this.askUserPassword(info).then((password) => {
return this.validatePassword(lessonId, info, password, forceCache, ignoreCache, siteId);
});
}
// Cannot ask for password, reject.
return Promise.reject(info.preventaccessreasons[0].message);
});
} else {
// Lesson cannot be played, reject.
return Promise.reject(info.preventaccessreasons[0].message);
}
}
// Password not needed.
return { accessInfo: info };
});
}
/**
* Invalidate the prefetched content.
*
* @param {number} moduleId The module ID.
* @param {number} courseId The course ID the module belongs to.
* @return {Promise<any>} Promise resolved when the data is invalidated.
*/
invalidateContent(moduleId: number, courseId: number): Promise<any> {
// Only invalidate the data that doesn't ignore cache when prefetching.
const promises = [];
promises.push(this.lessonProvider.invalidateLessonData(courseId));
promises.push(this.courseProvider.invalidateModule(moduleId));
promises.push(this.groupsProvider.invalidateActivityAllowedGroups(moduleId));
return Promise.all(promises);
}
/**
* Invalidate WS calls needed to determine module status.
*
* @param {any} module Module.
* @param {number} courseId Course ID the module belongs to.
* @return {Promise<any>} Promise resolved when invalidated.
*/
invalidateModule(module: any, courseId: number): Promise<any> {
const siteId = this.sitesProvider.getCurrentSiteId();
// Invalidate data to determine if module is downloadable.
return this.lessonProvider.getLesson(courseId, module.id, false, siteId).then((lesson) => {
const promises = [];
promises.push(this.lessonProvider.invalidateLessonData(courseId, siteId));
promises.push(this.lessonProvider.invalidateAccessInformation(lesson.id, siteId));
return Promise.all(promises);
});
}
/**
* Check if a module can be downloaded. If the function is not defined, we assume that all modules are downloadable.
*
* @param {any} module Module.
* @param {number} courseId Course ID the module belongs to.
* @return {boolean|Promise<boolean>} Whether the module can be downloaded. The promise should never be rejected.
*/
isDownloadable(module: any, courseId: number): boolean | Promise<boolean> {
const siteId = this.sitesProvider.getCurrentSiteId();
return this.lessonProvider.getLesson(courseId, module.id, false, siteId).then((lesson) => {
if (!this.lessonProvider.isLessonOffline(lesson)) {
return false;
}
// Check if there is any prevent access reason.
return this.lessonProvider.getAccessInformation(lesson.id, false, false, siteId).then((info) => {
// It's downloadable if there are no prevent access reasons or there is just 1 and it's password.
return !info.preventaccessreasons || !info.preventaccessreasons.length ||
(info.preventaccessreasons.length == 1 && this.lessonProvider.isPasswordProtected(info));
});
});
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return {boolean|Promise<boolean>} A boolean, or a promise resolved with a boolean, indicating if the handler is enabled.
*/
isEnabled(): boolean | Promise<boolean> {
return this.lessonProvider.isPluginEnabled();
}
/**
* Prefetch a module.
*
* @param {any} module Module.
* @param {number} courseId Course ID the module belongs to.
* @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section.
* @param {string} [dirPath] Path of the directory where to store all the content files. @see downloadOrPrefetch.
* @return {Promise<any>} Promise resolved when done.
*/
prefetch(module: any, courseId?: number, single?: boolean, dirPath?: string): Promise<any> {
return this.prefetchPackage(module, courseId, single, this.prefetchLesson.bind(this));
}
/**
* Prefetch a lesson.
*
* @param {any} module Module.
* @param {number} courseId Course ID the module belongs to.
* @param {boolean} single True if we're downloading a single module, false if we're downloading a whole section.
* @param {String} siteId Site ID.
* @return {Promise<any>} Promise resolved when done.
*/
protected prefetchLesson(module: any, courseId: number, single: boolean, siteId: string): Promise<any> {
let lesson,
password,
accessInfo;
return this.lessonProvider.getLesson(courseId, module.id, false, siteId).then((lessonData) => {
lesson = lessonData;
// Get the lesson password if it's needed.
return this.getLessonPassword(lesson.id, false, true, single, siteId);
}).then((data) => {
password = data.password;
lesson = data.lesson || lesson;
accessInfo = data.accessInfo;
if (!this.lessonProvider.leftDuringTimed(accessInfo)) {
// The user didn't left during a timed session. Call launch retake to make sure there is a started retake.
return this.lessonProvider.launchRetake(lesson.id, password, undefined, false, siteId).then(() => {
const promises = [];
// New data generated, update the download time and refresh the access info.
promises.push(this.filepoolProvider.updatePackageDownloadTime(siteId, this.component, module.id).catch(() => {
// Ignore errors.
}));
promises.push(this.lessonProvider.getAccessInformation(lesson.id, false, true, siteId).then((info) => {
accessInfo = info;
}));
return Promise.all(promises);
});
}
}).then(() => {
const promises = [],
retake = accessInfo.attemptscount;
// Download intro files and media files.
let files = lesson.mediafiles || [];
files = files.concat(this.getIntroFilesFromInstance(module, lesson));
promises.push(this.filepoolProvider.addFilesToQueue(siteId, files, this.component, module.id));
// Get the list of pages.
promises.push(this.lessonProvider.getPages(lesson.id, password, false, true, siteId).then((pages) => {
const subPromises = [];
let hasRandomBranch = false;
// Get the data for each page.
pages.forEach((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.
subPromises.push(this.lessonProvider.getPageData(lesson, data.page.id, password, false, true, false,
true, undefined, undefined, siteId).then((pageData) => {
// Download the page files.
let pageFiles = pageData.contentfiles || [];
pageData.answers.forEach((answer) => {
if (answer.answerfiles && answer.answerfiles.length) {
pageFiles = pageFiles.concat(answer.answerfiles);
}
if (answer.responsefiles && answer.responsefiles.length) {
pageFiles = pageFiles.concat(answer.responsefiles);
}
});
return this.filepoolProvider.addFilesToQueue(siteId, pageFiles, this.component, module.id);
}));
});
// Prefetch the list of possible jumps for offline navigation. Do it here because we know hasRandomBranch.
subPromises.push(this.lessonProvider.getPagesPossibleJumps(lesson.id, false, true, siteId).catch((error) => {
if (hasRandomBranch) {
// The WebSevice probably failed because RANDOMBRANCH aren't supported if the user hasn't seen any page.
return Promise.reject(this.translate.instant('addon.mod_lesson.errorprefetchrandombranch'));
} else {
return Promise.reject(error);
}
}));
return Promise.all(subPromises);
}));
// Prefetch user timers to be able to calculate timemodified in offline.
promises.push(this.lessonProvider.getTimers(lesson.id, false, true, siteId).catch(() => {
// Ignore errors.
}));
// Prefetch viewed pages in last retake to calculate progress.
promises.push(this.lessonProvider.getContentPagesViewedOnline(lesson.id, retake, false, true, siteId));
// Prefetch question attempts in last retake for offline calculations.
promises.push(this.lessonProvider.getQuestionsAttemptsOnline(lesson.id, retake, false, undefined, false, true, siteId));
// Get module info to be able to handle links.
promises.push(this.courseProvider.getModuleBasicInfo(module.id, siteId));
if (accessInfo.canviewreports) {
// Prefetch reports data.
promises.push(this.groupsProvider.getActivityAllowedGroupsIfEnabled(module.id, undefined, siteId).then((groups) => {
const subPromises = [];
groups.forEach((group) => {
subPromises.push(this.lessonProvider.getRetakesOverview(lesson.id, group.id, false, true, siteId));
});
// Always get group 0, even if there are no groups.
subPromises.push(this.lessonProvider.getRetakesOverview(lesson.id, 0, false, true, siteId).then((data) => {
if (!data || !data.students) {
return;
}
// Prefetch the last retake for each user.
const retakePromises = [];
data.students.forEach((student) => {
if (!student.attempts || !student.attempts.length) {
return;
}
const lastRetake = student.attempts[student.attempts.length - 1];
if (!lastRetake) {
return;
}
retakePromises.push(this.lessonProvider.getUserRetake(lesson.id, lastRetake.try, student.id, false,
true, siteId));
});
return Promise.all(retakePromises);
}));
return Promise.all(subPromises);
}));
}
return Promise.all(promises);
});
}
/**
* Validate the password.
*
* @param {number} lessonId Lesson ID.
* @param {any} info Lesson access info.
* @param {string} pwd Password to check.
* @param {boolean} [forceCache] Whether it should return cached data. Has priority over ignoreCache.
* @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down).
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<{password: string, lesson: any, accessInfo: any}>} Promise resolved when done.
*/
protected validatePassword(lessonId: number, info: any, pwd: string, forceCache?: boolean, ignoreCache?: boolean,
siteId?: string): Promise<{password: string, lesson: any, accessInfo: any}> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
return this.lessonProvider.getLessonWithPassword(lessonId, pwd, true, forceCache, ignoreCache, siteId).then((lesson) => {
// Password is ok, store it and return the data.
return this.lessonProvider.storePassword(lesson.id, pwd, siteId).then(() => {
return {
password: pwd,
lesson: lesson,
accessInfo: info
};
});
});
}
}

View File

@ -0,0 +1,154 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 { NavController } from 'ionic-angular';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler';
import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate';
import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper';
import { CoreCourseProvider } from '@core/course/providers/course';
import { CoreCourseHelperProvider } from '@core/course/providers/helper';
import { AddonModLessonProvider } from './lesson';
/**
* Handler to treat links to lesson report.
*/
@Injectable()
export class AddonModLessonReportLinkHandler extends CoreContentLinksHandlerBase {
name = 'AddonModLessonReportLinkHandler';
featureName = 'CoreCourseModuleDelegate_AddonModLesson';
pattern = /\/mod\/lesson\/report\.php.*([\&\?]id=\d+)/;
constructor(protected domUtils: CoreDomUtilsProvider, protected lessonProvider: AddonModLessonProvider,
protected courseHelper: CoreCourseHelperProvider, protected linkHelper: CoreContentLinksHelperProvider,
protected courseProvider: CoreCourseProvider) {
super();
}
/**
* Get the list of actions for a link (url).
*
* @param {string[]} siteIds List of sites the URL belongs to.
* @param {string} url The URL to treat.
* @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param {number} [courseId] Course ID related to the URL. Optional but recommended.
* @return {CoreContentLinksAction[]|Promise<CoreContentLinksAction[]>} List of (or promise resolved with list of) actions.
*/
getActions(siteIds: string[], url: string, params: any, courseId?: number):
CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
courseId = courseId || params.courseid || params.cid;
return [{
action: (siteId, navCtrl?): void => {
if (!params.action || params.action == 'reportoverview') {
// Go to overview.
this.openReportOverview(parseInt(params.id, 10), courseId, parseInt(params.group, 10), siteId, navCtrl);
} else if (params.action == 'reportdetail') {
this.openUserRetake(parseInt(params.id, 10), parseInt(params.userid, 10), courseId, parseInt(params.try, 10),
siteId, navCtrl);
}
}
}];
}
/**
* Check if the handler is enabled for a certain site (site + user) and a URL.
* If not defined, defaults to true.
*
* @param {string} siteId The site ID.
* @param {string} url The URL to treat.
* @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param {number} [courseId] Course ID related to the URL. Optional but recommended.
* @return {boolean|Promise<boolean>} Whether the handler is enabled for the URL and site.
*/
isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise<boolean> {
if (params.action == 'reportdetail' && !params.userid) {
// Individual details are only available if the teacher is seeing a certain user.
return false;
}
return this.lessonProvider.isPluginEnabled();
}
/**
* Open report overview.
*
* @param {number} moduleId Module ID.
* @param {number} courseId Course ID.
* @param {string} groupId Group ID.
* @param {string} siteId Site ID.
* @param {NavController} [navCtrl] The NavController to use to navigate.
* @return {Promise<any>} Promise resolved when done.
*/
protected openReportOverview(moduleId: number, courseId?: number, groupId?: number, siteId?: string, navCtrl?: NavController)
: Promise<any> {
const modal = this.domUtils.showModalLoading();
// Get the module object.
return this.courseProvider.getModuleBasicInfo(moduleId, siteId).then((module) => {
courseId = courseId || module.course;
const pageParams = {
module: module,
courseId: Number(courseId),
action: 'report',
group: groupId
};
this.linkHelper.goInSite(navCtrl, 'AddonModLessonIndexPage', pageParams, siteId);
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error processing link.');
}).finally(() => {
modal.dismiss();
});
}
/**
* Open a user's retake.
*
* @param {number} moduleId Module ID.
* @param {number} userId User ID.
* @param {number} courseId Course ID.
* @param {number} retake Retake to open.
* @param {string} groupId Group ID.
* @param {string} siteId Site ID.
* @param {NavController} [navCtrl] The NavController to use to navigate.
* @return {Promise<any>} Promise resolved when done.
*/
protected openUserRetake(moduleId: number, userId: number, courseId: number, retake: number, siteId: string,
navCtrl?: NavController): Promise<any> {
const modal = this.domUtils.showModalLoading();
// Get the module object.
return this.courseProvider.getModuleBasicInfo(moduleId, siteId).then((module) => {
courseId = courseId || module.course;
const pageParams = {
lessonId: module.instance,
courseId: Number(courseId),
userId: userId,
retake: retake || 0
};
this.linkHelper.goInSite(navCtrl, 'AddonModLessonUserRetakePage', pageParams, siteId);
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error processing link.');
}).finally(() => {
modal.dismiss();
});
}
}

View File

@ -0,0 +1,47 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 '@providers/cron';
import { AddonModLessonSyncProvider } from './lesson-sync';
/**
* Synchronization cron handler.
*/
@Injectable()
export class AddonModLessonSyncCronHandler implements CoreCronHandler {
name = 'AddonModLessonSyncCronHandler';
constructor(private lessonSync: AddonModLessonSyncProvider) {}
/**
* Execute the process.
* Receives the ID of the site affected, undefined for all sites.
*
* @param {string} [siteId] ID of the site affected, undefined for all sites.
* @return {Promise<any>} Promise resolved when done, rejected if failure.
*/
execute(siteId?: string): Promise<any> {
return this.lessonSync.syncAllLessons(siteId);
}
/**
* Get the time between consecutive executions.
*
* @return {number} Time between consecutive executions (in ms).
*/
getInterval(): number {
return 600000;
}
}

View File

@ -17,7 +17,7 @@
<!-- Warning message. -->
<div *ngIf="scorm && scorm.warningMessage" class="core-info-card" icon-start>
<ion-icon name="information"></ion-icon>
<ion-icon name="information-circle"></ion-icon>
{{ scorm.warningMessage }}
</div>

View File

@ -4,7 +4,7 @@
<ion-item text-wrap [ngClass]="{invisible: !question.loaded}">
<p *ngIf="!question.readOnly" class="core-info-card" icon-start>
<ion-icon name="information"></ion-icon>
<ion-icon name="information-circle"></ion-icon>
{{ 'core.question.howtodraganddrop' | translate }}
</p>
<p><core-format-text [component]="component" [componentId]="componentId" [text]="question.text"></core-format-text></p>

View File

@ -4,7 +4,7 @@
<ion-item text-wrap [ngClass]="{invisible: !question.loaded}">
<p *ngIf="!question.readOnly" class="core-info-card" icon-start>
<ion-icon name="information"></ion-icon>
<ion-icon name="information-circle"></ion-icon>
{{ 'core.question.howtodraganddrop' | translate }}
</p>
<p><core-format-text [component]="component" [componentId]="componentId" [text]="question.text"></core-format-text></p>

View File

@ -1,7 +1,7 @@
<section ion-list *ngIf="question.text || question.text === ''">
<ion-item text-wrap class="addon-qtype-ddwtos-container">
<p *ngIf="!question.readOnly" class="core-info-card" icon-start>
<ion-icon name="information"></ion-icon>
<ion-icon name="information-circle"></ion-icon>
{{ 'core.question.howtodraganddrop' | translate }}
</p>
<p><core-format-text [component]="component" [componentId]="componentId" [text]="question.text"></core-format-text></p>

View File

@ -12,7 +12,7 @@
<core-format-text [component]="component" [componentId]="componentId" [text]="option.text"></core-format-text>
<p *ngIf="option.feedback" class="core-question-feedback-container"><core-format-text [component]="component" [componentId]="componentId" [text]="option.feedback"></core-format-text></p>
</ion-label>
<ion-checkbox [name]="option.name" [(ngModel)]="option.checked" [disabled]="option.disabled" item-end></ion-checkbox>
<ion-checkbox [attr.name]="option.name" [(ngModel)]="option.checked" [disabled]="option.disabled" item-end></ion-checkbox>
<!-- ion-checkbox doesn't use an input. Create a hidden input to hold the value. -->
<input item-content type="hidden" [ngModel]="option.checked" [attr.name]="option.name">

View File

@ -88,6 +88,7 @@ import { AddonModFeedbackModule } from '@addon/mod/feedback/feedback.module';
import { AddonModFolderModule } from '@addon/mod/folder/folder.module';
import { AddonModForumModule } from '@addon/mod/forum/forum.module';
import { AddonModGlossaryModule } from '@addon/mod/glossary/glossary.module';
import { AddonModLessonModule } from '@addon/mod/lesson/lesson.module';
import { AddonModPageModule } from '@addon/mod/page/page.module';
import { AddonModQuizModule } from '@addon/mod/quiz/quiz.module';
import { AddonModScormModule } from '@addon/mod/scorm/scorm.module';
@ -186,6 +187,7 @@ export const CORE_PROVIDERS: any[] = [
AddonModChatModule,
AddonModChoiceModule,
AddonModLabelModule,
AddonModLessonModule,
AddonModResourceModule,
AddonModFeedbackModule,
AddonModFolderModule,

View File

@ -11,7 +11,7 @@ core-show-password {
background: transparent;
padding: 0 ($content-padding / 2);
position: absolute;
top: $content-padding / 2;
bottom: $content-padding / 2;
right: 0;
margin-top: 0;
margin-bottom: 0;
@ -25,25 +25,25 @@ core-show-password {
.md {
.item-label-stacked core-show-password .button[icon-only] {
top: 0;
bottom: 0;
}
}
.ios {
.item-label-stacked core-show-password .button[icon-only] {
top: -5px;
bottom: -5px;
}
core-show-password .button[icon-only] {
top: 0;
bottom: 0;
}
}
.wp {
.item-label-stacked core-show-password .button[icon-only] {
top: 7px;
bottom: 7px;
}
core-show-password .button[icon-only] {
top: 12px;
bottom: 12px;
right: 5px;
}
}

View File

@ -139,7 +139,7 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy {
this.spinner = true;
// Get download size to ask for confirm if it's high.
this.prefetchHandler.getDownloadSize(this.module, this.courseId).then((size) => {
this.prefetchHandler.getDownloadSize(this.module, this.courseId, true).then((size) => {
return this.courseHelper.prefetchModule(this.prefetchHandler, this.module, size, this.courseId, refresh);
}).catch((error) => {
// Error, hide spinner.

View File

@ -908,6 +908,10 @@ export class CoreCourseHelperProvider {
}
return promise.then(() => {
// Make sure they're numbers.
courseId = Number(courseId);
sectionId = Number(sectionId);
// Get the site.
return this.sitesProvider.getSite(siteId);
}).then((s) => {

View File

@ -22,6 +22,12 @@ import { CoreCoursesProvider } from '@core/courses/providers/courses';
*/
@Injectable()
export class CoreGradesProvider {
static TYPE_NONE = 0; // Moodle's GRADE_TYPE_NONE.
static TYPE_VALUE = 1; // Moodle's GRADE_TYPE_VALUE.
static TYPE_SCALE = 2; // Moodle's GRADE_TYPE_SCALE.
static TYPE_TEXT = 3; // Moodle's GRADE_TYPE_TEXT.
protected ROOT_CACHE_KEY = 'mmGrades:';
protected logger;

View File

@ -33,7 +33,7 @@
<form [formGroup]="credForm" (ngSubmit)="login()">
<ion-item>
<core-show-password item-content [name]="'password'">
<ion-input class="core-ioninput-password" name="password" type="password" placeholder="{{ 'core.login.password' | translate }}" formControlName="password" core-show-password core-auto-focus></ion-input>
<ion-input class="core-ioninput-password" name="password" type="password" placeholder="{{ 'core.login.password' | translate }}" formControlName="password" core-auto-focus></ion-input>
</core-show-password>
</ion-item>
<ion-grid>

View File

@ -17,6 +17,7 @@
"disableall": "Disable notifications",
"disabled": "Disabled",
"displayformat": "Display format",
"enabledownloadsection": "Enable download sections",
"enablerichtexteditor": "Enable text editor",
"enablerichtexteditordescription": "If enabled, a text editor will be available when entering content.",
"enablesyncwifi": "Allow sync only when on Wi-Fi",

View File

@ -131,6 +131,10 @@ export class CoreTextUtilsProvider {
* @return {string} Clean text.
*/
cleanTags(text: string, singleLine?: boolean): string {
if (typeof text == 'number') {
return text;
}
if (!text) {
return '';
}