MOBILE-3653 scorm: Implement index page

main
Dani Palou 2021-03-24 14:31:08 +01:00
parent aebc359083
commit a2cf8db2ea
9 changed files with 1022 additions and 1 deletions

View File

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

View File

@ -0,0 +1,246 @@
<!-- Buttons to add to the header. -->
<core-navbar-buttons slot="end">
<core-context-menu>
<core-context-menu-item *ngIf="externalUrl" [priority]="900" [content]="'core.openinbrowser' | translate"
[href]="externalUrl" iconAction="fas-external-link-alt">
</core-context-menu-item>
<core-context-menu-item *ngIf="description" [priority]="800" [content]="'core.moduleintro' | translate"
(action)="expandDescription()" iconAction="fas-arrow-right">
</core-context-menu-item>
<core-context-menu-item *ngIf="blog" [priority]="750" content="{{'addon.blog.blog' | translate}}"
iconAction="far-newspaper" (action)="gotoBlog()">
</core-context-menu-item>
<core-context-menu-item *ngIf="loaded && !hasOffline && isOnline" [priority]="700" [content]="'core.refresh' | translate"
(action)="doRefresh(null, $event)" [iconAction]="refreshIcon" [closeOnClick]="false">
</core-context-menu-item>
<core-context-menu-item *ngIf="loaded && hasOffline && isOnline" [priority]="600" (action)="doRefresh(null, $event, true)"
[content]="'core.settings.synchronizenow' | translate" [iconAction]="syncIcon" [closeOnClick]="false">
</core-context-menu-item>
<core-context-menu-item *ngIf="prefetchStatusIcon" [priority]="500" [content]="prefetchText" (action)="prefetch($event)"
[iconAction]="prefetchStatusIcon" [closeOnClick]="false">
</core-context-menu-item>
<core-context-menu-item *ngIf="size" [priority]="400" [content]="'core.clearstoreddata' | translate:{$a: size}"
iconDescription="fas-archive" (action)="removeFiles($event)" iconAction="fas-trash" [closeOnClick]="false">
</core-context-menu-item>
</core-context-menu>
</core-navbar-buttons>
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="core-loading-center">
<core-course-module-description [description]="description" [component]="component" [componentId]="componentId"
contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-course-module-description>
<!-- Warning message. -->
<ion-card class="core-info-card" *ngIf="scorm && scorm.warningMessage">
<ion-item>
<ion-icon name="fas-info-circle" slot="start"></ion-icon>
<ion-label>{{ scorm.warningMessage }}</ion-label>
</ion-item>
</ion-card>
<ng-container *ngIf="scorm && loaded && !scorm.warningMessage">
<!-- Attempts status. -->
<ion-card *ngIf="(scorm.displayattemptstatus || offlineAttempts.length) && !skip">
<ion-card-header class="ion-text-wrap">
<ion-card-title>{{ 'addon.mod_scorm.attempts' | translate }}</ion-card-title>
</ion-card-header>
<ion-list class="addon-mod_scorm-attempt-summary">
<ng-container *ngIf="scorm.displayattemptstatus">
<ion-item class="ion-text-wrap" *ngIf="scorm.maxattempt! >= 0">
<ion-label>
<h3>{{ 'addon.mod_scorm.noattemptsallowed' | translate }}</h3>
</ion-label>
<p slot="end">
<span *ngIf="scorm.maxattempt == 0">{{ 'core.unlimited' | translate }}</span>
<span *ngIf="scorm.maxattempt! > 0">{{ scorm.maxattempt }}</span>
</p>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="numAttempts >= 0">
<ion-label>
<h3>{{ 'addon.mod_scorm.noattemptsmade' | translate }}</h3>
</ion-label>
<p slot="end">{{ numAttempts }}</p>
</ion-item>
<ion-item class="ion-text-wrap" *ngFor="let attempt of onlineAttempts">
<ion-label>
<h3>{{ 'addon.mod_scorm.gradeforattempt' | translate }} {{attempt.num}}</h3>
</ion-label>
<p slot="end">
<span *ngIf="attempt.grade != -1">{{ attempt.gradeFormatted }}</span>
<span *ngIf="attempt.grade == -1">{{ 'addon.mod_scorm.cannotcalculategrade' | translate }}</span>
</p>
</ion-item>
</ng-container>
<ion-item class="ion-text-wrap" *ngFor="let attempt of offlineAttempts">
<ion-label>
<h3>{{ 'addon.mod_scorm.gradeforattempt' | translate }} {{attempt.num}}</h3>
<p *ngIf="!scorm.maxattempt || attempt.num <= scorm.maxattempt">
{{ 'addon.mod_scorm.offlineattemptnote' | translate }}
</p>
<p *ngIf="scorm.maxattempt && attempt.num > scorm.maxattempt">
{{ 'addon.mod_scorm.offlineattemptovermax' | translate }}
</p>
</ion-label>
<p slot="end">
<span *ngIf="attempt.grade != -1">{{ attempt.gradeFormatted }}</span>
<span *ngIf="attempt.grade == -1">{{ 'addon.mod_scorm.cannotcalculategrade' | translate }}</span>
</p>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="scorm.displayattemptstatus && gradeMethodReadable">
<ion-label>
<h3>{{ 'addon.mod_scorm.grademethod' | translate }}</h3>
</ion-label>
<p slot="end">{{ gradeMethodReadable }}</p>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="scorm.displayattemptstatus && gradeFormatted">
<ion-label>
<h3>{{ 'addon.mod_scorm.gradereported' | translate }}</h3>
</ion-label>
<p slot="end">
<span *ngIf="grade != -1">{{ gradeFormatted }}</span>
<span *ngIf="grade == -1">{{ 'addon.mod_scorm.cannotcalculategrade' | translate }}</span>
</p>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="syncTime">
<ion-label>
<h3>{{ 'core.lastsync' | translate }}</h3>
<p>{{ syncTime }}</p>
</ion-label>
</ion-item>
</ion-list>
</ion-card>
<!-- Synchronization warning. -->
<ion-card class="core-warning-card" *ngIf="!errorMessage && hasOffline">
<ion-item>
<ion-icon name="fas-exclamation-triangle" slot="start"></ion-icon>
<ion-label>{{ 'core.hasdatatosync' | translate: {$a: moduleName} }}</ion-label>
</ion-item>
</ion-card>
<!-- TOC. -->
<ion-card *ngIf="scorm && organizations && !skip &&
((scorm.displaycoursestructure && organizations.length) || organizations.length > 1)" class="addon-mod_scorm-toc">
<ion-card-header class="ion-text-wrap">
<ion-card-title>{{ 'addon.mod_scorm.contents' | translate }}</ion-card-title>
</ion-card-header>
<ion-list>
<ion-item class="ion-text-wrap" *ngIf="organizations.length > 1">
<ion-label>{{ 'addon.mod_scorm.organizations' | translate }}</ion-label>
<ion-select [(ngModel)]="currentOrganization.identifier" (ionChange)="loadOrganization()"
interface="action-sheet">
<ion-select-option *ngFor="let org of organizations" [value]="org.identifier">
{{ org.title }}
</ion-select-option>
</ion-select>
</ion-item>
<ion-item class="ion-text-center" *ngIf="scorm.displaycoursestructure && loadingToc">
<ion-label>
<ion-spinner></ion-spinner>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="scorm.displaycoursestructure && !loadingToc">
<!-- If data shown doesn't belong to last attempt, show a warning. -->
<ion-label>
<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}} addon-mod_scorm-type-{{sco.scormtype}}">
<p *ngIf="sco.isvisible">
<ion-icon *ngIf="sco.icon" [name]="sco.icon.icon" [attr.aria-label]="sco.icon.description"
slot="start">
</ion-icon>
<a *ngIf="sco.prereq && sco.launch" (click)="open($event, false, sco.id)" tappable="true">
<core-format-text [text]="sco.title" contextLevel="module" [contextInstanceId]="module.id"
[courseId]="courseId">
</core-format-text>
</a>
<span *ngIf="!sco.prereq || !sco.launch">
<core-format-text [text]="sco.title" contextLevel="module" [contextInstanceId]="module.id"
[courseId]="courseId">
</core-format-text>
</span>
<span *ngIf="accessInfo && accessInfo.canviewscores && sco.scoreraw">
({{ 'addon.mod_scorm.score' | translate }}: {{sco.scoreraw}})
</span>
</p>
</div>
</ion-label>
</ion-item>
</ion-list>
</ion-card>
<!-- Open in browser button. -->
<ion-card *ngIf="errorMessage">
<ion-item class="ion-text-wrap">
<ion-label>
<p class="text-danger">{{ errorMessage | translate }}</p>
</ion-label>
</ion-item>
<ion-button class="ion-margin ion-text-wrap" expand="block" [href]="externalUrl" core-link>
{{ 'core.openinbrowser' | translate }}
<ion-icon name="fas-external-link-alt" slot="end"></ion-icon>
</ion-button>
</ion-card>
<!-- Warning that user doesn't have any more attempts. -->
<ion-card *ngIf="!errorMessage && attemptsLeft == 0">
<ion-item class="ion-text-wrap">
<ion-label>
<p class="text-danger">{{ 'addon.mod_scorm.exceededmaxattempts' | translate }}</p>
</ion-label>
</ion-item>
</ion-card>
<!-- Open SCORM in app form -->
<ion-card *ngIf="!errorMessage && scorm && (!scorm.lastattemptlock || attemptsLeft > 0)">
<ion-list>
<ng-container *ngIf="!downloading && !skip">
<!-- Create new attempt -->
<ion-item class="ion-text-wrap"
*ngIf="!scorm.forcenewattempt && numAttempts > 0 && !incomplete && attemptsLeft > 0">
<ion-label>{{ 'addon.mod_scorm.newattempt' | translate }}</ion-label>
<ion-checkbox slot="end" name="newAttempt" [(ngModel)]="startNewAttempt">
</ion-checkbox>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="statusMessage">
<ion-label>
<p>{{ statusMessage | translate }}</p>
</ion-label>
</ion-item>
<!-- Open mode (Preview or Normal) -->
<ion-grid>
<ion-row class="ion-align-items-center">
<ion-col *ngIf="!scorm.hidebrowse">
<ion-button expand="block" fill="outline" (click)="open($event, true)" class="ion-text-wrap">
{{ 'addon.mod_scorm.browse' | translate }}
<ion-icon name="fas-search" slot="end"></ion-icon>
</ion-button>
</ion-col>
<ion-col>
<ion-button expand="block" (click)="open($event)" class="ion-text-wrap">
{{ 'addon.mod_scorm.enter' | translate }}
<ion-icon name="fas-arrow-right" slot="end"></ion-icon>
</ion-button>
</ion-col>
</ion-row>
</ion-grid>
</ng-container>
<!-- Download progress. -->
<ion-item class="ion-text-center" *ngIf="downloading">
<ion-label>
<ion-spinner></ion-spinner>
<h2 *ngIf="progressMessage">{{ progressMessage | translate }}</h2>
<core-progress-bar *ngIf="showPercentage" [progress]="percentage"></core-progress-bar>
</ion-label>
</ion-item>
</ion-list>
</ion-card>
</ng-container>
</core-loading>

View File

@ -0,0 +1,19 @@
@import "~theme/globals";
:host {
.addon-mod_scorm-attempt-summary ion-item > p {
font-size: 14px;
}
.addon-mod_scorm-toc {
// Hide all non sco icons using css to maintain padding.
ion-icon {
opacity: 0;
@include margin(5px, 8px, null, null);
}
.addon-mod_scorm-type-sco ion-icon {
opacity: 1
}
}
}

View File

@ -0,0 +1,605 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { CoreConstants } from '@/core/constants';
import { Component, OnInit, Optional } from '@angular/core';
import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component';
import { CoreCourseContentsPage } from '@features/course/pages/contents/contents';
import { CoreCourse } from '@features/course/services/course';
import { IonContent } from '@ionic/angular';
import { CoreNavigator } from '@services/navigator';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { Translate } from '@singletons';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { AddonModScormPrefetchHandler } from '../../services/handlers/prefetch';
import {
AddonModScorm,
AddonModScormAttemptCountResult,
AddonModScormGetScormAccessInformationWSResponse,
AddonModScormAttemptGrade,
AddonModScormOrganization,
AddonModScormProvider,
AddonModScormScorm,
} from '../../services/scorm';
import { AddonModScormHelper, AddonModScormTOCScoWithIcon } from '../../services/scorm-helper';
import {
AddonModScormAutoSyncEventData,
AddonModScormSync,
AddonModScormSyncProvider,
AddonModScormSyncResult,
} from '../../services/scorm-sync';
/**
* Component that displays a SCORM entry page.
*/
@Component({
selector: 'addon-mod-scorm-index',
templateUrl: 'addon-mod-scorm-index.html',
styleUrls: ['index.scss'],
})
export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit {
component = AddonModScormProvider.COMPONENT;
moduleName = 'scorm';
scorm?: AddonModScormScorm; // The SCORM object.
currentOrganization: Partial<AddonModScormOrganization> = {}; // Selected organization.
startNewAttempt = false;
errorMessage?: string; // Error message.
syncTime?: string; // Last sync time.
hasOffline = false; // Whether the SCORM has offline data.
attemptToContinue?: number; // The attempt to continue or review.
statusMessage?: string; // Message about the status.
downloading = false; // Whether the SCORM is being downloaded.
percentage?: string; // Download/unzip percentage.
showPercentage = false; // Whether to show the percentage.
progressMessage?: string; // Message about download/unzip.
organizations?: AddonModScormOrganization[]; // List of organizations.
loadingToc = false; // Whether the TOC is being loaded.
toc?: AddonModScormTOCScoWithIcon[]; // Table of contents (structure).
accessInfo?: AddonModScormGetScormAccessInformationWSResponse; // Access information.
skip?: boolean; // Launch immediately.
incomplete = false; // Whether last attempt is incomplete.
numAttempts = -1; // Number of attempts.
grade?: number; // Grade.
gradeFormatted?: string; // Grade formatted.
gradeMethodReadable?: string; // Grade method in a readable format.
attemptsLeft = -1; // Number of attempts left.
onlineAttempts: AttemptGrade[] = []; // Grades for online attempts.
offlineAttempts: AttemptGrade[] = []; // Grades for offline attempts.
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 = false; // Whether the last attempt is offline.
protected hasPlayed = false; // Whether the user has opened the player page.
protected dataSentObserver?: CoreEventObserver; // To detect data sent to server.
protected dataSent = false; // Whether some data was sent to server while playing the SCORM.
constructor(
protected content?: IonContent,
@Optional() courseContentsPage?: CoreCourseContentsPage,
) {
super('AddonModScormIndexComponent', content, courseContentsPage);
}
/**
* Component being initialized.
*/
async ngOnInit(): Promise<void> {
super.ngOnInit();
await this.loadContent(false, true);
if (!this.scorm) {
return;
}
if (this.skip) {
this.open();
}
try {
await AddonModScorm.logView(this.scorm.id, this.scorm.name);
this.checkCompletion();
} catch {
// Ignore errors.
}
}
/**
* Check the completion.
*/
protected checkCompletion(): void {
CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata);
}
/**
* Download a SCORM package or restores an ongoing download.
*
* @return Promise resolved when done.
*/
protected async downloadScormPackage(): Promise<void> {
this.downloading = true;
try {
await AddonModScormPrefetchHandler.download(this.module, this.courseId, undefined, (data) => {
if (!data) {
return;
}
this.percentage = undefined;
this.showPercentage = false;
if (data.downloading) {
// Downloading package.
if (this.scorm!.packagesize && data.progress) {
const percentageNumber = Number(data.progress.loaded / this.scorm!.packagesize) * 100;
this.percentage = percentageNumber.toFixed(1);
this.showPercentage = percentageNumber >= 0 && percentageNumber <= 100;
}
} else if (data.message) {
// Show a message.
this.progressMessage = data.message;
} else if (data.progress && data.progress.loaded && data.progress.total) {
// Unzipping package.
const percentageNumber = Number(data.progress.loaded / data.progress.total) * 100;
this.percentage = percentageNumber.toFixed(1);
this.showPercentage = percentageNumber >= 0 && percentageNumber <= 100;
}
});
} finally {
this.progressMessage = undefined;
this.percentage = undefined;
this.downloading = false;
}
}
/**
* @inheritdoc
*/
protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<void> {
try {
// Get the SCORM instance.
this.scorm = await AddonModScorm.getScorm(this.courseId, this.module.id, { moduleUrl: this.module.url });
this.dataRetrieved.emit(this.scorm);
this.description = this.scorm.intro || this.description;
this.errorMessage = AddonModScorm.isScormUnsupported(this.scorm);
if (this.scorm.warningMessage) {
return; // SCORM is closed or not open yet, we can't get more data.
}
if (sync) {
// Try to synchronize the SCORM.
await CoreUtils.ignoreErrors(this.syncActivity(showErrors));
}
const [syncTime, accessInfo] = await Promise.all([
AddonModScormSync.getReadableSyncTime(this.scorm.id),
AddonModScorm.getAccessInformation(this.scorm.id, { cmId: this.module.id }),
this.fetchAttemptData(this.scorm),
]);
this.syncTime = syncTime;
this.accessInfo = accessInfo;
// Check whether to launch the SCORM immediately.
if (typeof this.skip == 'undefined') {
this.skip = !this.hasOffline && !this.errorMessage &&
(!this.scorm.lastattemptlock || this.attemptsLeft > 0) &&
this.accessInfo.canskipview && !this.accessInfo.canviewreport &&
this.scorm.skipview! >= AddonModScormProvider.SKIPVIEW_FIRST &&
(this.scorm.skipview == AddonModScormProvider.SKIPVIEW_ALWAYS || this.lastAttempt == 0);
}
} finally {
this.fillContextMenu(refresh);
}
}
/**
* Fetch attempt data.
*
* @param scorm Scorm.
* @return Promise resolved when done.
*/
protected async fetchAttemptData(scorm: AddonModScormScorm): Promise<void> {
// Get the number of attempts.
this.attempts = await AddonModScorm.getAttemptCount(scorm.id, { cmId: this.module.id });
this.hasOffline = !!this.attempts.offline.length;
// Determine the attempt that will be continued or reviewed.
const attempt = await AddonModScormHelper.determineAttemptToContinue(scorm, this.attempts);
this.lastAttempt = attempt.num;
this.lastIsOffline = attempt.offline;
if (this.lastAttempt != this.attempts.lastAttempt.num) {
this.attemptToContinue = this.lastAttempt;
} else {
this.attemptToContinue = undefined;
}
// Check if the last attempt is incomplete.
this.incomplete = await AddonModScorm.isAttemptIncomplete(scorm.id, this.lastAttempt, {
offline: this.lastIsOffline,
cmId: this.module.id,
});
this.numAttempts = this.attempts.total;
this.gradeMethodReadable = AddonModScorm.getScormGradeMethod(scorm);
this.attemptsLeft = AddonModScorm.countAttemptsLeft(scorm, this.attempts.lastAttempt.num);
if (scorm.forcenewattempt == AddonModScormProvider.SCORM_FORCEATTEMPT_ALWAYS ||
(scorm.forcenewattempt && !this.incomplete)) {
this.startNewAttempt = true;
}
await Promise.all([
this.getReportedGrades(scorm, this.attempts),
this.fetchStructure(scorm),
this.loadPackageSize(scorm),
this.setStatusListener(),
]);
}
/**
* Load SCORM package size if needed.
*
* @return Promise resolved when done.
*/
protected async loadPackageSize(scorm: AddonModScormScorm): Promise<void> {
if (scorm.packagesize || this.errorMessage) {
return;
}
// SCORM is supported but we don't have package size. Try to calculate it.
scorm.packagesize = await AddonModScorm.calculateScormSize(scorm);
}
/**
* Fetch the structure of the SCORM (TOC).
*
* @return Promise resolved when done.
*/
protected async fetchStructure(scorm: AddonModScormScorm): Promise<void> {
this.organizations = await AddonModScorm.getOrganizations(scorm.id, { cmId: this.module.id });
if (!this.currentOrganization.identifier) {
// Load first organization (if any).
if (this.organizations.length) {
this.currentOrganization.identifier = this.organizations[0].identifier;
} else {
this.currentOrganization.identifier = '';
}
}
return this.loadOrganizationToc(scorm, this.currentOrganization.identifier);
}
/**
* Get the grade of an attempt and add it to the scorm attempts list.
*
* @param attempt The attempt number.
* @param offline Whether it's an offline attempt.
* @param attempts Object where to add the attempt.
* @return Promise resolved when done.
*/
protected async getAttemptGrade(
attempt: number,
offline: boolean,
attempts: Record<number, AddonModScormAttemptGrade>,
): Promise<void> {
const grade = await AddonModScorm.getAttemptGrade(this.scorm!, attempt, offline);
attempts[attempt] = {
num: attempt,
grade: grade,
};
}
/**
* Get the grades of each attempt and the grade of the SCORM.
*
* @return Promise resolved when done.
*/
protected async getReportedGrades(scorm: AddonModScormScorm, attempts: AddonModScormAttemptCountResult): Promise<void> {
const promises: Promise<void>[] = [];
const onlineAttempts: Record<number, AttemptGrade> = {};
const offlineAttempts: Record<number, AttemptGrade> = {};
// Calculate the grade for each attempt.
attempts.online.forEach((attempt) => {
// Check that attempt isn't in offline to prevent showing the same attempt twice. Offline should be more recent.
if (attempts.offline.indexOf(attempt) == -1) {
promises.push(this.getAttemptGrade(attempt, false, onlineAttempts));
}
});
attempts.offline.forEach((attempt) => {
promises.push(this.getAttemptGrade(attempt, true, offlineAttempts));
});
await Promise.all(promises);
// Calculate the grade of the whole SCORM. We only use online attempts to calculate this data.
this.grade = AddonModScorm.calculateScormGrade(scorm, onlineAttempts);
// Add the attempts to the SCORM in array format in ASC order, and format the grades.
this.onlineAttempts = CoreUtils.objectToArray(onlineAttempts);
this.offlineAttempts = CoreUtils.objectToArray(offlineAttempts);
this.onlineAttempts.sort((a, b) => a.num - b.num);
this.offlineAttempts.sort((a, b) => a.num - b.num);
// Now format the grades.
this.onlineAttempts.forEach((attempt) => {
attempt.gradeFormatted = AddonModScorm.formatGrade(scorm, attempt.grade);
});
this.offlineAttempts.forEach((attempt) => {
attempt.gradeFormatted = AddonModScorm.formatGrade(scorm, attempt.grade);
});
this.gradeFormatted = AddonModScorm.formatGrade(scorm, this.grade);
}
/**
* Checks if sync has succeed from result sync data.
*
* @param result Data returned on the sync function.
* @return If suceed or not.
*/
protected hasSyncSucceed(result: AddonModScormSyncResult): boolean {
if (result.updated || this.dataSent) {
// Check completion status if something was sent.
this.checkCompletion();
}
this.dataSent = false;
return true;
}
/**
* User entered the page that contains the component.
*/
ionViewDidEnter(): void {
super.ionViewDidEnter();
if (!this.hasPlayed) {
return;
}
this.hasPlayed = false;
this.startNewAttempt = false; // Uncheck new attempt.
// Add a delay to make sure the player has started the last writing calls so we can detect conflicts.
setTimeout(() => {
this.dataSentObserver?.off(); // Stop listening for changes.
this.dataSentObserver = undefined;
// Refresh data.
this.showLoadingAndRefresh(true, false);
}, 500);
}
/**
* Perform the invalidate content function.
*
* @return Resolved when done.
*/
protected async invalidateContent(): Promise<void> {
const promises: Promise<void>[] = [];
promises.push(AddonModScorm.invalidateScormData(this.courseId));
if (this.scorm) {
promises.push(AddonModScorm.invalidateAllScormData(this.scorm.id));
}
await Promise.all(promises);
}
/**
* Compares sync event data with current data to check if refresh content is needed.
*
* @param syncEventData Data receiven on sync observer.
* @return True if refresh is needed, false otherwise.
*/
protected isRefreshSyncNeeded(syncEventData: AddonModScormAutoSyncEventData): 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.
*/
async loadOrganization(): Promise<void> {
try {
await this.loadOrganizationToc(this.scorm!, this.currentOrganization.identifier!);
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, this.fetchContentDefaultError, true);
}
}
/**
* Load the TOC of a certain organization.
*
* @param organizationId The organization id.
* @return Promise resolved when done.
*/
protected async loadOrganizationToc(scorm: AddonModScormScorm, organizationId: string): Promise<void> {
if (!scorm.displaycoursestructure) {
// TOC is not displayed, no need to load it.
return;
}
this.loadingToc = true;
try {
this.toc = await AddonModScormHelper.getToc(scorm.id, this.lastAttempt!, this.incomplete, {
organization: organizationId,
offline: this.lastIsOffline,
cmId: this.module.id,
});
// 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.
*
* @param event Event.
* @param scoId SCO that needs to be loaded when the SCORM is opened. If not defined, load first SCO.
*/
async open(event?: Event, preview: boolean = false, scoId?: number): Promise<void> {
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (this.downloading) {
// Scope is being downloaded, abort.
return;
}
const isOutdated = this.currentStatus == CoreConstants.OUTDATED;
const scorm = this.scorm!;
if (!isOutdated && this.currentStatus != CoreConstants.NOT_DOWNLOADED) {
// Already downloaded, open it.
this.openScorm(scoId, preview);
return;
}
// SCORM needs to be downloaded.
await AddonModScormHelper.confirmDownload(scorm, isOutdated);
// Invalidate WS data if SCORM is outdated.
if (isOutdated) {
await CoreUtils.ignoreErrors(AddonModScorm.invalidateAllScormData(scorm.id));
}
try {
await this.downloadScormPackage();
// Success downloading, open SCORM if user hasn't left the view.
if (!this.isDestroyed) {
this.openScorm(scoId, preview);
}
} catch (error) {
if (!this.isDestroyed) {
CoreDomUtils.showErrorModalDefault(
error,
Translate.instant('addon.mod_scorm.errordownloadscorm', { name: scorm.name }),
);
}
}
}
/**
* Open a SCORM package.
*
* @param scoId SCO ID.
*/
protected openScorm(scoId?: number, preview: boolean = false): void {
// Display the full page when returning to the page.
this.skip = false;
this.hasPlayed = true;
// Detect if anything was sent to server.
this.dataSentObserver?.off();
this.dataSentObserver = CoreEvents.on(AddonModScormProvider.DATA_SENT_EVENT, (data) => {
if (data.scormId === this.scorm!.id) {
this.dataSent = true;
}
}, this.siteId);
CoreNavigator.navigate('player', {
params: {
mode: preview ? AddonModScormProvider.MODEBROWSE : AddonModScormProvider.MODENORMAL,
moduleUrl: this.module.url,
newAttempt: !!this.startNewAttempt,
organizationId: this.currentOrganization.identifier,
scoId: scoId,
},
});
}
/**
* @inheritdoc
*/
protected async showStatus(status: string): Promise<void> {
if (status == CoreConstants.OUTDATED && this.scorm) {
// Only show the outdated message if the file should be downloaded.
const download = await AddonModScorm.shouldDownloadMainFile(this.scorm, true);
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 resolved when done.
*/
protected async sync(): Promise<AddonModScormSyncResult> {
const result = await AddonModScormSync.syncScorm(this.scorm!);
if (!result.updated && this.dataSent) {
// The user sent data to server, but not in the sync process. Check if we need to fetch data.
await CoreUtils.ignoreErrors(
AddonModScormSync.prefetchAfterUpdate(AddonModScormPrefetchHandler.instance, this.module, this.courseId),
);
}
return result;
}
}
/**
* Grade for an online attempt.
*/
export type AttemptGrade = AddonModScormAttemptGrade & {
gradeFormatted?: string;
};

View File

@ -0,0 +1,22 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>
<core-format-text [text]="title" contextLevel="module" [contextInstanceId]="module?.id" [courseId]="courseId">
</core-format-text>
</ion-title>
<ion-buttons slot="end">
<!-- The buttons defined by the component will be added in here. -->
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!activityComponent?.loaded" (ionRefresh)="activityComponent?.doRefresh($event.target)">
<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,30 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, ViewChild } from '@angular/core';
import { CoreCourseModuleMainActivityPage } from '@features/course/classes/main-activity-page';
import { AddonModScormIndexComponent } from '../../components/index/index';
/**
* Page that displays the scorm entry page.
*/
@Component({
selector: 'page-addon-mod-scorm-index',
templateUrl: 'index.html',
})
export class AddonModScormIndexPage extends CoreCourseModuleMainActivityPage<AddonModScormIndexComponent> implements OnInit {
@ViewChild(AddonModScormIndexComponent) activityComponent?: AddonModScormIndexComponent;
}

View File

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

View File

@ -13,25 +13,44 @@
// limitations under the License.
import { APP_INITIALIZER, NgModule, Type } from '@angular/core';
import { Routes } from '@angular/router';
import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate';
import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate';
import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module';
import { CoreCronDelegate } from '@services/cron';
import { CORE_SITE_SCHEMAS } from '@services/sites';
import { AddonModScormComponentsModule } from './components/components.module';
import { OFFLINE_SITE_SCHEMA } from './services/database/scorm';
import { AddonModScormGradeLinkHandler } from './services/handlers/grade-link';
import { AddonModScormIndexLinkHandler } from './services/handlers/index-link';
import { AddonModScormListLinkHandler } from './services/handlers/list-link';
import { AddonModScormModuleHandler } from './services/handlers/module';
import { AddonModScormModuleHandler, AddonModScormModuleHandlerService } from './services/handlers/module';
import { AddonModScormPrefetchHandler } from './services/handlers/prefetch';
import { AddonModScormSyncCronHandler } from './services/handlers/sync-cron';
import { AddonModScormProvider } from './services/scorm';
import { AddonModScormHelperProvider } from './services/scorm-helper';
import { AddonModScormOfflineProvider } from './services/scorm-offline';
import { AddonModScormSyncProvider } from './services/scorm-sync';
export const ADDON_MOD_SCORM_SERVICES: Type<unknown>[] = [
AddonModScormProvider,
AddonModScormOfflineProvider,
AddonModScormHelperProvider,
AddonModScormSyncProvider,
];
const routes: Routes = [
{
path: AddonModScormModuleHandlerService.PAGE_NAME,
loadChildren: () => import('./scorm-lazy.module').then(m => m.AddonModScormLazyModule),
},
];
@NgModule({
imports: [
CoreMainMenuTabRoutingModule.forChild(routes),
AddonModScormComponentsModule,
],
providers: [
{

View File

@ -480,3 +480,10 @@ ion-button.core-button-select {
.core-browser-copy-area {
display: none;
}
// Different levels of padding.
@for $i from 0 through 15 {
.core-padding-#{$i} {
@include padding(null, null, null, 15px * $i + 16px);
}
}