From 1da073eefa5abe4f203f6d475bea7d38dfe302e2 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 4 Mar 2022 09:42:18 +0100 Subject: [PATCH] MOBILE-3919 assign: Support time limit in assigns --- scripts/langindex.json | 7 + .../index/addon-mod-assign-index.html | 8 ++ .../mod/assign/components/index/index.ts | 21 ++- .../addon-mod-assign-submission.html | 114 ++++++++++------ .../components/submission/submission.scss | 22 ++- .../components/submission/submission.ts | 116 ++++++++++------ src/addons/mod/assign/lang.json | 7 + src/addons/mod/assign/pages/edit/edit.html | 5 + src/addons/mod/assign/pages/edit/edit.scss | 17 +++ src/addons/mod/assign/pages/edit/edit.ts | 62 +++++++++ .../mod/assign/services/assign-helper.ts | 19 +++ src/addons/mod/assign/services/assign.ts | 72 ++++++++++ .../mod/assign/services/handlers/prefetch.ts | 3 +- .../mod/h5pactivity/services/h5pactivity.ts | 4 +- .../mod/lesson/pages/player/player.html | 2 +- src/addons/mod/quiz/pages/player/player.scss | 20 +++ src/core/components/timer/core-timer.html | 23 +++- src/core/components/timer/timer.scss | 18 --- src/core/components/timer/timer.ts | 52 ++++++-- src/core/initializers/initialize-services.ts | 2 + src/core/services/utils/dom.ts | 20 ++- src/core/services/utils/time.ts | 126 +++++++----------- src/theme/theme.base.scss | 9 ++ 23 files changed, 536 insertions(+), 213 deletions(-) create mode 100644 src/addons/mod/assign/pages/edit/edit.scss diff --git a/scripts/langindex.json b/scripts/langindex.json index ec7f4e8eb..ef683936a 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -331,14 +331,18 @@ "addon.mod_assign.allowsubmissionsfromdatesummary": "assign", "addon.mod_assign.applytoteam": "assign", "addon.mod_assign.assignmentisdue": "assign", + "addon.mod_assign.assigntimeleft": "assign", "addon.mod_assign.attemptnumber": "assign", "addon.mod_assign.attemptreopenmethod": "assign", "addon.mod_assign.attemptreopenmethod_manual": "assign", "addon.mod_assign.attemptreopenmethod_untilpass": "assign", "addon.mod_assign.attemptsettings": "assign", + "addon.mod_assign.beginassignment": "assign", + "addon.mod_assign.caneditsubmission": "assign", "addon.mod_assign.cannoteditduetostatementsubmission": "local_moodlemobileapp", "addon.mod_assign.cannotgradefromapp": "local_moodlemobileapp", "addon.mod_assign.cannotsubmitduetostatementsubmission": "local_moodlemobileapp", + "addon.mod_assign.confirmstart": "assign", "addon.mod_assign.confirmsubmission": "assign", "addon.mod_assign.currentattempt": "assign", "addon.mod_assign.currentattemptof": "assign", @@ -416,7 +420,10 @@ "addon.mod_assign.submitassignment_help": "assign", "addon.mod_assign.submittedearly": "assign", "addon.mod_assign.submittedlate": "assign", + "addon.mod_assign.submittedovertime": "assign", + "addon.mod_assign.submittedundertime": "assign", "addon.mod_assign.syncblockedusercomponent": "local_moodlemobileapp", + "addon.mod_assign.timelimit": "assign", "addon.mod_assign.timemodified": "assign", "addon.mod_assign.timeremaining": "assign", "addon.mod_assign.ungroupedusers": "assign", diff --git a/src/addons/mod/assign/components/index/addon-mod-assign-index.html b/src/addons/mod/assign/components/index/addon-mod-assign-index.html index c58552c55..ff974dd33 100644 --- a/src/addons/mod/assign/components/index/addon-mod-assign-index.html +++ b/src/addons/mod/assign/components/index/addon-mod-assign-index.html @@ -33,6 +33,14 @@ + + +

{{ 'core.course.hiddenfromstudents' | translate }}

+

{{ 'core.no' | translate }}

+

{{ 'core.yes' | translate }}

+
+
+

{{ 'addon.mod_assign.timeremaining' | translate }}

diff --git a/src/addons/mod/assign/components/index/index.ts b/src/addons/mod/assign/components/index/index.ts index b2ae03e97..e39660b97 100644 --- a/src/addons/mod/assign/components/index/index.ts +++ b/src/addons/mod/assign/components/index/index.ts @@ -86,6 +86,7 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo protected savedObserver?: CoreEventObserver; protected submittedObserver?: CoreEventObserver; protected gradedObserver?: CoreEventObserver; + protected startedObserver?: CoreEventObserver; constructor( protected content?: IonContent, @@ -136,6 +137,13 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo } }, this.siteId); + this.startedObserver = CoreEvents.on(AddonModAssignProvider.STARTED_EVENT, (data) => { + if (this.assign && data.assignmentId == this.assign.id) { + // Assignment submission started, refresh data. + this.showLoadingAndRefresh(false, false); + } + }, this.siteId); + await this.loadContent(false, true); } @@ -167,12 +175,17 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo if (submissions.canviewsubmissions) { // Calculate the messages to display about time remaining and late submissions. + this.timeRemaining = ''; + this.lateSubmissions = ''; + if (this.assign.duedate > 0) { if (this.assign.duedate - time <= 0) { this.timeRemaining = Translate.instant('addon.mod_assign.assignmentisdue'); } else { - this.timeRemaining = CoreTimeUtils.formatDuration(this.assign.duedate - time, 3); + this.timeRemaining = CoreTimeUtils.formatTime(this.assign.duedate - time); + } + if (this.assign.duedate < time) { if (this.assign.cutoffdate) { if (this.assign.cutoffdate > time) { this.lateSubmissions = Translate.instant( @@ -182,13 +195,8 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo } else { this.lateSubmissions = Translate.instant('addon.mod_assign.nomoresubmissionsaccepted'); } - } else { - this.lateSubmissions = ''; } } - } else { - this.timeRemaining = ''; - this.lateSubmissions = ''; } // Check if groupmode is enabled to avoid showing wrong numbers. @@ -398,6 +406,7 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo this.savedObserver?.off(); this.submittedObserver?.off(); this.gradedObserver?.off(); + this.startedObserver?.off(); } } diff --git a/src/addons/mod/assign/components/submission/addon-mod-assign-submission.html b/src/addons/mod/assign/components/submission/addon-mod-assign-submission.html index 7cd548372..42dde1d55 100644 --- a/src/addons/mod/assign/components/submission/addon-mod-assign-submission.html +++ b/src/addons/mod/assign/components/submission/addon-mod-assign-submission.html @@ -1,5 +1,15 @@
+ + + + + +

{{ 'addon.mod_assign.caneditsubmission' | translate }}

+
+
+
+ @@ -31,33 +41,36 @@ - - - - + -

{{ 'addon.mod_assign.timemodified' | translate }}

-

{{ userSubmission!.timemodified * 1000 | coreFormatDate }}

+

{{ 'addon.mod_assign.attemptnumber' | translate }}

+

+ {{ 'addon.mod_assign.outof' | translate : + {'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }} +

+

+ {{ 'addon.mod_assign.outof' | translate : + {'$a': {'current': currentAttempt, 'total': assign!.maxattempts} } }} +

- + + -

{{ 'addon.mod_assign.timeremaining' | translate }}

-

+

{{ 'addon.mod_assign.submissionslocked' | translate }}

+

+ {'$a': fromDate}">

@@ -84,20 +97,48 @@
- + + -

{{ 'addon.mod_assign.attemptnumber' | translate }}

-

- {{ 'addon.mod_assign.outof' | translate : - {'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }} -

-

- {{ 'addon.mod_assign.outof' | translate : - {'$a': {'current': currentAttempt, 'total': assign!.maxattempts} } }} -

+

{{ 'addon.mod_assign.timeremaining' | translate }}

+

+ +
+ + + +

{{ 'addon.mod_assign.timelimit' | translate }}

+

{{ assign.timelimit | coreDuration }}

+
+
+ + + + +

{{ 'addon.mod_assign.editingstatus' | translate }}

+

{{ 'addon.mod_assign.submissioneditable' | translate }}

+

{{ 'addon.mod_assign.submissionnoteditable' | translate }}

+
+
+ + + + +

{{ 'addon.mod_assign.timemodified' | translate }}

+

{{ userSubmission!.timemodified * 1000 | coreFormatDate }}

+
+
+ + + + @@ -109,7 +150,12 @@ - {{ 'addon.mod_assign.addsubmission' | translate }} + + {{ 'addon.mod_assign.addsubmission' | translate }} + + + {{ 'addon.mod_assign.beginassignment' | translate }} + @@ -122,9 +168,8 @@ - {{ 'addon.mod_assign.editsubmission' | translate }} @@ -191,23 +236,6 @@ - - - - -

{{ 'addon.mod_assign.submissionslocked' | translate }}

-
-
- - - - -

{{ 'addon.mod_assign.editingstatus' | translate }}

-

{{ 'addon.mod_assign.submissioneditable' | translate }}

-

{{ 'addon.mod_assign.submissionnoteditable' | translate }}

-
-
diff --git a/src/addons/mod/assign/components/submission/submission.scss b/src/addons/mod/assign/components/submission/submission.scss index 935b96fe7..eb68d7add 100644 --- a/src/addons/mod/assign/components/submission/submission.scss +++ b/src/addons/mod/assign/components/submission/submission.scss @@ -1,20 +1,20 @@ :host ::ng-deep { - div.latesubmission, - div.overdue { + ion-item.latesubmission, + ion-item.overdue { border-bottom: 3px solid var(--danger) !important; ion-icon { color: var(--danger); } } - div.earlysubmission { + ion-item.earlysubmission { border-bottom: 3px solid var(--success) !important; ion-icon { color: var(--success); } } - div.submissioneditable p { + ion-item.submissioneditable p { color: var(--danger); } @@ -26,10 +26,22 @@ margin-left: 2px; margin-right: 2px; } + + core-timer .core-timer { + &.core-timer-under-300 { + background-color: var(--danger-tint); + color: var(--danger-shade); + } + + &.core-timer-under-900 { + background-color: var(--warning-tint); + color: var(--warning-shade); + } + } } :host-context(body.dark) ::ng-deep { - div.submissioneditable p { + ion-item.submissioneditable p { color: var(--danger-tint); } } diff --git a/src/addons/mod/assign/components/submission/submission.ts b/src/addons/mod/assign/components/submission/submission.ts index 9d5735936..a5566cbb6 100644 --- a/src/addons/mod/assign/components/submission/submission.ts +++ b/src/addons/mod/assign/components/submission/submission.ts @@ -106,6 +106,7 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy, Can submissionPlugins: AddonModAssignPlugin[] = []; // List of submission plugins. timeRemaining = ''; // Message about time remaining. timeRemainingClass = ''; // Class to apply to time remaining message. + timeLimitEndTime = 0; // If time limit is enabled and submission is ongoing, the end time for the timer. statusTranslated?: string; // Status. statusColor = ''; // Color to apply to the status. unsupportedEditPlugins: string[] = []; // List of submission plugins that don't support edit. @@ -126,6 +127,7 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy, Can gradeUrl?: string; // URL to grade in browser. isPreviousAttemptEmpty = true; // Whether the previous attempt contains an empty submission. showDates = false; // Whether to show some dates. + timeLimitFinished = false; // Whether there is a time limit and it finished, so the user will submit late. // Some constants. statusNew = AddonModAssignSubmissionStatusValues.NEW; @@ -200,7 +202,12 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy, Can return; } - if (this.assign.duedate <= 0) { + const submissionStarted = !!this.userSubmission?.timestarted; + this.timeLimitEndTime = 0; + this.timeLimitFinished = false; + + if (this.assign.duedate <= 0 && !submissionStarted) { + // No due date and no countdown. this.timeRemaining = ''; this.timeRemainingClass = ''; @@ -208,53 +215,53 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy, Can } const time = CoreTimeUtils.timestamp(); - const dueDate = response.lastattempt?.extensionduedate - ? response.lastattempt.extensionduedate - : this.assign.duedate; - const timeRemaining = dueDate - time; + const timeLimitEnabled = this.assign.timelimit && submissionStarted; + const dueDateReached = this.assign.duedate > 0 && this.assign.duedate - time <= 0; + const timeLimitEnabledBeforeDueDate = timeLimitEnabled && !dueDateReached; - if (timeRemaining > 0) { - this.timeRemaining = CoreTimeUtils.formatDuration(timeRemaining, 3); - this.timeRemainingClass = ''; + if (this.userSubmission && this.userSubmission.status === AddonModAssignSubmissionStatusValues.SUBMITTED) { + // Submitted, display the relevant early/late message. + const lateCalculation = this.userSubmission.timemodified - + (timeLimitEnabledBeforeDueDate ? this.userSubmission.timecreated : 0); + const lateThreshold = timeLimitEnabledBeforeDueDate ? this.assign.timelimit || 0 : this.assign.duedate; + const earlyString = timeLimitEnabledBeforeDueDate ? 'submittedundertime' : 'submittedearly'; + const lateString = timeLimitEnabledBeforeDueDate ? 'submittedovertime' : 'submittedlate'; + const onTime = lateCalculation <= lateThreshold; - return; - } - - // Not submitted. - if (!this.userSubmission || this.userSubmission.status != AddonModAssignSubmissionStatusValues.SUBMITTED) { - - if (response.lastattempt?.submissionsenabled || response.gradingsummary?.submissionsenabled) { - this.timeRemaining = Translate.instant( - 'addon.mod_assign.overdue', - { $a: CoreTimeUtils.formatDuration(-timeRemaining, 3) }, - ); - this.timeRemainingClass = 'overdue'; - - return; - } - - this.timeRemaining = Translate.instant('addon.mod_assign.duedatereached'); - this.timeRemainingClass = ''; - - return; - } - - const timeSubmittedDiff = this.userSubmission.timemodified - dueDate; - if (timeSubmittedDiff > 0) { this.timeRemaining = Translate.instant( - 'addon.mod_assign.submittedlate', - { $a: CoreTimeUtils.formatDuration(timeSubmittedDiff, 2) }, + 'addon.mod_assign.' + (onTime ? earlyString : lateString), + { $a: CoreTimeUtils.formatTime(Math.abs(lateCalculation - lateThreshold)) }, ); - this.timeRemainingClass = 'latesubmission'; + this.timeRemainingClass = onTime ? 'earlysubmission' : 'latesubmission'; return; } - this.timeRemaining = Translate.instant( - 'addon.mod_assign.submittedearly', - { $a: CoreTimeUtils.formatDuration(-timeSubmittedDiff, 2) }, - ); - this.timeRemainingClass = 'earlysubmission'; + if (dueDateReached) { + // There is no submission, due date has passed, show assignment is overdue. + const submissionsEnabled = response.lastattempt?.submissionsenabled || response.gradingsummary?.submissionsenabled; + this.timeRemaining = Translate.instant( + 'addon.mod_assign.' + (submissionsEnabled ? 'overdue' : 'duedatereached'), + { $a: CoreTimeUtils.formatTime(time - this.assign.duedate) }, + ); + this.timeRemainingClass = 'overdue'; + this.timeLimitFinished = true; + + return; + } + + if (timeLimitEnabled && submissionStarted) { + // An attempt has started and there is a time limit, display the time limit. + this.timeRemaining = ''; + this.timeRemainingClass = 'timeremaining'; + this.timeLimitEndTime = AddonModAssignHelper.calculateEndTime(this.assign, this.userSubmission); + + return; + } + + // Assignment is not overdue, and no submission has been made. Just display the due date. + this.timeRemaining = CoreTimeUtils.formatTime(this.assign.duedate - time); + this.timeRemainingClass = 'timeremaining'; } /** @@ -292,7 +299,7 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy, Can if (!this.previousAttempt?.submission) { // Cannot access previous attempts, just go to edit. - return this.goToEdit(); + return this.goToEdit(true); } const previousSubmission = this.previousAttempt.submission; @@ -319,7 +326,7 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy, Can try { await AddonModAssignHelper.copyPreviousAttempt(this.assign, previousSubmission); // Now go to edit. - this.goToEdit(); + this.goToEdit(true); if (!this.assign.submissiondrafts && this.userSubmission) { // No drafts allowed, so it was submitted. Trigger event. @@ -352,8 +359,24 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy, Can /** * Go to the page to add or edit submission. + * + * @param afterCopyPrevious Whether the user has just copied the previous submission. */ - goToEdit(): void { + async goToEdit(afterCopyPrevious = false): Promise { + if (!afterCopyPrevious && this.assign?.timelimit && (!this.userSubmission || !this.userSubmission.timestarted)) { + try { + await CoreDomUtils.showConfirm( + Translate.instant('addon.mod_assign.confirmstart', { + $a: CoreTimeUtils.formatTime(this.assign.timelimit), + }), + undefined, + Translate.instant('addon.mod_assign.beginassignment'), + ); + } catch { + return; // User canceled. + } + } + CoreNavigator.navigateToSitePath( AddonModAssignModuleHandlerService.PAGE_NAME + '/' + this.courseId + '/' + this.moduleId + '/edit', { @@ -1175,6 +1198,13 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy, Can this.setGradeSyncBlocked(tab.id === 'grade'); } + /** + * Function called when the time is up. + */ + timeUp(): void { + this.timeLimitFinished = true; + } + /** * Component being destroyed. */ diff --git a/src/addons/mod/assign/lang.json b/src/addons/mod/assign/lang.json index d79de9089..ef812a4d9 100644 --- a/src/addons/mod/assign/lang.json +++ b/src/addons/mod/assign/lang.json @@ -9,14 +9,18 @@ "allowsubmissionsfromdatesummary": "This assignment will accept submissions from {{$a}}", "applytoteam": "Apply grades and feedback to entire group", "assignmentisdue": "Assignment is due", + "assigntimeleft": "Time left", "attemptnumber": "Attempt number", "attemptreopenmethod": "Additional attempts", "attemptreopenmethod_manual": "Manually", "attemptreopenmethod_untilpass": "Automatically until pass", "attemptsettings": "Attempt settings", + "beginassignment": "Begin assignment", + "caneditsubmission": "You can submit/edit submission after time limit passed, but it will be marked as late.", "cannoteditduetostatementsubmission": "You can't add or edit a submission in the app because the submission statement could not be retrieved from the site.", "cannotgradefromapp": "Certain grading methods are not yet supported by the app and cannot be modified.", "cannotsubmitduetostatementsubmission": "You can't make a submission in the app because the submission statement could not be retrieved from the site.", + "confirmstart": "Your submission will have a time limit of {{$a}}. When you start, the timer will begin to count down and cannot be paused. You must finish your submission before it expires. Are you sure you wish to start now?", "confirmsubmission": "Are you sure you want to submit your work for grading? You will not be able to make any more changes.", "currentattempt": "This is attempt {{$a}}.", "currentattemptof": "This is attempt {{$a.attemptnumber}} ( {{$a.maxattempts}} attempts allowed ).", @@ -94,7 +98,10 @@ "submitassignment_help": "Once this assignment is submitted you will not be able to make any more changes.", "submittedearly": "Assignment was submitted {{$a}} early", "submittedlate": "Assignment was submitted {{$a}} late", + "submittedovertime": "Assignment was submitted {{$a}} over the time limit", + "submittedundertime": "Assignment was submitted {{$a}} under the time limit", "syncblockedusercomponent": "user grade", + "timelimit": "Time limit", "timemodified": "Last modified", "timeremaining": "Time remaining", "ungroupedusers": "The setting 'Require group to make submission' is enabled and some users are either not a member of any group, or are a member of more than one group, so are unable to make submissions.", diff --git a/src/addons/mod/assign/pages/edit/edit.html b/src/addons/mod/assign/pages/edit/edit.html index fab97200b..e661cbd84 100644 --- a/src/addons/mod/assign/pages/edit/edit.html +++ b/src/addons/mod/assign/pages/edit/edit.html @@ -20,6 +20,11 @@ + + +
diff --git a/src/addons/mod/assign/pages/edit/edit.scss b/src/addons/mod/assign/pages/edit/edit.scss new file mode 100644 index 000000000..de79e53ed --- /dev/null +++ b/src/addons/mod/assign/pages/edit/edit.scss @@ -0,0 +1,17 @@ +:host ::ng-deep { + core-timer { + display: block; + + .core-timer { + &.core-timer-under-300 { + background-color: var(--danger-tint); + color: var(--danger-shade); + } + + &.core-timer-under-900 { + background-color: var(--warning-tint); + color: var(--warning-shade); + } + } + } +} diff --git a/src/addons/mod/assign/pages/edit/edit.ts b/src/addons/mod/assign/pages/edit/edit.ts index 6e0f06b8d..7f76ca8f4 100644 --- a/src/addons/mod/assign/pages/edit/edit.ts +++ b/src/addons/mod/assign/pages/edit/edit.ts @@ -32,6 +32,7 @@ import { AddonModAssignSubmissionStatusOptions, AddonModAssignGetSubmissionStatusWSResponse, AddonModAssignSavePluginData, + AddonModAssignSubmissionStatusValues, } from '../../services/assign'; import { AddonModAssignHelper } from '../../services/assign-helper'; import { AddonModAssignOffline } from '../../services/assign-offline'; @@ -44,6 +45,7 @@ import { CoreUtils } from '@services/utils/utils'; @Component({ selector: 'page-addon-mod-assign-edit', templateUrl: 'edit.html', + styleUrls: ['edit.scss'], }) export class AddonModAssignEditPage implements OnInit, OnDestroy, CanLeave { @@ -58,6 +60,7 @@ export class AddonModAssignEditPage implements OnInit, OnDestroy, CanLeave { submissionStatement?: string; // The submission statement. submissionStatementAccepted = false; // Whether submission statement is accepted. loaded = false; // Whether data has been loaded. + timeLimitEndTime = 0; // If time limit is enabled, the end time for the timer. protected userId: number; // User doing the submission. protected isBlind = false; // Whether blind is used. @@ -179,6 +182,8 @@ export class AddonModAssignEditPage implements OnInit, OnDestroy, CanLeave { throw new CoreError(Translate.instant('core.nopermissions', { $a: this.editText })); } + submissionStatus = await this.startSubmissionIfNeeded(submissionStatus, options); + this.allowOffline = true; // If offline isn't allowed we shouldn't have reached this point. // Only show submission statement if we are editing our own submission. if (this.assign.requiresubmissionstatement && !this.assign.submissiondrafts && this.userId == currentUserId) { @@ -187,6 +192,12 @@ export class AddonModAssignEditPage implements OnInit, OnDestroy, CanLeave { this.submissionStatement = undefined; } + if (this.assign.timelimit && this.userSubmission?.timestarted) { + this.timeLimitEndTime = AddonModAssignHelper.calculateEndTime(this.assign, this.userSubmission); + } else { + this.timeLimitEndTime = 0; + } + try { // Check if there's any offline data for this submission. const offlineData = await AddonModAssignOffline.getSubmission(this.assign.id, this.userId); @@ -204,6 +215,45 @@ export class AddonModAssignEditPage implements OnInit, OnDestroy, CanLeave { } } + /** + * Start the submission if needed. + * + * @param submissionStatus Current submission status. + * @param options Options. + * @return Promise resolved with the new submission status if it changed, original submission status otherwise. + */ + protected async startSubmissionIfNeeded( + submissionStatus: AddonModAssignGetSubmissionStatusWSResponse, + options: AddonModAssignSubmissionStatusOptions, + ): Promise { + if (!this.assign || !this.assign.timelimit) { + // Submission only needs to be started if there's a timelimit. + return submissionStatus; + } + + if (this.userSubmission && this.userSubmission.status !== AddonModAssignSubmissionStatusValues.NEW && + this.userSubmission.status !== AddonModAssignSubmissionStatusValues.REOPENED) { + // There is an ongoing submission, no need to start it. + return submissionStatus; + } + + await AddonModAssign.startSubmission(this.assign.id); + + CoreEvents.trigger(AddonModAssignProvider.STARTED_EVENT, { + assignmentId: this.assign.id, + }, CoreSites.getCurrentSiteId()); + + // Submission started, update the submission status. + const newSubmissionStatus = await AddonModAssign.getSubmissionStatus(this.assign.id, { + ...options, + readingStrategy: CoreSitesReadingStrategy.ONLY_NETWORK, // Make sure not to use cache. + }); + + this.userSubmission = AddonModAssign.getSubmissionObjectFromAttempt(this.assign, newSubmissionStatus.lastattempt); + + return newSubmissionStatus; + } + /** * Get the input data. * @@ -392,6 +442,18 @@ export class AddonModAssignEditPage implements OnInit, OnDestroy, CanLeave { } } + /** + * Function called when the time is up. + */ + timeUp(): void { + CoreDomUtils.showToastWithOptions({ + message: Translate.instant('addon.mod_assign.caneditsubmission'), + duration: 0, + buttons: [Translate.instant('core.dismiss')], + cssClass: 'core-danger-toast', + }); + } + /** * Component being destroyed. */ diff --git a/src/addons/mod/assign/services/assign-helper.ts b/src/addons/mod/assign/services/assign-helper.ts index c3f948527..01e48419b 100644 --- a/src/addons/mod/assign/services/assign-helper.ts +++ b/src/addons/mod/assign/services/assign-helper.ts @@ -44,6 +44,25 @@ import { CoreFileEntry } from '@services/file-helper'; @Injectable({ providedIn: 'root' }) export class AddonModAssignHelperProvider { + /** + * Calculate the end time (timestamp) for an assign and submission. + * + * @param assign Assign instance. + * @param submission Submission. + * @return End time. + */ + calculateEndTime(assign: AddonModAssignAssign, submission?: AddonModAssignSubmissionFormatted): number { + const timeDue = (submission?.timestarted || 0) + (assign.timelimit || 0); + + if (assign.duedate) { + return Math.min(timeDue, assign.duedate); + } else if (assign.cutoffdate) { + return Math.min(timeDue, assign.cutoffdate); + } + + return timeDue; + } + /** * Check if a submission can be edited in offline. * diff --git a/src/addons/mod/assign/services/assign.ts b/src/addons/mod/assign/services/assign.ts index 643e4647c..d24c61664 100644 --- a/src/addons/mod/assign/services/assign.ts +++ b/src/addons/mod/assign/services/assign.ts @@ -49,6 +49,7 @@ declare module '@singletons/events' { [AddonModAssignProvider.SUBMISSION_SAVED_EVENT]: AddonModAssignSubmissionSavedEventData; [AddonModAssignProvider.SUBMITTED_FOR_GRADING_EVENT]: AddonModAssignSubmittedForGradingEventData; [AddonModAssignProvider.GRADED_EVENT]: AddonModAssignGradedEventData; + [AddonModAssignProvider.STARTED_EVENT]: AddonModAssignStartedEventData; [AddonModAssignSyncProvider.MANUAL_SYNCED]: AddonModAssignManualSyncData; [AddonModAssignSyncProvider.AUTO_SYNCED]: AddonModAssignAutoSyncData; } @@ -73,6 +74,7 @@ export class AddonModAssignProvider { static readonly SUBMISSION_SAVED_EVENT = 'addon_mod_assign_submission_saved'; static readonly SUBMITTED_FOR_GRADING_EVENT = 'addon_mod_assign_submitted_for_grading'; static readonly GRADED_EVENT = 'addon_mod_assign_graded'; + static readonly STARTED_EVENT = 'addon_mod_assign_started'; /** * Check if the user can submit in offline. This should only be used if submissionStatus.lastattempt.cansubmit cannot @@ -1069,6 +1071,35 @@ export class AddonModAssignProvider { } } + /** + * Start a submission. + * + * @param assignId Assign ID. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved when done. + */ + async startSubmission(assignId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const params: AddonModAssignStartSubmissionWSParams = { + assignid: assignId, + }; + + const result = await site.write('mod_assign_start_submission', params); + + if (!result.warnings?.length) { + return; + } + + // Ignore some warnings. + const warning = result.warnings.find(warning => + warning.warningcode !== 'timelimitnotenabled' && warning.warningcode !== 'opensubmissionexists'); + + if (warning) { + throw new CoreWSError(warning); + } + } + /** * Submit the current user assignment for grading. * @@ -1351,6 +1382,11 @@ export type AddonModAssignAssign = { introformat?: number; // Intro format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). introfiles?: CoreWSExternalFile[]; introattachments?: CoreWSExternalFile[]; + activity?: string; // @since 4.0. Description of activity. + activityformat?: number; // @since 4.0. Format of activity. + activityattachments?: CoreWSExternalFile[]; // @since 4.0. Files from activity field. + timelimit?: number; // @since 4.0. Time limit to complete assigment. + submissionattachments?: number; // @since 4.0. Flag to only show files during submission. }; /** @@ -1395,6 +1431,7 @@ export type AddonModAssignSubmission = { latest?: number; // Latest attempt. plugins?: AddonModAssignPlugin[]; // Plugins. gradingstatus?: AddonModAssignGradingStates; // Grading status. + timestarted?: number; // @since 4.0. Submission start time. }; /** @@ -1445,6 +1482,7 @@ export type AddonModAssignSubmissionAttempt = { blindmarking: boolean; // Whether blind marking is enabled. gradingstatus: AddonModAssignGradingStates; // Grading status. usergroups: number[]; // User groups in the course. + timelimit?: number; // @since 4.0. Time limit for submission. }; /** @@ -1604,6 +1642,14 @@ export type AddonModAssignGetSubmissionStatusWSResponse = { lastattempt?: AddonModAssignSubmissionAttempt; // Last attempt information. feedback?: AddonModAssignSubmissionFeedback; // Feedback for the last attempt. previousattempts?: AddonModAssignSubmissionPreviousAttempt[]; // List all the previous attempts did by the user. + assignmentdata?: { // @since 4.0. Extra information about assignment. + attachments?: { // Intro and activity attachments. + intro?: CoreWSExternalFile[]; // Intro attachments files. + activity?: CoreWSExternalFile[]; // Activity attachments files. + }; + activity?: string; // Text of activity. + activityformat?: number; // Format of activity. + }; warnings?: CoreWSExternalWarning[]; }; @@ -1715,6 +1761,25 @@ type AddonModAssignSubmitGradingFormWSParams = { jsonformdata: string; // The data from the grading form, encoded as a json array. }; +/** + * Params of mod_assign_start_submission WS. + * + * @since 4.0 + */ +type AddonModAssignStartSubmissionWSParams = { + assignid: number; // Assignment instance id. +}; + +/** + * Data returned by mod_assign_start_submission WS. + * + * @since 4.0 + */ +export type AddonModAssignStartSubmissionWSResponse = { + submissionid: number; // New submission ID. + warnings?: CoreWSExternalWarning[]; +}; + /** * Assignment grade outcomes. */ @@ -1739,6 +1804,13 @@ export type AddonModAssignSubmissionSavedEventData = AddonModAssignSubmittedForG */ export type AddonModAssignGradedEventData = AddonModAssignSubmittedForGradingEventData; +/** + * Data sent by STARTED_EVENT event. + */ +export type AddonModAssignStartedEventData = { + assignmentId: number; +}; + /** * Submission status. * Constants on LMS starting with ASSIGN_SUBMISSION_STATUS_ diff --git a/src/addons/mod/assign/services/handlers/prefetch.ts b/src/addons/mod/assign/services/handlers/prefetch.ts index bd504ca3b..bb344e147 100644 --- a/src/addons/mod/assign/services/handlers/prefetch.ts +++ b/src/addons/mod/assign/services/handlers/prefetch.ts @@ -87,9 +87,10 @@ export class AddonModAssignPrefetchHandlerService extends CoreCourseActivityPref try { const assign = await AddonModAssign.getAssignment(courseId, module.id, { siteId }); - // Get intro files and attachments. + // Get intro files, attachments and activity files. let files: CoreWSFile[] = assign.introattachments || []; files = files.concat(this.getIntroFilesFromInstance(module, assign)); + files = files.concat(assign.activityattachments || []); // Now get the files in the submissions. const submissionData = await AddonModAssign.getSubmissions(assign.id, { cmId: module.id, siteId }); diff --git a/src/addons/mod/h5pactivity/services/h5pactivity.ts b/src/addons/mod/h5pactivity/services/h5pactivity.ts index d8a80aee4..7d5dae0c2 100644 --- a/src/addons/mod/h5pactivity/services/h5pactivity.ts +++ b/src/addons/mod/h5pactivity/services/h5pactivity.ts @@ -90,8 +90,8 @@ export class AddonModH5PActivityProvider { formattedAttempt.durationReadable = '-'; formattedAttempt.durationCompact = '-'; } else { - formattedAttempt.durationReadable = CoreTimeUtils.formatTime(attempt.duration); - formattedAttempt.durationCompact = CoreTimeUtils.formatDurationShort(attempt.duration); + formattedAttempt.durationReadable = CoreTimeUtils.formatTime(attempt.duration, 3); + formattedAttempt.durationCompact = CoreTimeUtils.formatTimeShort(attempt.duration); } return formattedAttempt; diff --git a/src/addons/mod/lesson/pages/player/player.html b/src/addons/mod/lesson/pages/player/player.html index f0a46750e..da29653c9 100644 --- a/src/addons/mod/lesson/pages/player/player.html +++ b/src/addons/mod/lesson/pages/player/player.html @@ -30,7 +30,7 @@
- diff --git a/src/addons/mod/quiz/pages/player/player.scss b/src/addons/mod/quiz/pages/player/player.scss index 7d68762c8..ca4062557 100644 --- a/src/addons/mod/quiz/pages/player/player.scss +++ b/src/addons/mod/quiz/pages/player/player.scss @@ -1,3 +1,8 @@ +@import "~theme/globals"; + +$quiz-timer-warn-color: $red !default; +$quiz-timer-iterations: 15 !default; + :host { .addon-mod_quiz-question-note p { margin-top: 2px; @@ -7,4 +12,19 @@ ion-content ion-toolbar { border-bottom: 1px solid var(--stroke); } + + core-timer .core-timer { + // Make the timer go red when it's reaching 0. + @for $i from 0 through $quiz-timer-iterations { + &.core-timer-timeleft-#{$i} { + background-color: rgba($quiz-timer-warn-color, 1 - ($i / $quiz-timer-iterations)) !important; + + @if $i <= $quiz-timer-iterations / 2 { + label, span, ion-icon { + color: var(--white); + } + } + } + } + } } diff --git a/src/core/components/timer/core-timer.html b/src/core/components/timer/core-timer.html index 388a53324..8cf5a57e8 100644 --- a/src/core/components/timer/core-timer.html +++ b/src/core/components/timer/core-timer.html @@ -1,10 +1,21 @@ - + - {{ timerText }} - {{ timeLeft | coreSecondsToHMS }} - - {{ 'core.timesup' | translate }} - + + +
+ +
+ + + {{ timerText }} + {{ timeLeft | coreSecondsToHMS }} + + {{ timeUpText }} + {{ 'core.timesup' | translate }} + + diff --git a/src/core/components/timer/timer.scss b/src/core/components/timer/timer.scss index c7c9cc39c..12955ee2d 100644 --- a/src/core/components/timer/timer.scss +++ b/src/core/components/timer/timer.scss @@ -1,8 +1,3 @@ -@import "~theme/globals"; - -$core-timer-warn-color: $red !default; -$core-timer-iterations: 15 !default; - :host { .core-timer { --background: transparent !important; @@ -15,18 +10,5 @@ $core-timer-iterations: 15 !default; span { margin-right: 5px; } - - // Create the timer warning colors. - @for $i from 0 through $core-timer-iterations { - &.core-timer-timeleft-#{$i} { - background-color: rgba($core-timer-warn-color, 1 - ($i / $core-timer-iterations)) !important; - - @if $i <= $core-timer-iterations / 2 { - label, span, ion-icon { - color: var(--white); - } - } - } - } } } diff --git a/src/core/components/timer/timer.ts b/src/core/components/timer/timer.ts index 581941a70..5d310af86 100644 --- a/src/core/components/timer/timer.ts +++ b/src/core/components/timer/timer.ts @@ -32,10 +32,15 @@ export class CoreTimerComponent implements OnInit, OnDestroy { @Input() endTime?: string | number; // Timestamp (in seconds) when the timer should end. @Input() timerText?: string; // Text to show next to the timer. If not defined, no text shown. @Input() timeLeftClass?: string; // Name of the class to apply with each second. By default, 'core-timer-timeleft-'. + @Input() timeLeftClassThreshold = 100; // Number of seconds to start adding the timeLeftClass. Set it to -1 to not add it. @Input() align?: string; // Where to align the time and text. Defaults to 'left'. Other values: 'center', 'right'. + @Input() timeUpText?: string; // Text to show when the timer reaches 0. If not defined, 'core.timesup'. + @Input() mode: CoreTimerMode = CoreTimerMode.ITEM; // How to display data. + @Input() underTimeClassThresholds = []; // Number of seconds to add the class 'core-timer-under-'. @Output() finished = new EventEmitter(); // Will emit an event when the timer reaches 0. timeLeft?: number; // Seconds left to end. + modeBasic = CoreTimerMode.BASIC; protected timeInterval?: number; protected element?: HTMLElement; @@ -50,31 +55,51 @@ export class CoreTimerComponent implements OnInit, OnDestroy { ngOnInit(): void { const timeLeftClass = this.timeLeftClass || 'core-timer-timeleft-'; const endTime = Math.round(Number(this.endTime)); - const container: HTMLElement | undefined = this.elementRef.nativeElement.querySelector('.core-timer'); + this.underTimeClassThresholds.sort((a, b) => a - b); // Sort by increase order. if (!endTime) { return; } + let container: HTMLElement | undefined; + // Check time left every 200ms. this.timeInterval = window.setInterval(() => { - this.timeLeft = endTime - CoreTimeUtils.timestamp(); + container = container || this.elementRef.nativeElement.querySelector('.core-timer'); + this.timeLeft = Math.max(endTime - CoreTimeUtils.timestamp(), 0); - if (this.timeLeft < 0) { + if (container) { + // Add class if timer is below timeLeftClassThreshold. + if (this.timeLeft < this.timeLeftClassThreshold && !container.classList.contains(timeLeftClass + this.timeLeft)) { + // Time left has changed. Remove previous classes and add the new one. + container.classList.remove(timeLeftClass + (this.timeLeft + 1)); + container.classList.remove(timeLeftClass + (this.timeLeft + 2)); + container.classList.add(timeLeftClass + this.timeLeft); + } + + // Add classes for underTimeClassThresholds. + for (let i = 0; i < this.underTimeClassThresholds.length; i++) { + const threshold = this.underTimeClassThresholds[i]; + if (this.timeLeft <= threshold) { + if (!container.classList.contains('core-timer-under-' + this.timeLeft)) { + // Add new class and remove the previous one. + const nextTreshold = this.underTimeClassThresholds[i + 1]; + container.classList.add('core-timer-under-' + threshold); + nextTreshold && container.classList.remove('core-timer-under-' + nextTreshold); + } + + break; + } + } + } + + if (this.timeLeft === 0) { // Time is up! Stop the timer and call the finish function. clearInterval(this.timeInterval); this.finished.emit(); return; } - - // If the time has nearly expired, change the color. - if (this.timeLeft < 100 && container && !container.classList.contains(timeLeftClass + this.timeLeft)) { - // Time left has changed. Remove previous classes and add the new one. - container.classList.remove(timeLeftClass + (this.timeLeft + 1)); - container.classList.remove(timeLeftClass + (this.timeLeft + 2)); - container.classList.add(timeLeftClass + this.timeLeft); - } }, 200); } @@ -86,3 +111,8 @@ export class CoreTimerComponent implements OnInit, OnDestroy { } } + +export enum CoreTimerMode { + ITEM = 'item', + BASIC = 'basic', +} diff --git a/src/core/initializers/initialize-services.ts b/src/core/initializers/initialize-services.ts index 0806badd5..81aab20ed 100644 --- a/src/core/initializers/initialize-services.ts +++ b/src/core/initializers/initialize-services.ts @@ -18,6 +18,7 @@ import { CoreLang } from '@services/lang'; import { CoreLocalNotifications } from '@services/local-notifications'; import { CoreSites } from '@services/sites'; import { CoreUpdateManager } from '@services/update-manager'; +import { CoreTimeUtils } from '@services/utils/time'; export default async function(): Promise { await Promise.all([ @@ -27,5 +28,6 @@ export default async function(): Promise { CoreLang.initialize(), CoreLocalNotifications.initialize(), CoreUpdateManager.initialize(), + CoreTimeUtils.initialize(), ]); } diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index c0268ba1f..c6bbc23d4 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -14,7 +14,7 @@ import { Injectable, SimpleChange, ElementRef, KeyValueChanges } from '@angular/core'; import { IonContent } from '@ionic/angular'; -import { ModalOptions, PopoverOptions, AlertOptions, AlertButton, TextFieldTypes, getMode } from '@ionic/core'; +import { ModalOptions, PopoverOptions, AlertOptions, AlertButton, TextFieldTypes, getMode, ToastOptions } from '@ionic/core'; import { Md5 } from 'ts-md5'; import { CoreApp } from '@services/app'; @@ -1633,6 +1633,24 @@ export class CoreDomUtilsProvider { return loader; } + /** + * Show toast with some options. + * + * @param options Options. + * @return Promise resolved with Toast instance. + */ + async showToastWithOptions(options: ToastOptions): Promise { + // Set some default values. + options.duration = options.duration ?? 2000; + options.position = options.position ?? 'bottom'; + + const loader = await ToastController.create(options); + + await loader.present(); + + return loader; + } + /** * Stores a component/directive instance. * diff --git a/src/core/services/utils/time.ts b/src/core/services/utils/time.ts index 60457d388..bac183265 100644 --- a/src/core/services/utils/time.ts +++ b/src/core/services/utils/time.ts @@ -15,7 +15,6 @@ import { Injectable } from '@angular/core'; import moment, { LongDateFormatKey } from 'moment'; -import { CoreConstants } from '@/core/constants'; import { makeSingleton, Translate } from '@singletons'; /* @@ -68,6 +67,20 @@ export class CoreTimeUtilsProvider { '%%': '%', }; + /** + * Initialize. + */ + initialize(): void { + // Set relative time thresholds for humanize(), otherwise for example 47 minutes were converted to 'an hour'. + moment.relativeTimeThreshold('s', 60); + moment.relativeTimeThreshold('m', 60); + moment.relativeTimeThreshold('h', 24); + moment.relativeTimeThreshold('d', 31); + moment.relativeTimeThreshold('M', 12); + moment.relativeTimeThreshold('y', 365); + moment.relativeTimeThreshold('ss', 0); // To display exact number of seconds instead of just "a few seconds". + } + /** * Convert a PHP format to a Moment format. * @@ -142,82 +155,16 @@ export class CoreTimeUtilsProvider { } /** - * Returns hours, minutes and seconds in a human readable format + * Returns years, months, days, hours, minutes and seconds in a human readable format. * * @param seconds A number of seconds + * @param precision Number of elements to have in precision. * @return Seconds in a human readable format. */ - formatTime(seconds: number): string { - const totalSecs = Math.abs(seconds); - const years = Math.floor(totalSecs / CoreConstants.SECONDS_YEAR); - let remainder = totalSecs - (years * CoreConstants.SECONDS_YEAR); - const days = Math.floor(remainder / CoreConstants.SECONDS_DAY); + formatTime(seconds: number, precision = 2): string { + precision = precision || 6; // Use max precision if 0 is passed. - remainder = totalSecs - (days * CoreConstants.SECONDS_DAY); - - const hours = Math.floor(remainder / CoreConstants.SECONDS_HOUR); - remainder = remainder - (hours * CoreConstants.SECONDS_HOUR); - - const mins = Math.floor(remainder / CoreConstants.SECONDS_MINUTE); - const secs = remainder - (mins * CoreConstants.SECONDS_MINUTE); - - const ss = Translate.instant('core.' + (secs == 1 ? 'sec' : 'secs')); - const sm = Translate.instant('core.' + (mins == 1 ? 'min' : 'mins')); - const sh = Translate.instant('core.' + (hours == 1 ? 'hour' : 'hours')); - const sd = Translate.instant('core.' + (days == 1 ? 'day' : 'days')); - const sy = Translate.instant('core.' + (years == 1 ? 'year' : 'years')); - let oyears = ''; - let odays = ''; - let ohours = ''; - let omins = ''; - let osecs = ''; - - if (years) { - oyears = years + ' ' + sy; - } - if (days) { - odays = days + ' ' + sd; - } - if (hours) { - ohours = hours + ' ' + sh; - } - if (mins) { - omins = mins + ' ' + sm; - } - if (secs) { - osecs = secs + ' ' + ss; - } - - if (years) { - return oyears + ' ' + odays; - } - if (days) { - return odays + ' ' + ohours; - } - if (hours) { - return ohours + ' ' + omins; - } - if (mins) { - return omins + ' ' + osecs; - } - if (secs) { - return osecs; - } - - return Translate.instant('core.now'); - } - - /** - * Returns hours, minutes and seconds in a human readable format. - * - * @param duration Duration in seconds - * @param precision Number of elements to have in precision. 0 or undefined to full precission. - * @return Duration in a human readable format. - */ - formatDuration(duration: number, precision?: number): string { - precision = precision || 5; - - const eventDuration = moment.duration(duration, 'seconds'); + const eventDuration = moment.duration(Math.abs(seconds), 'seconds'); let durationString = ''; if (precision && eventDuration.years() > 0) { @@ -240,17 +187,21 @@ export class CoreTimeUtilsProvider { durationString += ' ' + moment.duration(eventDuration.minutes(), 'minutes').humanize(); precision--; } + if (precision && (eventDuration.seconds() > 0 || !durationString)) { + durationString += ' ' + moment.duration(eventDuration.seconds(), 'seconds').humanize(); + precision--; + } return durationString.trim(); } /** - * Returns duration in a short human readable format: minutes and seconds, in fromat: 3' 27''. + * Converts a number of seconds into a short human readable format: minutes and seconds, in fromat: 3' 27''. * - * @param duration Duration in seconds - * @return Duration in a short human readable format. + * @param seconds Seconds + * @return Short human readable text. */ - formatDurationShort(duration: number): string { + formatTimeShort(duration: number): string { const minutes = Math.floor(duration / 60); const seconds = duration - minutes * 60; const durations = []; @@ -266,6 +217,29 @@ export class CoreTimeUtilsProvider { return durations.join(' '); } + /** + * Returns hours, minutes and seconds in a human readable format. + * + * @param duration Duration in seconds + * @param precision Number of elements to have in precision. 0 or undefined to full precission. + * @return Duration in a human readable format. + * @deprecated since 4.0. Use formatTime instead. + */ + formatDuration(duration: number, precision?: number): string { + return this.formatTime(duration, precision); + } + + /** + * Returns duration in a short human readable format: minutes and seconds, in fromat: 3' 27''. + * + * @param duration Duration in seconds + * @return Duration in a short human readable format. + * @deprecated since 4.0. Use formatTime instead. + */ + formatDurationShort(duration: number): string { + return this.formatTimeShort(duration); + } + /** * Return the current timestamp in a "readable" format: YYYYMMDDHHmmSS. * diff --git a/src/theme/theme.base.scss b/src/theme/theme.base.scss index ebf03452b..78a7bf894 100644 --- a/src/theme/theme.base.scss +++ b/src/theme/theme.base.scss @@ -504,10 +504,19 @@ ion-toast { @include media-breakpoint-down(sm) { &::part(container) { flex-direction: column; + align-items: flex-end; } } } +@each $color-name, $unused in $colors { + ion-toast.core-#{$color-name}-toast { + --background: var(--ion-color-#{$color-name}-tint); + --color: var(--ion-color-#{$color-name}-shade); + --button-color: var(--ion-color-#{$color-name}-shade); + } +} + // Ionic list. ion-list { padding: 0 !important;