MOBILE-2348 quiz: First implementation of the player page
parent
ccb36b3f64
commit
20395c1eba
|
@ -0,0 +1,133 @@
|
|||
<ion-header>
|
||||
<ion-navbar>
|
||||
<ion-title><core-format-text *ngIf="quiz" [text]="quiz.name"></core-format-text></ion-title>
|
||||
</ion-navbar>
|
||||
<ion-buttons end>
|
||||
<button id="addon-mod_quiz-connection-error-button" ion-button icon-only [hidden]="!autoSaveError" (click)="showConnectionError($event)" [attr.aria-label]="'core.error' | translate">
|
||||
<ion-icon name="alert"></ion-icon>
|
||||
</button>
|
||||
<!-- @todo <button menu-toggle="right" ng-if="toc && toc.length" class="button button-icon icon ion-bookmark" aria-label="{{ 'mma.mod_quiz.opentoc' | translate }}"></button> -->
|
||||
</ion-buttons>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<core-loading [hideUntil]="loaded">
|
||||
<!-- Navigation arrows -->
|
||||
<div *ngIf="questions && questions.length && !quizAborted && !showSummary">
|
||||
<ion-row align-items-center>
|
||||
<ion-col col-2>
|
||||
<a ion-button icon-only color="light" *ngIf="previousPage >= 0" (click)="changePage(previousPage)" [title]="'core.previous' | translate">
|
||||
<ion-icon name="arrow-back" md="ios-arrow-back"></ion-icon>
|
||||
</a>
|
||||
</ion-col>
|
||||
<ion-col col-8>
|
||||
<!-- @todo <core-timer *ngIf="endTime" end-time="endTime" finished="timeUp()" timer-text="{{ 'mma.mod_quiz.timeleft' | translate }}"></core-timer> -->
|
||||
</ion-col>
|
||||
<ion-col col-2 text-right>
|
||||
<a ion-button icon-only color="light" *ngIf="nextPage >= -1" (click)="changePage(nextPage)" [title]="'core.next' | translate">
|
||||
<ion-icon name="arrow-forward" md="ios-arrow-forward"></ion-icon>
|
||||
</a>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</div>
|
||||
|
||||
<!-- Button to start attempting. -->
|
||||
<div padding *ngIf="!attempt">
|
||||
<button ion-button block (click)="start()">{{ 'addon.mod_quiz.startattempt' | translate }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Questions -->
|
||||
<form name="addon-mod_quiz-player-form" *ngIf="questions && questions.length && !quizAborted && !showSummary">
|
||||
<div *ngFor="let question of questions">
|
||||
<ion-card id="addon-mod_quiz-question-{{question.slot}}">
|
||||
<!-- "Header" of the question. -->
|
||||
<ion-item-divider color="light">
|
||||
<h2 *ngIf="question.number" class="inline">{{ 'core.question.questionno' | translate:{$a: question.number} }}</h2>
|
||||
<h2 *ngIf="!question.number" class="inline">{{ 'core.question.information' | translate }}</h2>
|
||||
<ion-note text-wrap item-end *ngIf="question.status || question.readableMark">
|
||||
<p *ngIf="question.status" class="block">{{question.status}}</p>
|
||||
<p *ngIf="question.readableMark"><core-format-text [text]="question.readableMark"></core-format-text></p>
|
||||
</ion-note>
|
||||
</ion-item-divider>
|
||||
<!-- Body of the question. -->
|
||||
<core-question text-wrap [question]="question" [component]="component" [componentId]="quiz.coursemodule" [attemptId]="attempt.id" [offlineEnabled]="offline" (onAbort)="abortQuiz()" (buttonClicked)="behaviourButtonClicked($event)"></core-question>
|
||||
</ion-card>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Go to next or previous page. -->
|
||||
<ion-grid text-wrap *ngIf="questions && questions.length && !quizAborted && !showSummary">
|
||||
<ion-row>
|
||||
<ion-col *ngIf="previousPage >= 0" >
|
||||
<button ion-button block icon-start (click)="changePage(previousPage)">
|
||||
<ion-icon name="arrow-back" md="ios-arrow-back"></ion-icon>
|
||||
{{ 'core.previous' | translate }}
|
||||
</button>
|
||||
</ion-col>
|
||||
<ion-col *ngIf="nextPage >= -1">
|
||||
<button ion-button block icon-end (click)="changePage(nextPage)">
|
||||
{{ 'core.next' | translate }}
|
||||
<ion-icon name="arrow-forward" md="ios-arrow-forward"></ion-icon>
|
||||
</button>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
|
||||
<!-- Summary -->
|
||||
<ion-card *ngIf="!quizAborted && showSummary && summaryQuestions && summaryQuestions.length" class="addon-mod_quiz-table">
|
||||
<ion-card-header text-wrap>
|
||||
<h2>{{ 'addon.mod_quiz.summaryofattempt' | translate }}</h2>
|
||||
</ion-card-header>
|
||||
<!-- "Header" of the summary table. -->
|
||||
<ion-item text-wrap>
|
||||
<ion-row align-items-center>
|
||||
<ion-col col-3 text-center><b>{{ 'addon.mod_quiz.question' | translate }}</b></ion-col>
|
||||
<ion-col col-9 text-center><b>{{ 'addon.mod_quiz.status' | translate }}</b></ion-col>
|
||||
</ion-row>
|
||||
</ion-item>
|
||||
<!-- Lift of questions of the summary table. -->
|
||||
<ng-container *ngFor="let question of summaryQuestions">
|
||||
<a ion-item (click)="changePage(question.page, false, question.slot)" *ngIf="question.number" [attr.aria-label]="'core.question.questionno' | translate:{$a: question.number}" [attr.detail-push]="!quiz.isSequential && canReturn ? true : null">
|
||||
<ion-row align-items-center>
|
||||
<ion-col col-3 text-center>{{ question.number }}</ion-col>
|
||||
<ion-col col-9 text-center>{{ question.status }}</ion-col>
|
||||
</ion-row>
|
||||
</a>
|
||||
</ng-container>
|
||||
<!-- Button to return to last page seen. -->
|
||||
<ion-item *ngIf="canReturn">
|
||||
<a ion-button block (click)="changePage(attempt.currentpage)">{{ 'addon.mod_quiz.returnattempt' | translate }}</a>
|
||||
</ion-item>
|
||||
<!-- Due date warning. -->
|
||||
<ion-item text-wrap *ngIf="attempt.dueDateWarning">
|
||||
{{ attempt.dueDateWarning }}
|
||||
</ion-item>
|
||||
<!-- @todo <core-timer ng-if="endTime" end-time="endTime" finished="timeUp()" timer-text="{{ 'mma.mod_quiz.timeleft' | translate }}"></core-timer> -->
|
||||
<!-- List of messages explaining why the quiz cannot be submitted. -->
|
||||
<ion-item text-wrap *ngIf="preventSubmitMessages.length">
|
||||
<p class="item-heading">{{ 'addon.mod_quiz.cannotsubmitquizdueto' | translate }}</p>
|
||||
<p *ngFor="let message of preventSubmitMessages">{{message}}</p>
|
||||
<a ion-button block icon-end [href]="moduleUrl" core-link>
|
||||
<ion-icon name="open"></ion-icon>
|
||||
{{ 'core.openinbrowser' | translate }}
|
||||
</a>
|
||||
</ion-item>
|
||||
<!-- Button to submit the quiz. -->
|
||||
<ion-item *ngIf="!attempt.finishedOffline && !preventSubmitMessages.length">
|
||||
<a ion-button block (click)="finishAttempt(true)">{{ 'addon.mod_quiz.submitallandfinish' | translate }}</a>
|
||||
</ion-item>
|
||||
</ion-card>
|
||||
|
||||
<!-- Quiz aborted -->
|
||||
<ion-card *ngIf="attempt && (((!questions || !questions.length) && !showSummary) || quizAborted)">
|
||||
<ion-item text-wrap>
|
||||
<p>{{ 'addon.mod_quiz.errorparsequestions' | translate }}</p>
|
||||
</ion-item>
|
||||
<ion-item>
|
||||
<a ion-button block icon-end [href]="moduleUrl" core-link>
|
||||
<ion-icon name="open"></ion-icon>
|
||||
{{ 'core.openinbrowser' | translate }}
|
||||
</a>
|
||||
</ion-item>
|
||||
</ion-card>
|
||||
</core-loading>
|
||||
</ion-content>
|
|
@ -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 { CoreQuestionComponentsModule } from '@core/question/components/components.module';
|
||||
import { AddonModQuizPlayerPage } from './player';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AddonModQuizPlayerPage,
|
||||
],
|
||||
imports: [
|
||||
CoreComponentsModule,
|
||||
CoreDirectivesModule,
|
||||
CoreQuestionComponentsModule,
|
||||
IonicPageModule.forChild(AddonModQuizPlayerPage),
|
||||
TranslateModule.forChild()
|
||||
],
|
||||
})
|
||||
export class AddonModQuizPlayerPageModule {}
|
|
@ -0,0 +1,522 @@
|
|||
// (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, ElementRef } from '@angular/core';
|
||||
import { IonicPage, NavParams, Content } from 'ionic-angular';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { CoreEventsProvider } from '@providers/events';
|
||||
import { CoreSitesProvider } from '@providers/sites';
|
||||
import { CoreSyncProvider } from '@providers/sync';
|
||||
import { CoreDomUtilsProvider } from '@providers/utils/dom';
|
||||
import { CoreTimeUtilsProvider } from '@providers/utils/time';
|
||||
import { CoreQuestionHelperProvider } from '@core/question/providers/helper';
|
||||
import { AddonModQuizProvider } from '../../providers/quiz';
|
||||
import { AddonModQuizSyncProvider } from '../../providers/quiz-sync';
|
||||
import { AddonModQuizHelperProvider } from '../../providers/helper';
|
||||
|
||||
/**
|
||||
* Page that allows attempting a quiz.
|
||||
*/
|
||||
@IonicPage({ segment: 'addon-mod-quiz-player' })
|
||||
@Component({
|
||||
selector: 'page-addon-mod-quiz-player',
|
||||
templateUrl: 'player.html',
|
||||
})
|
||||
export class AddonModQuizPlayerPage implements OnInit, OnDestroy {
|
||||
@ViewChild(Content) content: Content;
|
||||
|
||||
quiz: any; // The quiz the attempt belongs to.
|
||||
attempt: any; // The attempt being attempted.
|
||||
moduleUrl: string; // URL to the module in the site.
|
||||
component = AddonModQuizProvider.COMPONENT; // Component to link the files to.
|
||||
loaded: boolean; // Whether data has been loaded.
|
||||
quizAborted: boolean; // Whether the quiz was aborted due to an error.
|
||||
preflightData: any = {}; // Preflight data to attempt the quiz.
|
||||
offline: boolean; // Whether the quiz is being attempted in offline mode.
|
||||
toc: any[]; // TOC to navigate the questions.
|
||||
questions: any[]; // Questions of the current page.
|
||||
nextPage: number; // Next page.
|
||||
previousPage: number; // Previous page.
|
||||
showSummary: boolean; // Whether the attempt summary should be displayed.
|
||||
summaryQuestions: any[]; // The questions to display in the summary.
|
||||
canReturn: boolean; // Whether the user can return to a page after seeing the summary.
|
||||
preventSubmitMessages: string[]; // List of messages explaining why the quiz cannot be submitted.
|
||||
endTime: number; // The time when the attempt must be finished.
|
||||
|
||||
protected element: HTMLElement; // Host element of the page.
|
||||
protected courseId: number; // The course ID the quiz belongs to.
|
||||
protected quizId: number; // Quiz ID to attempt.
|
||||
protected isTimed: boolean; // Whether the quiz has a time limit.
|
||||
protected quizAccessInfo: any; // Quiz access information.
|
||||
protected attemptAccessInfo: any; // Attempt access info.
|
||||
protected lastAttempt: any; // Last user attempt before a new one is created (if needed).
|
||||
protected newAttempt: boolean; // Whether the user is starting a new attempt.
|
||||
protected quizDataLoaded: boolean; // Whether the quiz data has been loaded.
|
||||
protected timeUpCalled: boolean; // Whether the time up function has been called.
|
||||
|
||||
constructor(navParams: NavParams, element: ElementRef, protected translate: TranslateService,
|
||||
protected eventsProvider: CoreEventsProvider, protected sitesProvider: CoreSitesProvider,
|
||||
protected syncProvider: CoreSyncProvider, protected domUtils: CoreDomUtilsProvider,
|
||||
protected timeUtils: CoreTimeUtilsProvider, protected quizProvider: AddonModQuizProvider,
|
||||
protected quizHelper: AddonModQuizHelperProvider, protected quizSync: AddonModQuizSyncProvider,
|
||||
protected questionHelper: CoreQuestionHelperProvider) {
|
||||
|
||||
this.quizId = navParams.get('quizId');
|
||||
this.courseId = navParams.get('courseId');
|
||||
this.moduleUrl = navParams.get('moduleUrl');
|
||||
|
||||
// Block the quiz so it cannot be synced.
|
||||
this.syncProvider.blockOperation(AddonModQuizProvider.COMPONENT, this.quizId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component being initialized.
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
// Start the player when the page is loaded.
|
||||
this.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Component being destroyed.
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
// Stop auto save.
|
||||
// @todo $mmaModQuizAutoSave.stopAutoSaving();
|
||||
// @todo $mmaModQuizAutoSave.stopCheckChangesProcess();
|
||||
|
||||
// Unblock the quiz so it can be synced.
|
||||
this.syncProvider.unblockOperation(AddonModQuizProvider.COMPONENT, this.quizId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort the quiz.
|
||||
*/
|
||||
abortQuiz(): void {
|
||||
this.quizAborted = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A behaviour button in a question was clicked (Check, Redo, ...).
|
||||
*
|
||||
* @param {any} button Clicked button.
|
||||
*/
|
||||
behaviourButtonClicked(button: any): void {
|
||||
// Confirm that the user really wants to do it.
|
||||
this.domUtils.showConfirm(this.translate.instant('core.areyousure')).then(() => {
|
||||
const modal = this.domUtils.showModalLoading('core.sending', true),
|
||||
answers = this.getAnswers();
|
||||
|
||||
// Add the clicked button data.
|
||||
answers[button.name] = button.value;
|
||||
|
||||
// Behaviour checks are always in online.
|
||||
this.quizProvider.processAttempt(this.quiz, this.attempt, answers, this.preflightData).then(() => {
|
||||
// Reload the current page.
|
||||
const scrollElement = this.content.getScrollElement(),
|
||||
scrollTop = scrollElement.scrollTop || 0,
|
||||
scrollLeft = scrollElement.scrollLeft || 0;
|
||||
|
||||
this.loaded = false;
|
||||
this.content.scrollToTop(); // Scroll top so the spinner is seen.
|
||||
|
||||
return this.loadPage(this.attempt.currentpage).finally(() => {
|
||||
this.loaded = true;
|
||||
this.content.scrollTo(scrollLeft, scrollTop);
|
||||
});
|
||||
}).catch((error) => {
|
||||
this.domUtils.showErrorModalDefault(error, 'Error performing action.');
|
||||
}).finally(() => {
|
||||
modal.dismiss();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the current page. If slot is supplied, try to scroll to that question.
|
||||
*
|
||||
* @param {number} page Page to load. -1 means summary.
|
||||
* @param {boolean} [fromToc] Whether the page was selected using the TOC.
|
||||
* @param {number} [slot] Slot of the question to scroll to.
|
||||
*/
|
||||
changePage(page: number, fromToc?: boolean, slot?: number): void {
|
||||
if (page != -1 && (this.attempt.state == AddonModQuizProvider.ATTEMPT_OVERDUE || this.attempt.finishedOffline)) {
|
||||
// We can't load a page if overdue or the local attempt is finished.
|
||||
return;
|
||||
} else if (page == this.attempt.currentpage && !this.showSummary && typeof slot != 'undefined') {
|
||||
// Navigating to a question in the current page.
|
||||
this.scrollToQuestion(slot);
|
||||
|
||||
return;
|
||||
} else if ((page == this.attempt.currentpage && !this.showSummary) || (fromToc && this.quiz.isSequential && page != -1)) {
|
||||
// If the user is navigating to the current page we do nothing.
|
||||
// Also, in sequential quizzes we don't allow navigating using the TOC except for finishing the quiz (summary).
|
||||
return;
|
||||
} else if (page === -1 && this.showSummary) {
|
||||
// Summary already shown.
|
||||
return;
|
||||
}
|
||||
|
||||
this.loaded = false;
|
||||
this.content.scrollToTop();
|
||||
|
||||
// First try to save the attempt data. We only save it if we're not seeing the summary.
|
||||
const promise = this.showSummary ? Promise.resolve() : this.processAttempt(false, false);
|
||||
promise.then(() => {
|
||||
// Attempt data successfully saved, load the page or summary.
|
||||
|
||||
if (page === -1) {
|
||||
return this.loadSummary();
|
||||
} else {
|
||||
// @todo $mmaModQuizAutoSave.stopCheckChangesProcess(); // Stop checking for changes during page change.
|
||||
|
||||
return this.loadPage(page).catch((error) => {
|
||||
// @todo $mmaModQuizAutoSave.startCheckChangesProcess($scope, quiz, attempt); // Start the check again.
|
||||
this.domUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquestions', true);
|
||||
});
|
||||
}
|
||||
}, (error) => {
|
||||
this.domUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorsaveattempt', true);
|
||||
}).catch((error) => {
|
||||
this.domUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquestions', true);
|
||||
}).finally(() => {
|
||||
this.loaded = true;
|
||||
|
||||
if (typeof slot != 'undefined') {
|
||||
// Scroll to the question. Give some time to the questions to render.
|
||||
setTimeout(() => {
|
||||
this.scrollToQuestion(slot);
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to get the quiz data.
|
||||
*
|
||||
* @return {Promise<any>} Promise resolved when done.
|
||||
*/
|
||||
protected fetchData(): Promise<any> {
|
||||
// Wait for any ongoing sync to finish. We won't sync a quiz while it's being played.
|
||||
return this.quizSync.waitForSync(this.quizId).then(() => {
|
||||
// Sync finished, now get the quiz.
|
||||
return this.quizProvider.getQuizById(this.courseId, this.quizId);
|
||||
}).then((quizData) => {
|
||||
this.quiz = quizData;
|
||||
this.quiz.isSequential = this.quizProvider.isNavigationSequential(this.quiz);
|
||||
|
||||
if (this.quizProvider.isQuizOffline(this.quiz)) {
|
||||
// Quiz supports offline.
|
||||
return true;
|
||||
} else {
|
||||
// Quiz doesn't support offline right now, but maybe it did and then the setting was changed.
|
||||
// If we have an unfinished offline attempt then we'll use offline mode.
|
||||
return this.quizProvider.isLastAttemptOfflineUnfinished(this.quiz);
|
||||
}
|
||||
}).then((offlineMode) => {
|
||||
this.offline = offlineMode;
|
||||
|
||||
if (this.quiz.timelimit > 0) {
|
||||
this.isTimed = true;
|
||||
this.quiz.readableTimeLimit = this.timeUtils.formatTime(this.quiz.timelimit);
|
||||
}
|
||||
|
||||
// Get access information for the quiz.
|
||||
return this.quizProvider.getQuizAccessInformation(this.quiz.id, this.offline, true);
|
||||
}).then((info) => {
|
||||
this.quizAccessInfo = info;
|
||||
|
||||
// Get user attempts to determine last attempt.
|
||||
return this.quizProvider.getUserAttempts(this.quiz.id, 'all', true, this.offline, true);
|
||||
}).then((attempts) => {
|
||||
if (!attempts.length) {
|
||||
// There are no attempts, start a new one.
|
||||
this.newAttempt = true;
|
||||
} else {
|
||||
const promises = [];
|
||||
|
||||
// Get the last attempt. If it's finished, start a new one.
|
||||
this.lastAttempt = attempts[attempts.length - 1];
|
||||
this.newAttempt = this.quizProvider.isAttemptFinished(this.lastAttempt.state);
|
||||
|
||||
// Load quiz last sync time.
|
||||
promises.push(this.quizSync.getSyncTime(this.quiz.id).then((time) => {
|
||||
this.quiz.syncTime = time;
|
||||
this.quiz.syncTimeReadable = this.quizSync.getReadableTimeFromTimestamp(time);
|
||||
}));
|
||||
|
||||
// Load flag to show if attempts are finished but not synced.
|
||||
promises.push(this.quizProvider.loadFinishedOfflineData(attempts));
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
}).catch((error) => {
|
||||
this.domUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquiz', true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish an attempt, either by timeup or because the user clicked to finish it.
|
||||
*
|
||||
* @param {boolean} [userFinish] Whether the user clicked to finish the attempt.
|
||||
* @param {boolean} [timeUp] Whether the quiz time is up.
|
||||
* @return {Promise<void>} Promise resolved when done.
|
||||
*/
|
||||
finishAttempt(userFinish?: boolean, timeUp?: boolean): Promise<void> {
|
||||
let promise;
|
||||
|
||||
// Show confirm if the user clicked the finish button and the quiz is in progress.
|
||||
if (!timeUp && this.attempt.state == AddonModQuizProvider.ATTEMPT_IN_PROGRESS) {
|
||||
promise = this.domUtils.showConfirm(this.translate.instant('addon.mod_quiz.confirmclose'));
|
||||
} else {
|
||||
promise = Promise.resolve();
|
||||
}
|
||||
|
||||
return promise.then(() => {
|
||||
const modal = this.domUtils.showModalLoading('core.sending', true);
|
||||
|
||||
return this.processAttempt(userFinish, timeUp).then(() => {
|
||||
// Trigger an event to notify the attempt was finished.
|
||||
this.eventsProvider.trigger(AddonModQuizProvider.ATTEMPT_FINISHED_EVENT, {
|
||||
quizId: this.quizId,
|
||||
attemptId: this.attempt.id,
|
||||
synced: !this.offline
|
||||
}, this.sitesProvider.getCurrentSiteId());
|
||||
|
||||
// Leave the player.
|
||||
// @todo blockData && blockData.back();
|
||||
}).catch((error) => {
|
||||
this.domUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorsaveattempt', true);
|
||||
}).finally(() => {
|
||||
modal.dismiss();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the input answers.
|
||||
*
|
||||
* @return {any} Object with the answers.
|
||||
*/
|
||||
protected getAnswers(): any {
|
||||
return this.questionHelper.getAnswersFromForm(document.forms['addon-mod_quiz-player-form']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the timer if enabled.
|
||||
*/
|
||||
protected initTimer(): void {
|
||||
if (this.attemptAccessInfo.endtime > 0) {
|
||||
// Quiz has an end time. Check if time left should be shown.
|
||||
if (this.quizProvider.shouldShowTimeLeft(this.quizAccessInfo.activerulenames, this.attempt,
|
||||
this.attemptAccessInfo.endtime)) {
|
||||
this.endTime = this.attemptAccessInfo.endtime;
|
||||
} else {
|
||||
delete this.endTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a page questions.
|
||||
*
|
||||
* @param {number} page The page to load.
|
||||
* @return {Promise<void>} Promise resolved when done.
|
||||
*/
|
||||
protected loadPage(page: number): Promise<void> {
|
||||
return this.quizProvider.getAttemptData(this.attempt.id, page, this.preflightData, this.offline, true).then((data) => {
|
||||
// Update attempt, status could change during the execution.
|
||||
this.attempt = data.attempt;
|
||||
this.attempt.currentpage = page;
|
||||
|
||||
this.questions = data.questions;
|
||||
this.nextPage = data.nextpage;
|
||||
this.previousPage = this.quiz.isSequential ? -1 : page - 1;
|
||||
this.showSummary = false;
|
||||
|
||||
this.questions.forEach((question) => {
|
||||
// Get the readable mark for each question.
|
||||
question.readableMark = this.quizHelper.getQuestionMarkFromHtml(question.html);
|
||||
|
||||
// Extract the question info box.
|
||||
this.questionHelper.extractQuestionInfoBox(question, '.info');
|
||||
|
||||
// Set the preferred behaviour.
|
||||
question.preferredBehaviour = this.quiz.preferredbehaviour;
|
||||
|
||||
// Check if the question is blocked. If it is, treat it as a description question.
|
||||
if (this.quizProvider.isQuestionBlocked(question)) {
|
||||
question.type = 'description';
|
||||
}
|
||||
});
|
||||
|
||||
// Mark the page as viewed. We'll ignore errors in this call.
|
||||
this.quizProvider.logViewAttempt(this.attempt.id, page, this.preflightData, this.offline);
|
||||
|
||||
// Start looking for changes.
|
||||
// @todo $mmaModQuizAutoSave.startCheckChangesProcess($scope, quiz, attempt);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load attempt summary.
|
||||
*
|
||||
* @return {Promise<void>} Promise resolved when done.
|
||||
*/
|
||||
protected loadSummary(): Promise<void> {
|
||||
this.summaryQuestions = [];
|
||||
|
||||
return this.quizProvider.getAttemptSummary(this.attempt.id, this.preflightData, this.offline, true, true).then((qs) => {
|
||||
this.showSummary = true;
|
||||
this.summaryQuestions = qs;
|
||||
|
||||
this.canReturn = this.attempt.state == AddonModQuizProvider.ATTEMPT_IN_PROGRESS && !this.attempt.finishedOffline;
|
||||
this.preventSubmitMessages = this.quizProvider.getPreventSubmitMessages(this.summaryQuestions);
|
||||
|
||||
this.attempt.dueDateWarning = this.quizProvider.getAttemptDueDateWarning(this.quiz, this.attempt);
|
||||
|
||||
// Log summary as viewed.
|
||||
this.quizProvider.logViewAttemptSummary(this.attempt.id, this.preflightData);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load TOC to navigate the questions.
|
||||
*
|
||||
* @return {Promise<void>} Promise resolved when done.
|
||||
*/
|
||||
protected loadToc(): Promise<void> {
|
||||
// We use the attempt summary to build the TOC because it contains all the questions.
|
||||
return this.quizProvider.getAttemptSummary(this.attempt.id, this.preflightData, this.offline).then((questions) => {
|
||||
this.toc = questions;
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare the answers to be sent for the attempt.
|
||||
protected prepareAnswers(): Promise<any> {
|
||||
return this.questionHelper.prepareAnswers(this.questions, this.getAnswers(), this.offline);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process attempt.
|
||||
*
|
||||
* @param {boolean} [userFinish] Whether the user clicked to finish the attempt.
|
||||
* @param {boolean} [timeUp] Whether the quiz time is up.
|
||||
* @return {Promise<any>} Promise resolved when done.
|
||||
*/
|
||||
protected processAttempt(userFinish?: boolean, timeUp?: boolean): Promise<any> {
|
||||
// Get the answers to send.
|
||||
return this.prepareAnswers().then((answers) => {
|
||||
// Send the answers.
|
||||
return this.quizProvider.processAttempt(this.quiz, this.attempt, answers, this.preflightData, userFinish, timeUp,
|
||||
this.offline);
|
||||
}).then(() => {
|
||||
// Answers saved, cancel auto save.
|
||||
// @todo $mmaModQuizAutoSave.cancelAutoSave();
|
||||
// @todo $mmaModQuizAutoSave.hideAutoSaveError($scope);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to a certain question.
|
||||
*
|
||||
* @param {number} slot Slot of the question to scroll to.
|
||||
*/
|
||||
protected scrollToQuestion(slot: number): void {
|
||||
this.domUtils.scrollToElementBySelector(this.content, '#addon-mod_quiz-question-' + slot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show connection error.
|
||||
*
|
||||
* @param {Event} ev Click event.
|
||||
*/
|
||||
showConnectionError(ev: Event): void {
|
||||
// @todo
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to start the player.
|
||||
*/
|
||||
start(): void {
|
||||
let promise;
|
||||
this.loaded = false;
|
||||
|
||||
if (this.quizDataLoaded) {
|
||||
// Quiz data has been loaded, try to start or continue.
|
||||
promise = this.startOrContinueAttempt();
|
||||
} else {
|
||||
// Fetch data.
|
||||
promise = this.fetchData().then(() => {
|
||||
this.quizDataLoaded = true;
|
||||
|
||||
return this.startOrContinueAttempt();
|
||||
});
|
||||
}
|
||||
|
||||
promise.finally(() => {
|
||||
this.loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or continue an attempt.
|
||||
*
|
||||
* @return {Promise<any>} [description]
|
||||
*/
|
||||
protected startOrContinueAttempt(): Promise<any> {
|
||||
const attempt = this.newAttempt ? undefined : this.lastAttempt;
|
||||
|
||||
// Get the preflight data and start attempt if needed.
|
||||
return this.quizHelper.getAndCheckPreflightData(this.quiz, this.quizAccessInfo, this.preflightData, attempt, this.offline,
|
||||
false, 'addon.mod_quiz.startattempt').then((attempt) => {
|
||||
|
||||
// Re-fetch attempt access information with the right attempt (might have changed because a new attempt was created).
|
||||
return this.quizProvider.getAttemptAccessInformation(this.quiz.id, attempt.id, this.offline, true).then((info) => {
|
||||
this.attemptAccessInfo = info;
|
||||
this.attempt = attempt;
|
||||
|
||||
return this.loadToc();
|
||||
}).then(() => {
|
||||
if (this.attempt.state != AddonModQuizProvider.ATTEMPT_OVERDUE && !this.attempt.finishedOffline) {
|
||||
// Attempt not overdue and not finished in offline, load page.
|
||||
return this.loadPage(this.attempt.currentpage).then(() => {
|
||||
this.initTimer();
|
||||
});
|
||||
} else {
|
||||
// Attempt is overdue or finished in offline, we can only load the summary.
|
||||
return this.loadSummary();
|
||||
}
|
||||
}).catch((error) => {
|
||||
this.domUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquestions', true);
|
||||
});
|
||||
}).catch((error) => {
|
||||
if (error) {
|
||||
this.domUtils.showErrorModal(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Quiz time has finished.
|
||||
*/
|
||||
timeUp(): void {
|
||||
if (this.timeUpCalled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timeUpCalled = true;
|
||||
this.finishAttempt(false, true);
|
||||
}
|
||||
}
|
|
@ -15,9 +15,9 @@
|
|||
|
||||
<!-- Radio buttons for single choice. -->
|
||||
<div *ngIf="!question.multi" radio-group [ngModel]="question.singleChoiceModel" [name]="question.optionsName">
|
||||
<ion-item text-wrap>
|
||||
<ion-item text-wrap *ngFor="let option of question.options">
|
||||
<ion-label><core-format-text [component]="component" [componentId]="componentId" [text]="option.text"></core-format-text></ion-label>
|
||||
<ion-radio *ngFor="let option of question.options" [value]="option.value" [disabled]="option.disabled" [ngClass]='{"core-question-answer-correct": option.isCorrect === 1, "core-question-answer-incorrect": option.isCorrect === 0}'>
|
||||
<ion-radio [value]="option.value" [disabled]="option.disabled" [ngClass]='{"core-question-answer-correct": option.isCorrect === 1, "core-question-answer-incorrect": option.isCorrect === 0}'>
|
||||
<p *ngIf="option.feedback" class="core-question-feedback-container"><core-format-text [component]="component" [componentId]="componentId" [text]="option.feedback"></core-format-text></p>
|
||||
</ion-radio>
|
||||
</ion-item>
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
|
||||
<!-- Question behaviour buttons. -->
|
||||
<ion-item text-wrap *ngFor="let button of question.behaviourButtons">
|
||||
<a ion-button block (click)="action.emit(button)" [disabled]="button.disabled">{{ button.value }}</a>
|
||||
<button ion-button block (click)="buttonClicked.emit(button)" [disabled]="button.disabled">{{ button.value }}</button>
|
||||
</ion-item>
|
||||
|
||||
<!-- Question feedback. -->
|
||||
|
|
|
@ -19,7 +19,9 @@ import { CoreSitesProvider } from '@providers/sites';
|
|||
import { CoreDomUtilsProvider } from '@providers/utils/dom';
|
||||
import { CoreTextUtilsProvider } from '@providers/utils/text';
|
||||
import { CoreUrlUtilsProvider } from '@providers/utils/url';
|
||||
import { CoreUtilsProvider } from '@providers/utils/utils';
|
||||
import { CoreQuestionProvider } from './question';
|
||||
import { CoreQuestionDelegate } from './delegate';
|
||||
|
||||
/**
|
||||
* Service with some common functions to handle questions.
|
||||
|
@ -31,8 +33,8 @@ export class CoreQuestionHelperProvider {
|
|||
|
||||
constructor(private domUtils: CoreDomUtilsProvider, private textUtils: CoreTextUtilsProvider,
|
||||
private questionProvider: CoreQuestionProvider, private sitesProvider: CoreSitesProvider,
|
||||
private translate: TranslateService, private urlUtils: CoreUrlUtilsProvider,
|
||||
private filepoolProvider: CoreFilepoolProvider) { }
|
||||
private translate: TranslateService, private urlUtils: CoreUrlUtilsProvider, private utils: CoreUtilsProvider,
|
||||
private filepoolProvider: CoreFilepoolProvider, private questionDelegate: CoreQuestionDelegate) { }
|
||||
|
||||
/**
|
||||
* Add a behaviour button to the question's "behaviourButtons" property.
|
||||
|
@ -301,6 +303,44 @@ export class CoreQuestionHelperProvider {
|
|||
return answers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the answers entered in a form.
|
||||
* We don't use ngModel because it doesn't detect changes done by JavaScript and some questions might do that.
|
||||
*
|
||||
* @param {HTMLFormElement} form Form.
|
||||
* @return {any} Object with the answers.
|
||||
*/
|
||||
getAnswersFromForm(form: HTMLFormElement): any {
|
||||
if (!form || !form.elements) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const answers = {},
|
||||
elements = Array.from(form.elements);
|
||||
|
||||
elements.forEach((element: HTMLInputElement) => {
|
||||
const name = element.name || '';
|
||||
|
||||
// Ignore flag and submit inputs.
|
||||
if (!name || name.match(/_:flagged$/) || element.type == 'submit' || element.tagName == 'BUTTON') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the value.
|
||||
if (element.type == 'checkbox') {
|
||||
answers[name] = !!element.checked;
|
||||
} else if (element.type == 'radio') {
|
||||
if (element.checked) {
|
||||
answers[name] = element.value;
|
||||
}
|
||||
} else {
|
||||
answers[name] = element.value;
|
||||
}
|
||||
});
|
||||
|
||||
return answers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an HTML code with list of attachments, returns the list of attached files (filename and fileurl).
|
||||
* Please take into account that this function will treat all the anchors in the HTML, you should provide
|
||||
|
@ -468,6 +508,27 @@ export class CoreQuestionHelperProvider {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare and return the answers.
|
||||
*
|
||||
* @param {any[]} questions The list of questions.
|
||||
* @param {any} answers The input data.
|
||||
* @param {boolean} offline True if data should be saved in offline.
|
||||
* @param {string} [siteId] Site ID. If not defined, current site.
|
||||
* @return {Promise<any>} Promise resolved with answers to send to server.
|
||||
*/
|
||||
prepareAnswers(questions: any[], answers: any, offline?: boolean, siteId?: string): Promise<any> {
|
||||
const promises = [];
|
||||
|
||||
questions.forEach((question) => {
|
||||
promises.push(this.questionDelegate.prepareAnswersForQuestion(question, answers, offline, siteId));
|
||||
});
|
||||
|
||||
return this.utils.allPromises(promises).then(() => {
|
||||
return answers;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace Moodle's correct/incorrect classes with the Mobile ones.
|
||||
*
|
||||
|
|
Loading…
Reference in New Issue