MOBILE-2350 scorm: Implement index page and component

main
Dani Palou 2018-04-25 10:06:04 +02:00
parent b2bd25fdcf
commit dffc8c3c6c
15 changed files with 1018 additions and 9 deletions

View File

@ -16,7 +16,6 @@ import { Component, Optional, Injector } from '@angular/core';
import { Content, NavController } from 'ionic-angular'; import { Content, NavController } from 'ionic-angular';
import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main-activity-component'; import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main-activity-component';
import { CoreQuestionBehaviourDelegate } from '@core/question/providers/behaviour-delegate'; import { CoreQuestionBehaviourDelegate } from '@core/question/providers/behaviour-delegate';
import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate';
import { AddonModQuizProvider } from '../../providers/quiz'; import { AddonModQuizProvider } from '../../providers/quiz';
import { AddonModQuizHelperProvider } from '../../providers/helper'; import { AddonModQuizHelperProvider } from '../../providers/helper';
import { AddonModQuizOfflineProvider } from '../../providers/quiz-offline'; import { AddonModQuizOfflineProvider } from '../../providers/quiz-offline';
@ -71,8 +70,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
constructor(injector: Injector, protected quizProvider: AddonModQuizProvider, @Optional() content: Content, constructor(injector: Injector, protected quizProvider: AddonModQuizProvider, @Optional() content: Content,
protected quizHelper: AddonModQuizHelperProvider, protected quizOffline: AddonModQuizOfflineProvider, protected quizHelper: AddonModQuizHelperProvider, protected quizOffline: AddonModQuizOfflineProvider,
protected quizSync: AddonModQuizSyncProvider, protected behaviourDelegate: CoreQuestionBehaviourDelegate, protected quizSync: AddonModQuizSyncProvider, protected behaviourDelegate: CoreQuestionBehaviourDelegate,
protected prefetchHandler: AddonModQuizPrefetchHandler, protected navCtrl: NavController, protected prefetchHandler: AddonModQuizPrefetchHandler, protected navCtrl: NavController) {
protected prefetchDelegate: CoreCourseModulePrefetchDelegate) {
super(injector, content); super(injector, content);
} }
@ -117,7 +115,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
// If the site doesn't support check updates, always prefetch it because we cannot tell if there's something new. // If the site doesn't support check updates, always prefetch it because we cannot tell if there's something new.
const isDownloaded = this.currentStatus == CoreConstants.DOWNLOADED; const isDownloaded = this.currentStatus == CoreConstants.DOWNLOADED;
if (!isDownloaded || !this.prefetchDelegate.canCheckUpdates()) { if (!isDownloaded || !this.modulePrefetchDelegate.canCheckUpdates()) {
// Prefetch the quiz. // Prefetch the quiz.
this.showStatusSpinner = true; this.showStatusSpinner = true;
@ -125,7 +123,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
// Success downloading, open quiz. // Success downloading, open quiz.
this.openQuiz(); this.openQuiz();
}).catch((error) => { }).catch((error) => {
if (this.hasOffline || (isDownloaded && !this.prefetchDelegate.canCheckUpdates())) { if (this.hasOffline || (isDownloaded && !this.modulePrefetchDelegate.canCheckUpdates())) {
// Error downloading but there is something offline, allow continuing it. // Error downloading but there is something offline, allow continuing it.
// If the site doesn't support check updates, continue too because we cannot tell if there's something new. // If the site doesn't support check updates, continue too because we cannot tell if there's something new.
this.openQuiz(); this.openQuiz();

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 { AddonModScormIndexComponent } from './index/index';
@NgModule({
declarations: [
AddonModScormIndexComponent
],
imports: [
CommonModule,
IonicModule,
TranslateModule.forChild(),
CoreComponentsModule,
CoreDirectivesModule,
CoreCourseComponentsModule
],
providers: [
],
exports: [
AddonModScormIndexComponent
],
entryComponents: [
AddonModScormIndexComponent
]
})
export class AddonModScormComponentsModule {}

View File

@ -0,0 +1,170 @@
<!-- 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-course-module-description [description]="description" [component]="component" [componentId]="componentId"></core-course-module-description>
<!-- Warning message. -->
<div *ngIf="scorm && scorm.warningMessage" class="core-info-card" icon-start>
<ion-icon name="information"></ion-icon>
{{ scorm.warningMessage }}
</div>
<div *ngIf="scorm && loaded && !scorm.warningMessage">
<!-- Attempts status. -->
<ion-card *ngIf="scorm.displayattemptstatus || Object.keys(scorm.offlineAttempts).length">
<ion-card-header text-wrap>
<h2>{{ 'addon.mod_scorm.attempts' | translate }}</h2>
</ion-card-header>
<ion-list>
<ng-container *ngIf="scorm.displayattemptstatus">
<ion-item text-wrap *ngIf="scorm.maxattempt >= 0">
<p class="item-heading">{{ 'addon.mod_scorm.noattemptsallowed' | translate }}</p>
<p *ngIf="scorm.maxattempt == 0">{{ 'core.unlimited' | translate }}</p>
<p *ngIf="scorm.maxattempt > 0">{{ scorm.maxattempt }}</p>
</ion-item>
<ion-item text-wrap *ngIf="scorm.numAttempts >= 0">
<p class="item-heading">{{ 'addon.mod_scorm.noattemptsmade' | translate }}</p>
<p>{{ scorm.numAttempts }}</p>
</ion-item>
<ion-item text-wrap *ngFor="let attempt of scorm.onlineAttempts">
<p class="item-heading">{{ 'addon.mod_scorm.gradeforattempt' | translate }} {{attempt.number}}</p>
<p *ngIf="attempt.grade != -1">{{ attempt.grade }}</p>
<p *ngIf="attempt.grade == -1">{{ 'addon.mod_scorm.cannotcalculategrade' | translate }}</p>
</ion-item>
</ng-container>
<ion-item text-wrap *ngFor="let attempt of scorm.offlineAttempts">
<p class="item-heading">{{ 'addon.mod_scorm.gradeforattempt' | translate }} {{attempt.number}}</p>
<p *ngIf="attempt.grade != -1">{{ attempt.grade }}</p>
<p *ngIf="attempt.grade == -1">{{ 'addon.mod_scorm.cannotcalculategrade' | translate }}</p>
<p *ngIf="scorm.maxattempt == 0 || attempt.number <= scorm.maxattempt">{{ 'addon.mod_scorm.offlineattemptnote' | translate }}</p>
<p *ngIf="scorm.maxattempt != 0 && attempt.number > scorm.maxattempt">{{ 'addon.mod_scorm.offlineattemptovermax' | translate }}</p>
</ion-item>
<ion-item text-wrap *ngIf="scorm.displayattemptstatus && scorm.gradeMethodReadable">
<p class="item-heading">{{ 'addon.mod_scorm.grademethod' | translate }}</p>
<p>{{ scorm.gradeMethodReadable }}</p>
</ion-item>
<ion-item text-wrap *ngIf="scorm.displayattemptstatus && scorm.grade">
<p class="item-heading">{{ 'addon.mod_scorm.gradereported' | translate }}</p>
<p *ngIf="scorm.grade != -1">{{ scorm.grade }}</p>
<p *ngIf="scorm.grade == -1">{{ 'addon.mod_scorm.cannotcalculategrade' | translate }}</p>
</ion-item>
<ion-item text-wrap *ngIf="syncTime">
<p class="item-heading">{{ 'core.lastsync' | translate }}</p>
<p>{{ syncTime }}</p>
</ion-item>
</ion-list>
</ion-card>
<!-- Synchronization warning. -->
<div class="core-warning-card" icon-start *ngIf="!errorMessage && hasOffline">
<ion-icon name="warning"></ion-icon>
{{ 'core.hasdatatosync' | translate: {$a: moduleName} }}
</div>
<!-- TOC. -->
<ion-card *ngIf="scorm && organizations && ((scorm.displaycoursestructure && organizations.length) || organizations.length > 1)" class="addon-mod_scorm-toc">
<ion-card-header text-wrap>
<h2>{{ 'addon.mod_scorm.contents' | translate }}</h2>
</ion-card-header>
<ion-list>
<ion-item text-wrap *ngIf="organizations.length > 1">
<ion-label>{{ 'addon.mod_scorm.organizations' | translate }}</ion-label>
<ion-select [(ngModel)]="currentOrganization.identifier" (ionChange)="loadOrganization()" interface="popover">
<ion-option *ngFor="let org of organizations" [value]="org.identifier">{{ org.title }}</ion-option>
</ion-select>
</ion-item>
<ion-item text-center *ngIf="scorm.displaycoursestructure && loadingToc">
<ion-spinner></ion-spinner>
</ion-item>
<ion-item text-wrap *ngIf="scorm.displaycoursestructure && !loadingToc">
<!-- If data shown doesn't belong to last attempt, show a warning. -->
<p *ngIf="attemptToContinue">{{ 'addon.mod_scorm.dataattemptshown' | translate:{number: attemptToContinue} }}</p>
<p>{{ currentOrganization.title }}</p>
<div *ngFor="let sco of toc" class="core-padding-{{sco.level}}">
<p *ngIf="sco.isvisible">
<img [src]="sco.image.url" [alt]="sco.image.description" />
<a *ngIf="sco.prereq && sco.launch" (click)="open($event, sco.id)">{{ sco.title }}</a>
<span *ngIf="!sco.prereq || !sco.launch">{{ sco.title }}</span>
</p>
</div>
</ion-item>
</ion-list>
</ion-card>
<!-- Open in browser button. -->
<ion-card *ngIf="errorMessage">
<ion-item text-wrap>
<p class="text-danger">{{ errorMessage | translate }}</p>
</ion-item>
<ion-item text-wrap>
<a ion-button block icon-end [href]="externalUrl" core-link>
{{ 'core.openinbrowser' | translate }}
<ion-icon name="open"></ion-icon>
</a>
</ion-item>
</ion-card>
<!-- Warning that user doesn't have any more attempts. -->
<ion-card *ngIf="!errorMessage && scorm && scorm.attemptsLeft <= 0">
<ion-item text-wrap>
<p class="text-danger">{{ 'addon.mod_scorm.exceededmaxattempts' | translate }}</p>
</ion-item>
</ion-card>
<!-- Open SCORM in app form -->
<ion-card *ngIf="!errorMessage && scorm && (!scorm.lastattemptlock || scorm.attemptsLeft > 0)">
<ion-list>
<!-- Open mode (Preview or Normal) -->
<div *ngIf="!scorm.hidebrowse" radio-group [(ngModel)]="scormOptions.mode" name="mode">
<ion-item>
<p class="item-heading">{{ 'addon.mod_scorm.mode' | translate }}</p>
</ion-item>
<ion-item>
<ion-label>{{ 'addon.mod_scorm.browse' | translate }}</ion-label>
<ion-radio [value]="modeBrowser"></ion-radio>
</ion-item>
<ion-item>
<ion-label>{{ 'addon.mod_scorm.normal' | translate }}</ion-label>
<ion-radio [value]="modeNormal"></ion-radio>
</ion-item>
</div>
<!-- Create new attempt -->
<ion-item text-wrap *ngIf="!scorm.forcenewattempt && scorm.numAttempts > 0 && !scorm.incomplete && scorm.attemptsLeft > 0">
<ion-label>{{ 'addon.mod_scorm.newattempt' | translate }}</ion-label>
<ion-checkbox item-end name="newAttempt" [(ngModel)]="scormOptions.newAttempt">
</ion-checkbox>
</ion-item>
<!-- Button to open the SCORM. -->
<ng-container *ngIf="!downloading">
<ion-item text-wrap *ngIf="statusMessage">
<p >{{ statusMessage | translate }}</p>
</ion-item>
<ion-item text-wrap>
<a ion-button block (click)="open($event)">{{ 'addon.mod_scorm.enter' | translate }}</a>
</ion-item>
</ng-container>
<!-- Download progress. -->
<ion-item text-center *ngIf="downloading">
<ion-spinner></ion-spinner>
<p *ngIf="progressMessage">{{ progressMessage | translate }}</p>
<p *ngIf="percentage <= 100">{{ 'core.percentagenumber' | translate:{$a: percentage} }}</p>
</ion-item>
</ion-list>
</ion-card>
</div>
</core-loading>

View File

@ -0,0 +1,9 @@
addon-mod-scorm-index {
.addon-mod_scorm-toc {
img {
width: auto;
display: inline;
}
}
}

View File

@ -0,0 +1,531 @@
// (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 } from '@angular/core';
import { Content, NavController } from 'ionic-angular';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main-activity-component';
import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate';
import { AddonModScormProvider, AddonModScormAttemptCountResult } from '../../providers/scorm';
import { AddonModScormHelperProvider } from '../../providers/helper';
import { AddonModScormOfflineProvider } from '../../providers/scorm-offline';
import { AddonModScormSyncProvider } from '../../providers/scorm-sync';
import { AddonModScormPrefetchHandler } from '../../providers/prefetch-handler';
import { CoreConstants } from '@core/constants';
/**
* Component that displays a SCORM entry page.
*/
@Component({
selector: 'addon-mod-scorm-index',
templateUrl: 'index.html',
})
export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityComponent {
component = AddonModScormProvider.COMPONENT;
moduleName = 'scorm';
scorm: any; // The SCORM object.
currentOrganization: any = {}; // Selected organization.
scormOptions: any = { // Options to open the SCORM.
mode: AddonModScormProvider.MODENORMAL,
newAttempt: false
};
modeNormal = AddonModScormProvider.MODENORMAL; // Normal open mode.
modeBrowser = AddonModScormProvider.MODEBROWSE; // Browser open mode.
errorMessage: string; // Error message.
syncTime: string; // Last sync time.
hasOffline: boolean; // Whether the SCORM has offline data.
attemptToContinue: number; // The attempt to continue or review.
statusMessage: string; // Message about the status.
downloading: boolean; // Whether the SCORM is being downloaded.
percentage: string; // Download/unzip percentage.
progressMessage: string; // Message about download/unzip.
organizations: any[]; // List of organizations.
loadingToc: boolean; // Whether the TOC is being loaded.
toc: any[]; // Table of contents (structure).
protected fetchContentDefaultError = 'addon.mod_scorm.errorgetscorm'; // Default error to show when loading contents.
protected syncEventName = AddonModScormSyncProvider.AUTO_SYNCED;
protected attempts: AddonModScormAttemptCountResult; // Data about online and offline attempts.
protected lastAttempt: number; // Last attempt.
protected lastIsOffline: boolean; // Whether the last attempt is offline.
protected hasPlayed = false; // Whether the user has opened the player page.
constructor(injector: Injector, protected scormProvider: AddonModScormProvider, @Optional() protected content: Content,
protected scormHelper: AddonModScormHelperProvider, protected scormOffline: AddonModScormOfflineProvider,
protected scormSync: AddonModScormSyncProvider, protected prefetchHandler: AddonModScormPrefetchHandler,
protected navCtrl: NavController, protected prefetchDelegate: CoreCourseModulePrefetchDelegate,
protected utils: CoreUtilsProvider) {
super(injector, content);
}
/**
* Component being initialized.
*/
ngOnInit(): void {
super.ngOnInit();
this.loadContent(false, true).then(() => {
if (!this.scorm) {
return;
}
this.scormProvider.logView(this.scorm.id).then(() => {
this.checkCompletion();
}).catch((error) => {
// Ignore errors.
});
});
}
/**
* Check the completion.
*/
protected checkCompletion(): void {
this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus);
}
/**
* Download a SCORM package or restores an ongoing download.
*
* @return {Promise<any>} Promise resolved when done.
*/
protected downloadScormPackage(): Promise<any> {
this.downloading = true;
return this.prefetchHandler.download(this.module, this.courseId, undefined, (data) => {
if (!data) {
return;
}
if (data.downloading) {
// Downloading package.
if (this.scorm.packagesize && data.progress) {
this.percentage = (Number(data.progress.loaded / this.scorm.packagesize) * 100).toFixed(1);
}
} else if (data.message) {
// Show a message.
this.progressMessage = data.message;
this.percentage = undefined;
} else if (data.progress && data.progress.loaded && data.progress.total) {
// Unzipping package.
this.percentage = (Number(data.progress.loaded / data.progress.total) * 100).toFixed(1);
} else {
this.percentage = undefined;
}
}).finally(() => {
this.progressMessage = undefined;
this.percentage = undefined;
this.downloading = false;
});
}
/**
* Get the SCORM 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> {
// Get the SCORM instance.
return this.scormProvider.getScorm(this.courseId, this.module.id, this.module.url).then((scormData) => {
this.scorm = scormData;
this.dataRetrieved.emit(this.scorm);
this.description = this.scorm.intro || this.description;
const result = this.scormProvider.isScormUnsupported(this.scorm);
if (result) {
this.errorMessage = result;
} else {
this.errorMessage = '';
}
if (this.scorm.warningMessage) {
return; // SCORM is closed or not open yet, we can't get more data.
}
let promise;
if (sync) {
// Try to synchronize the assign.
promise = this.syncActivity(showErrors).catch(() => {
// Ignore errors.
});
} else {
promise = Promise.resolve();
}
return promise.catch(() => {
// Ignore errors, keep getting data even if sync fails.
}).then(() => {
// No need to return this promise, it should be faster than the rest.
this.scormSync.getReadableSyncTime(this.scorm.id).then((syncTime) => {
this.syncTime = syncTime;
});
// Get the number of attempts.
return this.scormProvider.getAttemptCount(this.scorm.id);
}).then((attemptsData) => {
this.attempts = attemptsData;
this.hasOffline = !!this.attempts.offline.length;
// Determine the attempt that will be continued or reviewed.
return this.scormHelper.determineAttemptToContinue(this.scorm, this.attempts);
}).then((attempt) => {
this.lastAttempt = attempt.number;
this.lastIsOffline = attempt.offline;
if (this.lastAttempt != this.attempts.lastAttempt.number) {
this.attemptToContinue = this.lastAttempt;
} else {
this.attemptToContinue = undefined;
}
// Check if the last attempt is incomplete.
return this.scormProvider.isAttemptIncomplete(this.scorm.id, this.lastAttempt, this.lastIsOffline);
}).then((incomplete) => {
const promises = [];
this.scorm.incomplete = incomplete;
this.scorm.numAttempts = this.attempts.total;
this.scorm.gradeMethodReadable = this.scormProvider.getScormGradeMethod(this.scorm);
this.scorm.attemptsLeft = this.scormProvider.countAttemptsLeft(this.scorm, this.attempts.lastAttempt.number);
if (this.scorm.forceattempt && this.scorm.incomplete) {
this.scormOptions.newAttempt = true;
}
promises.push(this.getReportedGrades());
promises.push(this.fetchStructure());
if (!this.scorm.packagesize && this.errorMessage === '') {
// SCORM is supported but we don't have package size. Try to calculate it.
promises.push(this.scormProvider.calculateScormSize(this.scorm).then((size) => {
this.scorm.packagesize = size;
}));
}
// Handle status.
this.setStatusListener();
return Promise.all(promises);
});
}).then(() => {
// All data obtained, now fill the context menu.
this.fillContextMenu(refresh);
});
}
/**
* Fetch the structure of the SCORM (TOC).
*
* @return {Promise<any>} Promise resolved when done.
*/
protected fetchStructure(): Promise<any> {
return this.scormProvider.getOrganizations(this.scorm.id).then((organizations) => {
this.organizations = organizations;
if (!this.currentOrganization.identifier) {
// Load first organization (if any).
if (organizations.length) {
this.currentOrganization.identifier = organizations[0].identifier;
} else {
this.currentOrganization.identifier = '';
}
}
return this.loadOrganizationToc(this.currentOrganization.identifier);
});
}
/**
* Get the grade of an attempt and add it to the scorm attempts list.
*
* @param {number} attempt The attempt number.
* @param {boolean} offline Whether it's an offline attempt.
* @param {any} attempts Object where to add the attempt.
* @return {Promise<void>} Promise resolved when done.
*/
protected getAttemptGrade(attempt: number, offline: boolean, attempts: any): Promise<void> {
return this.scormProvider.getAttemptGrade(this.scorm, attempt, offline).then((grade) => {
attempts[attempt] = {
number: attempt,
grade: grade
};
});
}
/**
* Get the grades of each attempt and the grade of the SCORM.
*
* @return {Promise<any>} Promise resolved when done.
*/
protected getReportedGrades(): Promise<any> {
const promises = [],
onlineAttempts = {},
offlineAttempts = {};
// Calculate the grade for each attempt.
this.attempts.online.forEach((attempt) => {
// Check that attempt isn't in offline to prevent showing the same attempt twice. Offline should be more recent.
if (this.attempts.offline.indexOf(attempt) == -1) {
promises.push(this.getAttemptGrade(attempt, false, onlineAttempts));
}
});
this.attempts.offline.forEach((attempt) => {
promises.push(this.getAttemptGrade(attempt, true, offlineAttempts));
});
return Promise.all(promises).then(() => {
// Calculate the grade of the whole SCORM. We only use online attempts to calculate this data.
this.scorm.grade = this.scormProvider.calculateScormGrade(this.scorm, onlineAttempts);
// Add the attempts to the SCORM in array format in ASC order, and format the grades.
this.scorm.onlineAttempts = this.utils.objectToArray(onlineAttempts);
this.scorm.offlineAttempts = this.utils.objectToArray(offlineAttempts);
this.scorm.onlineAttempts.sort((a, b) => {
return a.number - b.number;
});
this.scorm.offlineAttempts.sort((a, b) => {
return a.number - b.number;
});
// Now format the grades.
this.scorm.onlineAttempts.forEach((attempt) => {
attempt.grade = this.scormProvider.formatGrade(this.scorm, attempt.grade);
});
this.scorm.offlineAttempts.forEach((attempt) => {
attempt.grade = this.scormProvider.formatGrade(this.scorm, attempt.grade);
});
this.scorm.grade = this.scormProvider.formatGrade(this.scorm, this.scorm.grade);
});
}
/**
* 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 {
if (result.updated) {
// Check completion status.
this.checkCompletion();
}
return true;
}
/**
* User entered the page that contains the component.
*/
ionViewDidEnter(): void {
super.ionViewDidEnter();
if (this.hasPlayed) {
this.hasPlayed = false;
this.scormOptions.newAttempt = false; // Uncheck new attempt.
// Add a delay to make sure the player has started the last writing calls so we can detect conflicts.
setTimeout(() => {
// Refresh data.
this.showLoadingAndRefresh(true, false);
}, 500);
}
}
/**
* User left the page that contains the component.
*/
ionViewDidLeave(): void {
super.ionViewDidLeave();
if (this.navCtrl.getActive().component.name == 'AddonModScormPlayerPage') {
this.hasPlayed = true;
}
}
/**
* Perform the invalidate content function.
*
* @return {Promise<any>} Resolved when done.
*/
protected invalidateContent(): Promise<any> {
const promises = [];
promises.push(this.scormProvider.invalidateScormData(this.courseId));
if (this.scorm) {
promises.push(this.scormProvider.invalidateAllScormData(this.scorm.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 {
if (syncEventData.updated && this.scorm && syncEventData.scormId == this.scorm.id) {
// Check completion status.
this.checkCompletion();
return true;
}
return false;
}
/**
* Load a organization's TOC.
*/
loadOrganization(): void {
this.loadOrganizationToc(this.currentOrganization.identifier).catch((error) => {
this.domUtils.showErrorModalDefault(error, this.fetchContentDefaultError, true);
});
}
/**
* Load the TOC of a certain organization.
*
* @param {string} organizationId The organization id.
* @return {Promise<any>} Promise resolved when done.
*/
protected loadOrganizationToc(organizationId: string): Promise<any> {
if (!this.scorm.displaycoursestructure) {
// TOC is not displayed, no need to load it.
return Promise.resolve();
}
this.loadingToc = true;
return this.scormProvider.getOrganizationToc(this.scorm.id, this.lastAttempt, organizationId, this.lastIsOffline)
.then((toc) => {
this.toc = this.scormProvider.formatTocToArray(toc);
// Get images for each SCO.
this.toc.forEach((sco) => {
sco.image = this.scormProvider.getScoStatusIcon(sco, this.scorm.incomplete);
});
// Search organization title.
this.organizations.forEach((org) => {
if (org.identifier == organizationId) {
this.currentOrganization.title = org.title;
}
});
}).finally(() => {
this.loadingToc = false;
});
}
// Open a SCORM. It will download the SCORM package if it's not downloaded or it has changed.
// The scoId param indicates the SCO that needs to be loaded when the SCORM is opened. If not defined, load first SCO.
open(e: Event, scoId: number): void {
e.preventDefault();
e.stopPropagation();
if (this.downloading) {
// Scope is being downloaded, abort.
return;
}
const isOutdated = this.currentStatus == CoreConstants.OUTDATED;
if (isOutdated || this.currentStatus == CoreConstants.NOT_DOWNLOADED) {
// SCORM needs to be downloaded.
this.scormHelper.confirmDownload(this.scorm, isOutdated).then(() => {
// Invalidate WS data if SCORM is outdated.
const promise = isOutdated ? this.scormProvider.invalidateAllScormData(this.scorm.id) : Promise.resolve();
promise.finally(() => {
this.downloadScormPackage().then(() => {
// Success downloading, open SCORM if user hasn't left the view.
if (!this.isDestroyed) {
this.openScorm(scoId);
}
}).catch((error) => {
if (!this.isDestroyed) {
this.domUtils.showErrorModalDefault(error, this.translate.instant(
'addon.mod_scorm.errordownloadscorm', {name: this.scorm.name}));
}
});
});
});
} else {
this.openScorm(scoId);
}
}
/**
* Open a SCORM package.
*
* @param {number} scoId SCO ID.
*/
protected openScorm(scoId: number): void {
this.navCtrl.push('AddonModScormPlayerPage', {
scorm: this.scorm,
mode: this.scormOptions.mode,
newAttempt: !!this.scormOptions.newAttempt,
organizationId: this.currentOrganization.identifier,
scoId: scoId
});
}
/**
* 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 {
if (status == CoreConstants.OUTDATED && this.scorm) {
// Only show the outdated message if the file should be downloaded.
this.scormProvider.shouldDownloadMainFile(this.scorm, true).then((download) => {
this.statusMessage = download ? 'addon.mod_scorm.scormstatusoutdated' : '';
});
} else if (status == CoreConstants.NOT_DOWNLOADED) {
this.statusMessage = 'addon.mod_scorm.scormstatusnotdownloaded';
} else if (status == CoreConstants.DOWNLOADING) {
if (!this.downloading) {
// It's being downloaded right now but the view isn't tracking it. "Restore" the download.
this.downloadScormPackage();
}
} else {
this.statusMessage = '';
}
}
/**
* Performs the sync of the activity.
*
* @return {Promise<any>} Promise resolved when done.
*/
protected sync(): Promise<any> {
return this.scormSync.syncScorm(this.scorm);
}
}

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]="scormComponent.loaded" (ionRefresh)="scormComponent.doRefresh($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<addon-mod-scorm-index [module]="module" [courseId]="courseId" (dataRetrieved)="updateData($event)"></addon-mod-scorm-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 { AddonModScormComponentsModule } from '../../components/components.module';
import { AddonModScormIndexPage } from './index';
@NgModule({
declarations: [
AddonModScormIndexPage,
],
imports: [
CoreDirectivesModule,
AddonModScormComponentsModule,
IonicPageModule.forChild(AddonModScormIndexPage),
TranslateModule.forChild()
],
})
export class AddonModScormIndexPageModule {}

View File

@ -0,0 +1,62 @@
// (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 { AddonModScormIndexComponent } from '../../components/index/index';
/**
* Page that displays the SCORM entry page.
*/
@IonicPage({ segment: 'addon-mod-scorm-index' })
@Component({
selector: 'page-addon-mod-scorm-index',
templateUrl: 'index.html',
})
export class AddonModScormIndexPage {
@ViewChild(AddonModScormIndexComponent) scormComponent: AddonModScormIndexComponent;
title: string;
module: any;
courseId: number;
constructor(navParams: NavParams) {
this.module = navParams.get('module') || {};
this.courseId = navParams.get('courseId');
this.title = this.module.name;
}
/**
* Update some data based on the SCORM instance.
*
* @param {any} scorm SCORM instance.
*/
updateData(scorm: any): void {
this.title = scorm.name || this.title;
}
/**
* User entered the page.
*/
ionViewDidEnter(): void {
this.scormComponent.ionViewDidEnter();
}
/**
* User left the page.
*/
ionViewDidLeave(): void {
this.scormComponent.ionViewDidLeave();
}
}

View File

@ -0,0 +1,121 @@
// (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 { AddonModScormProvider, AddonModScormAttemptCountResult } from './scorm';
/**
* Helper service that provides some features for SCORM.
*/
@Injectable()
export class AddonModScormHelperProvider {
protected div = document.createElement('div'); // A div element to search in HTML code.
constructor(private domUtils: CoreDomUtilsProvider, private scormProvider: AddonModScormProvider) { }
/**
* Show a confirm dialog if needed. If SCORM doesn't have size, try to calculate it.
*
* @param {any} scorm SCORM to download.
* @param {boolean} [isOutdated] True if package outdated, false if not outdated, undefined to calculate it.
* @return {Promise<any>} Promise resolved if the user confirms or no confirmation needed.
*/
confirmDownload(scorm: any, isOutdated?: boolean): Promise<any> {
// Check if file should be downloaded.
return this.scormProvider.shouldDownloadMainFile(scorm, isOutdated).then((download) => {
if (download) {
let subPromise;
if (!scorm.packagesize) {
// We don't have package size, try to calculate it.
subPromise = this.scormProvider.calculateScormSize(scorm).then((size) => {
// Store it so we don't have to calculate it again when using the same object.
scorm.packagesize = size;
return size;
});
} else {
subPromise = Promise.resolve(scorm.packagesize);
}
return subPromise.then((size) => {
return this.domUtils.confirmDownloadSize({size: size, total: true});
});
}
});
}
/**
* Determines the attempt to continue/review. It will be:
* - The last incomplete online attempt if it hasn't been continued in offline and all offline attempts are complete.
* - The attempt with highest number without surpassing max attempts otherwise.
*
* @param {any} scorm SCORM object.
* @param {AddonModScormAttemptCountResult} attempts Attempts count.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<{number: number, offline: boolean}>} Promise resolved with the attempt data.
*/
determineAttemptToContinue(scorm: any, attempts: AddonModScormAttemptCountResult, siteId?: string)
: Promise<{number: number, offline: boolean}> {
let lastOnline;
// Get last online attempt.
if (attempts.online.length) {
lastOnline = Math.max.apply(Math, attempts.online);
}
if (lastOnline) {
// Check if last online incomplete.
const hasOffline = attempts.offline.indexOf(lastOnline) > -1;
return this.scormProvider.isAttemptIncomplete(scorm.id, lastOnline, hasOffline, false, siteId).then((incomplete) => {
if (incomplete) {
return {
number: lastOnline,
offline: hasOffline
};
} else {
return this.getLastBeforeMax(scorm, attempts);
}
});
} else {
return Promise.resolve(this.getLastBeforeMax(scorm, attempts));
}
}
/**
* Get the last attempt (number and whether it's offline).
* It'll be the highest number as long as it doesn't surpass the max number of attempts.
*
* @param {any} scorm SCORM object.
* @param {AddonModScormAttemptCountResult} attempts Attempts count.
* @return {{number: number, offline: boolean}} Last attempt data.
*/
protected getLastBeforeMax(scorm: any, attempts: AddonModScormAttemptCountResult): {number: number, offline: boolean} {
if (scorm.maxattempt != 0 && attempts.lastAttempt.number > scorm.maxattempt) {
return {
number: scorm.maxattempt,
offline: attempts.offline.indexOf(scorm.maxattempt) > -1
};
} else {
return {
number: attempts.lastAttempt.number,
offline: attempts.lastAttempt.offline
};
}
}
}

View File

@ -18,6 +18,7 @@ import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate
import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate';
import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate';
import { AddonModScormProvider } from './providers/scorm'; import { AddonModScormProvider } from './providers/scorm';
import { AddonModScormHelperProvider } from './providers/helper';
import { AddonModScormOfflineProvider } from './providers/scorm-offline'; import { AddonModScormOfflineProvider } from './providers/scorm-offline';
import { AddonModScormModuleHandler } from './providers/module-handler'; import { AddonModScormModuleHandler } from './providers/module-handler';
import { AddonModScormPrefetchHandler } from './providers/prefetch-handler'; import { AddonModScormPrefetchHandler } from './providers/prefetch-handler';
@ -36,6 +37,7 @@ import { AddonModScormComponentsModule } from './components/components.module';
providers: [ providers: [
AddonModScormProvider, AddonModScormProvider,
AddonModScormOfflineProvider, AddonModScormOfflineProvider,
AddonModScormHelperProvider,
AddonModScormSyncProvider, AddonModScormSyncProvider,
AddonModScormModuleHandler, AddonModScormModuleHandler,
AddonModScormPrefetchHandler, AddonModScormPrefetchHandler,

View File

@ -104,3 +104,10 @@
} }
} }
} }
// Different levels of padding.
@for $i from 0 through 15 {
.ios .core-padding-#{$i} {
padding-left: 15px * $i + $item-ios-padding-start;
}
}

View File

@ -105,3 +105,10 @@
} }
} }
} }
// Different levels of padding.
@for $i from 0 through 15 {
.md .core-padding-#{$i} {
padding-left: 15px * $i + $item-md-padding-start;
}
}

View File

@ -621,7 +621,7 @@ canvas[core-chart] {
color: $color-base; color: $color-base;
} }
.text-#{$color-name}, p.#{$color-name}, .item p.text-#{$color-name} { .text-#{$color-name}, p.text-#{$color-name}, .item p.text-#{$color-name}, .card p.text-#{$color-name} {
color: $color-base; color: $color-base;
} }
} }

View File

@ -40,3 +40,10 @@
top: $navbar-wp-height; top: $navbar-wp-height;
height: calc(100% - #{($navbar-wp-height)}); height: calc(100% - #{($navbar-wp-height)});
} }
// Different levels of padding.
@for $i from 0 through 15 {
.wp .core-padding-#{$i} {
padding-left: 15px * $i + $item-wp-padding-start;
}
}

View File

@ -46,7 +46,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR
protected courseProvider: CoreCourseProvider; protected courseProvider: CoreCourseProvider;
protected appProvider: CoreAppProvider; protected appProvider: CoreAppProvider;
protected eventsProvider: CoreEventsProvider; protected eventsProvider: CoreEventsProvider;
protected modulePrefetchProvider: CoreCourseModulePrefetchDelegate; protected modulePrefetchDelegate: CoreCourseModulePrefetchDelegate;
constructor(injector: Injector, protected content?: Content) { constructor(injector: Injector, protected content?: Content) {
super(injector); super(injector);
@ -55,6 +55,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR
this.courseProvider = injector.get(CoreCourseProvider); this.courseProvider = injector.get(CoreCourseProvider);
this.appProvider = injector.get(CoreAppProvider); this.appProvider = injector.get(CoreAppProvider);
this.eventsProvider = injector.get(CoreEventsProvider); this.eventsProvider = injector.get(CoreEventsProvider);
this.modulePrefetchDelegate = injector.get(CoreCourseModulePrefetchDelegate);
const network = injector.get(Network); const network = injector.get(Network);
@ -158,7 +159,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR
this.loaded = false; this.loaded = false;
this.content && this.content.scrollToTop(); this.content && this.content.scrollToTop();
return this.refreshContent(true, showErrors); return this.refreshContent(sync, showErrors);
} }
/** /**
@ -226,7 +227,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR
}, this.siteId); }, this.siteId);
// Also, get the current status. // Also, get the current status.
this.modulePrefetchProvider.getModuleStatus(this.module, this.courseId).then((status) => { this.modulePrefetchDelegate.getModuleStatus(this.module, this.courseId).then((status) => {
this.currentStatus = status; this.currentStatus = status;
this.showStatus(status); this.showStatus(status);
}); });