forked from EVOgeek/Vmeda.Online
		
	
						commit
						692a31e29b
					
				| @ -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", | ||||
|  | ||||
| @ -11,7 +11,7 @@ | ||||
|     <!-- Activity info. --> | ||||
|     <core-course-module-info [module]="module" [description]="description" [component]="component" [componentId]="componentId" | ||||
|         [courseId]="courseId" [hasDataToSync]="hasOffline" (completionChanged)="onCompletionChange()"> | ||||
|         <ion-list inset="true" description *ngIf="assign && assign.introattachments && assign.introattachments.length"> | ||||
|         <ion-list inset="true" description *ngIf="assign && assign.introattachments?.length && !assign.submissionattachments"> | ||||
|             <core-file *ngFor="let file of assign.introattachments" [file]="file" [component]="component" [componentId]="componentId"> | ||||
|             </core-file> | ||||
|         </ion-list> | ||||
| @ -33,6 +33,14 @@ | ||||
|                 </ion-select> | ||||
|             </ion-item> | ||||
| 
 | ||||
|             <ion-item class="ion-text-wrap"> | ||||
|                 <ion-label> | ||||
|                     <h2>{{ 'core.course.hiddenfromstudents' | translate }}</h2> | ||||
|                     <p *ngIf="module.visible">{{ 'core.no' | translate }}</p> | ||||
|                     <p *ngIf="!module.visible">{{ 'core.yes' | translate }}</p> | ||||
|                 </ion-label> | ||||
|             </ion-item> | ||||
| 
 | ||||
|             <ion-item class="ion-text-wrap" *ngIf="timeRemaining"> | ||||
|                 <ion-label> | ||||
|                     <h2>{{ 'addon.mod_assign.timeremaining' | translate }}</h2> | ||||
|  | ||||
| @ -26,6 +26,7 @@ import { CoreTimeUtils } from '@services/utils/time'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { Translate } from '@singletons'; | ||||
| import { CoreEventObserver, CoreEvents } from '@singletons/events'; | ||||
| import { CoreTime } from '@singletons/time'; | ||||
| import { AddonModAssignListFilterName } from '../../classes/submissions-source'; | ||||
| import { | ||||
|     AddonModAssign, | ||||
| @ -86,6 +87,7 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo | ||||
|     protected savedObserver?: CoreEventObserver; | ||||
|     protected submittedObserver?: CoreEventObserver; | ||||
|     protected gradedObserver?: CoreEventObserver; | ||||
|     protected startedObserver?: CoreEventObserver; | ||||
| 
 | ||||
|     constructor( | ||||
|         protected content?: IonContent, | ||||
| @ -136,6 +138,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 +176,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 = CoreTime.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 +196,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 +407,7 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo | ||||
|         this.savedObserver?.off(); | ||||
|         this.submittedObserver?.off(); | ||||
|         this.gradedObserver?.off(); | ||||
|         this.startedObserver?.off(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -1,5 +1,15 @@ | ||||
| <core-loading [hideUntil]="loaded"> | ||||
|     <div class="list-item-limited-width"> | ||||
|         <!-- Time limit is over. --> | ||||
|         <ion-card *ngIf="timeLimitFinished" class="core-danger-card"> | ||||
|             <ion-item class="ion-text-wrap"> | ||||
|                 <ion-icon name="fas-exclamation-triangle" slot="start" aria-hidden="true"></ion-icon> | ||||
|                 <ion-label> | ||||
|                     <p>{{ 'addon.mod_assign.caneditsubmission' | translate }}</p> | ||||
|                 </ion-label> | ||||
|             </ion-item> | ||||
|         </ion-card> | ||||
| 
 | ||||
|         <!-- User and status of the submission. --> | ||||
|         <ion-item class="ion-text-wrap" *ngIf="!blindMarking && user" core-user-link [userId]="submitId" [courseId]="courseId" | ||||
|             [attr.aria-label]="user!.fullname"> | ||||
| @ -31,26 +41,29 @@ | ||||
|             <!-- View the submission tab. --> | ||||
|             <core-tab [title]="'addon.mod_assign.submission' | translate" id="submission"> | ||||
|                 <ng-template> | ||||
|                     <addon-mod-assign-submission-plugin *ngFor="let plugin of submissionPlugins" [assign]="assign" | ||||
|                         [submission]="userSubmission" [plugin]="plugin"> | ||||
|                     </addon-mod-assign-submission-plugin> | ||||
| 
 | ||||
|                     <!-- Render some data about the submission. --> | ||||
|                     <ion-item class="ion-text-wrap" | ||||
|                         *ngIf="userSubmission && userSubmission!.status != statusNew && userSubmission!.timemodified"> | ||||
|                     <ion-item class="ion-text-wrap" *ngIf="currentAttempt && !isGrading"> | ||||
|                         <ion-label> | ||||
|                             <h2>{{ 'addon.mod_assign.timemodified' | translate }}</h2> | ||||
|                             <p>{{ userSubmission!.timemodified * 1000 | coreFormatDate }}</p> | ||||
|                             <h2>{{ 'addon.mod_assign.attemptnumber' | translate }}</h2> | ||||
|                             <p *ngIf="assign!.maxattempts == unlimitedAttempts"> | ||||
|                                 {{ 'addon.mod_assign.outof' | translate : | ||||
|                                 {'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }} | ||||
|                             </p> | ||||
|                             <p *ngIf="assign!.maxattempts != unlimitedAttempts"> | ||||
|                                 {{ 'addon.mod_assign.outof' | translate : | ||||
|                                 {'$a': {'current': currentAttempt, 'total': assign!.maxattempts} } }} | ||||
|                             </p> | ||||
|                         </ion-label> | ||||
|                     </ion-item> | ||||
| 
 | ||||
|                     <ion-item class="ion-text-wrap" *ngIf="timeRemaining" [ngClass]="[timeRemainingClass]"> | ||||
|                     <!-- Submission is locked. --> | ||||
|                     <ion-item class="ion-text-wrap" *ngIf="lastAttempt?.locked"> | ||||
|                         <ion-label> | ||||
|                             <h2>{{ 'addon.mod_assign.timeremaining' | translate }}</h2> | ||||
|                             <p [innerHTML]="timeRemaining"></p> | ||||
|                             <h2>{{ 'addon.mod_assign.submissionslocked' | translate }}</h2> | ||||
|                         </ion-label> | ||||
|                     </ion-item> | ||||
| 
 | ||||
|                     <!-- Dates. --> | ||||
|                     <ion-item class="ion-text-wrap" *ngIf="showDates && fromDate && !isSubmittedForGrading"> | ||||
|                         <ion-label> | ||||
|                             <p *ngIf="assign!.intro" | ||||
| @ -84,20 +97,48 @@ | ||||
|                         </ion-label> | ||||
|                     </ion-item> | ||||
| 
 | ||||
|                     <ion-item class="ion-text-wrap" *ngIf="currentAttempt && !isGrading"> | ||||
|                     <!-- Time remaining. --> | ||||
|                     <ion-item class="ion-text-wrap" *ngIf="timeRemaining || timeLimitEndTime > 0" [ngClass]="[timeRemainingClass]"> | ||||
|                         <ion-label> | ||||
|                             <h2>{{ 'addon.mod_assign.attemptnumber' | translate }}</h2> | ||||
|                             <p *ngIf="assign!.maxattempts == unlimitedAttempts"> | ||||
|                                 {{ 'addon.mod_assign.outof' | translate : | ||||
|                                 {'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }} | ||||
|                             </p> | ||||
|                             <p *ngIf="assign!.maxattempts != unlimitedAttempts"> | ||||
|                                 {{ 'addon.mod_assign.outof' | translate : | ||||
|                                 {'$a': {'current': currentAttempt, 'total': assign!.maxattempts} } }} | ||||
|                             </p> | ||||
|                             <h2>{{ 'addon.mod_assign.timeremaining' | translate }}</h2> | ||||
|                             <p *ngIf="!timeLimitEndTime" [innerHTML]="timeRemaining"></p> | ||||
|                             <core-timer *ngIf="timeLimitEndTime > 0" [endTime]="timeLimitEndTime" mode="basic" timeUpText="00:00:00" | ||||
|                                 [timeLeftClassThreshold]="-1" [underTimeClassThresholds]="[300, 900]" (finished)="timeUp()"> | ||||
|                             </core-timer> | ||||
|                         </ion-label> | ||||
|                     </ion-item> | ||||
| 
 | ||||
|                     <!-- Time limit. --> | ||||
|                     <ion-item class="ion-text-wrap" *ngIf="assign && assign.timelimit"> | ||||
|                         <ion-label> | ||||
|                             <h2>{{ 'addon.mod_assign.timelimit' | translate }}</h2> | ||||
|                             <p>{{ assign.timelimit | coreDuration }}</p> | ||||
|                         </ion-label> | ||||
|                     </ion-item> | ||||
| 
 | ||||
|                     <!-- Editing status. --> | ||||
|                     <ion-item class="ion-text-wrap" *ngIf="lastAttempt && isSubmittedForGrading && lastAttempt!.caneditowner !== undefined" | ||||
|                         [ngClass]="{submissioneditable: lastAttempt!.caneditowner, submissionnoteditable: !lastAttempt!.caneditowner}"> | ||||
|                         <ion-label> | ||||
|                             <h2>{{ 'addon.mod_assign.editingstatus' | translate }}</h2> | ||||
|                             <p *ngIf="lastAttempt!.caneditowner">{{ 'addon.mod_assign.submissioneditable' | translate }}</p> | ||||
|                             <p *ngIf="!lastAttempt!.caneditowner">{{ 'addon.mod_assign.submissionnoteditable' | translate }}</p> | ||||
|                         </ion-label> | ||||
|                     </ion-item> | ||||
| 
 | ||||
|                     <!-- Last modified. --> | ||||
|                     <ion-item class="ion-text-wrap" | ||||
|                         *ngIf="userSubmission && userSubmission!.status != statusNew && userSubmission!.timemodified"> | ||||
|                         <ion-label> | ||||
|                             <h2>{{ 'addon.mod_assign.timemodified' | translate }}</h2> | ||||
|                             <p>{{ userSubmission!.timemodified * 1000 | coreFormatDate }}</p> | ||||
|                         </ion-label> | ||||
|                     </ion-item> | ||||
| 
 | ||||
|                     <addon-mod-assign-submission-plugin *ngFor="let plugin of submissionPlugins" [assign]="assign" | ||||
|                         [submission]="userSubmission" [plugin]="plugin"> | ||||
|                     </addon-mod-assign-submission-plugin> | ||||
| 
 | ||||
|                     <!-- Add or edit submission. --> | ||||
|                     <ion-item class="ion-text-wrap" *ngIf="canEdit"> | ||||
|                         <ion-label> | ||||
| @ -109,7 +150,12 @@ | ||||
|                                 <!-- If no submission or is new, show add submission. --> | ||||
|                                 <ion-button expand="block" class="ion-text-wrap" (click)="goToEdit()" *ngIf="!hasOffline && | ||||
|                                     (!userSubmission || !userSubmission!.status || userSubmission!.status == statusNew)"> | ||||
|                                     <ng-container *ngIf="!assign?.timelimit || userSubmission?.timestarted"> | ||||
|                                         {{ 'addon.mod_assign.addsubmission' | translate }} | ||||
|                                     </ng-container> | ||||
|                                     <ng-container *ngIf="assign?.timelimit && (!userSubmission || !userSubmission.timestarted)"> | ||||
|                                         {{ 'addon.mod_assign.beginassignment' | translate }} | ||||
|                                     </ng-container> | ||||
|                                 </ion-button> | ||||
|                                 <!-- If reopened, show addfromprevious and addnewattempt. --> | ||||
|                                 <ng-container *ngIf="!hasOffline && userSubmission?.status == statusReopened"> | ||||
| @ -122,9 +168,8 @@ | ||||
|                                     </ion-button> | ||||
|                                 </ng-container> | ||||
|                                 <!-- Else show editsubmission. --> | ||||
|                                 <ion-button expand="block" class="ion-text-wrap" *ngIf="!hasOffline && | ||||
|                                     userSubmission && userSubmission!.status && | ||||
|                                     userSubmission!.status != statusNew && | ||||
|                                 <ion-button expand="block" class="ion-text-wrap" *ngIf="!hasOffline && userSubmission && | ||||
|                                     userSubmission!.status && userSubmission!.status != statusNew && | ||||
|                                     userSubmission!.status != statusReopened" (click)="goToEdit()"> | ||||
|                                     {{ 'addon.mod_assign.editsubmission' | translate }} | ||||
|                                 </ion-button> | ||||
| @ -191,23 +236,6 @@ | ||||
|                             </ion-item> | ||||
|                         </ng-container> | ||||
|                     </ng-container> | ||||
| 
 | ||||
|                     <!-- Submission is locked. --> | ||||
|                     <ion-item class="ion-text-wrap" *ngIf="lastAttempt?.locked"> | ||||
|                         <ion-label> | ||||
|                             <h2>{{ 'addon.mod_assign.submissionslocked' | translate }}</h2> | ||||
|                         </ion-label> | ||||
|                     </ion-item> | ||||
| 
 | ||||
|                     <!-- Editing status. --> | ||||
|                     <ion-item class="ion-text-wrap" *ngIf="lastAttempt && isSubmittedForGrading && lastAttempt!.caneditowner !== undefined" | ||||
|                         [ngClass]="{submissioneditable: lastAttempt!.caneditowner, submissionnoteditable: !lastAttempt!.caneditowner}"> | ||||
|                         <ion-label> | ||||
|                             <h2>{{ 'addon.mod_assign.editingstatus' | translate }}</h2> | ||||
|                             <p *ngIf="lastAttempt!.caneditowner">{{ 'addon.mod_assign.submissioneditable' | translate }}</p> | ||||
|                             <p *ngIf="!lastAttempt!.caneditowner">{{ 'addon.mod_assign.submissionnoteditable' | translate }}</p> | ||||
|                         </ion-label> | ||||
|                     </ion-item> | ||||
|                 </ng-template> | ||||
|             </core-tab> | ||||
| 
 | ||||
|  | ||||
| @ -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); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -58,6 +58,7 @@ import { CoreSync } from '@services/sync'; | ||||
| import { AddonModAssignSubmissionPluginComponent } from '../submission-plugin/submission-plugin'; | ||||
| import { AddonModAssignModuleHandlerService } from '../../services/handlers/module'; | ||||
| import { CanLeave } from '@guards/can-leave'; | ||||
| import { CoreTime } from '@singletons/time'; | ||||
| 
 | ||||
| /** | ||||
|  * Component that displays an assignment submission. | ||||
| @ -106,6 +107,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 +128,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 +203,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 +216,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; | ||||
| 
 | ||||
|             this.timeRemaining = Translate.instant( | ||||
|                 'addon.mod_assign.' + (onTime ? earlyString : lateString), | ||||
|                 { $a: CoreTime.formatTime(Math.abs(lateCalculation - lateThreshold)) }, | ||||
|             ); | ||||
|             this.timeRemainingClass = onTime ? 'earlysubmission' : 'latesubmission'; | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         // Not submitted.
 | ||||
|         if (!this.userSubmission || this.userSubmission.status != AddonModAssignSubmissionStatusValues.SUBMITTED) { | ||||
| 
 | ||||
|             if (response.lastattempt?.submissionsenabled || response.gradingsummary?.submissionsenabled) { | ||||
|         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.overdue', | ||||
|                     { $a: CoreTimeUtils.formatDuration(-timeRemaining, 3) }, | ||||
|                 'addon.mod_assign.' + (submissionsEnabled ? 'overdue' : 'duedatereached'), | ||||
|                 { $a: CoreTime.formatTime(time - this.assign.duedate) }, | ||||
|             ); | ||||
|             this.timeRemainingClass = 'overdue'; | ||||
|             this.timeLimitFinished = true; | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|             this.timeRemaining = Translate.instant('addon.mod_assign.duedatereached'); | ||||
|             this.timeRemainingClass = ''; | ||||
|         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; | ||||
|         } | ||||
| 
 | ||||
|         const timeSubmittedDiff = this.userSubmission.timemodified - dueDate; | ||||
|         if (timeSubmittedDiff > 0) { | ||||
|             this.timeRemaining = Translate.instant( | ||||
|                 'addon.mod_assign.submittedlate', | ||||
|                 { $a: CoreTimeUtils.formatDuration(timeSubmittedDiff, 2) }, | ||||
|             ); | ||||
|             this.timeRemainingClass = 'latesubmission'; | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.timeRemaining = Translate.instant( | ||||
|             'addon.mod_assign.submittedearly', | ||||
|             { $a: CoreTimeUtils.formatDuration(-timeSubmittedDiff, 2) }, | ||||
|         ); | ||||
|         this.timeRemainingClass = 'earlysubmission'; | ||||
|         // Assignment is not overdue, and no submission has been made. Just display the due date.
 | ||||
|         this.timeRemaining = CoreTime.formatTime(this.assign.duedate - time); | ||||
|         this.timeRemainingClass = 'timeremaining'; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -292,7 +300,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 +327,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 +360,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<void> { | ||||
|         if (!afterCopyPrevious && this.assign?.timelimit && (!this.userSubmission || !this.userSubmission.timestarted)) { | ||||
|             try { | ||||
|                 await CoreDomUtils.showConfirm( | ||||
|                     Translate.instant('addon.mod_assign.confirmstart', { | ||||
|                         $a: CoreTime.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 +1199,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. | ||||
|      */ | ||||
|  | ||||
| @ -9,14 +9,18 @@ | ||||
|     "allowsubmissionsfromdatesummary": "This assignment will accept submissions from <strong>{{$a}}</strong>", | ||||
|     "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.", | ||||
|  | ||||
| @ -20,6 +20,25 @@ | ||||
|     <core-loading [hideUntil]="loaded"> | ||||
|         <ion-list *ngIf="userSubmission && userSubmission.plugins && userSubmission.plugins.length"> | ||||
|             <!-- @todo: plagiarism_print_disclosure --> | ||||
|             <core-timer *ngIf="timeLimitEndTime > 0" [endTime]="timeLimitEndTime" (finished)="timeUp()" timeUpText="00:00:00" | ||||
|                 [timerText]="'addon.mod_assign.assigntimeleft' | translate" [align]="'center'" [timeLeftClassThreshold]="-1" | ||||
|                 [underTimeClassThresholds]="[300, 900]" class="ion-margin-horizontal"> | ||||
|             </core-timer> | ||||
| 
 | ||||
|             <!-- Assign activity instructions and attachments if needed. --> | ||||
|             <ion-item class="ion-text-wrap" *ngIf="activityInstructions"> | ||||
|                 <ion-label> | ||||
|                     <core-format-text [text]="activityInstructions" [component]="component" [componentId]="moduleId" contextLevel="module" | ||||
|                         [contextInstanceId]="moduleId" [courseId]="courseId" [maxHeight]="120"> | ||||
|                     </core-format-text> | ||||
|                 </ion-label> | ||||
|             </ion-item> | ||||
| 
 | ||||
|             <ng-container *ngIf="assign?.submissionattachments"> | ||||
|                 <core-file *ngFor="let file of introAttachments" [file]="file" [component]="component" [componentId]="moduleId"> | ||||
|                 </core-file> | ||||
|             </ng-container> | ||||
| 
 | ||||
|             <form name="addon-mod_assign-edit-form" #editSubmissionForm> | ||||
|                 <!-- Submission statement. --> | ||||
|                 <ion-item class="ion-text-wrap" *ngIf="submissionStatement"> | ||||
|  | ||||
							
								
								
									
										17
									
								
								src/addons/mod/assign/pages/edit/edit.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/addons/mod/assign/pages/edit/edit.scss
									
									
									
									
									
										Normal file
									
								
							| @ -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); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -32,11 +32,13 @@ import { | ||||
|     AddonModAssignSubmissionStatusOptions, | ||||
|     AddonModAssignGetSubmissionStatusWSResponse, | ||||
|     AddonModAssignSavePluginData, | ||||
|     AddonModAssignSubmissionStatusValues, | ||||
| } from '../../services/assign'; | ||||
| import { AddonModAssignHelper } from '../../services/assign-helper'; | ||||
| import { AddonModAssignOffline } from '../../services/assign-offline'; | ||||
| import { AddonModAssignSync } from '../../services/assign-sync'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { CoreWSExternalFile } from '@services/ws'; | ||||
| 
 | ||||
| /** | ||||
|  * Page that allows adding or editing an assigment submission. | ||||
| @ -44,6 +46,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 +61,10 @@ 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.
 | ||||
|     activityInstructions?: string; // Activity instructions.
 | ||||
|     introAttachments?: CoreWSExternalFile[]; // Intro attachments.
 | ||||
|     component = AddonModAssignProvider.COMPONENT; | ||||
| 
 | ||||
|     protected userId: number; // User doing the submission.
 | ||||
|     protected isBlind = false; // Whether blind is used.
 | ||||
| @ -179,6 +186,22 @@ export class AddonModAssignEditPage implements OnInit, OnDestroy, CanLeave { | ||||
|                 throw new CoreError(Translate.instant('core.nopermissions', { $a: this.editText })); | ||||
|             } | ||||
| 
 | ||||
|             submissionStatus = await this.startSubmissionIfNeeded(submissionStatus, options); | ||||
| 
 | ||||
|             if (submissionStatus.assignmentdata?.activity) { | ||||
|                 // There are activity instructions. Make sure to display it with filters applied.
 | ||||
|                 const filteredSubmissionStatus = options.filter ? | ||||
|                     submissionStatus : | ||||
|                     await AddonModAssign.getSubmissionStatus(this.assign.id, { | ||||
|                         ...options, | ||||
|                         filter: true, | ||||
|                     }); | ||||
| 
 | ||||
|                 this.activityInstructions = filteredSubmissionStatus.assignmentdata?.activity; | ||||
|             } | ||||
| 
 | ||||
|             this.introAttachments = submissionStatus.assignmentdata?.attachments?.intro ?? this.assign.introattachments; | ||||
| 
 | ||||
|             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 +210,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 +233,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<AddonModAssignGetSubmissionStatusWSResponse> { | ||||
|         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 +460,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. | ||||
|      */ | ||||
|  | ||||
| @ -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. | ||||
|      * | ||||
|  | ||||
| @ -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<void> { | ||||
|         const site = await CoreSites.getSite(siteId); | ||||
| 
 | ||||
|         const params: AddonModAssignStartSubmissionWSParams = { | ||||
|             assignid: assignId, | ||||
|         }; | ||||
| 
 | ||||
|         const result = await site.write<AddonModAssignStartSubmissionWSResponse>('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_ | ||||
|  | ||||
| @ -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 }); | ||||
| @ -100,19 +101,26 @@ export class AddonModAssignPrefetchHandlerService extends CoreCourseActivityPref | ||||
|                     await AddonModAssignHelper.getSubmissionsUserData(assign, submissionData.submissions, 0, { siteId }); | ||||
| 
 | ||||
|                 // Get all the files in the submissions.
 | ||||
|                 const promises = submissions.map((submission) => | ||||
|                     this.getSubmissionFiles(assign, submission.submitid!, !!submission.blindid, siteId).then((submissionFiles) => { | ||||
|                         files = files.concat(submissionFiles); | ||||
|                 const promises = submissions.map(async (submission) => { | ||||
|                     try { | ||||
|                         const submissionFiles = await this.getSubmissionFiles( | ||||
|                             assign, | ||||
|                             submission.submitid!, | ||||
|                             !!submission.blindid, | ||||
|                             true, | ||||
|                             siteId, | ||||
|                         ); | ||||
| 
 | ||||
|                         return; | ||||
|                     }).catch((error) => { | ||||
|                         files = files.concat(submissionFiles); | ||||
|                     } catch (error) { | ||||
|                         if (error && error.errorcode == 'nopermission') { | ||||
|                             // The user does not have persmission to view this submission, ignore it.
 | ||||
|                             return; | ||||
|                         } | ||||
| 
 | ||||
|                         throw error; | ||||
|                     })); | ||||
|                     } | ||||
|                 }); | ||||
| 
 | ||||
|                 await Promise.all(promises); | ||||
|             } else { | ||||
| @ -120,7 +128,7 @@ export class AddonModAssignPrefetchHandlerService extends CoreCourseActivityPref | ||||
|                 const userId = CoreSites.getCurrentSiteUserId(); | ||||
|                 const blindMarking = !!assign.blindmarking && !assign.revealidentities; | ||||
| 
 | ||||
|                 const submissionFiles = await this.getSubmissionFiles(assign, userId, blindMarking, siteId); | ||||
|                 const submissionFiles = await this.getSubmissionFiles(assign, userId, blindMarking, false, siteId); | ||||
|                 files = files.concat(submissionFiles); | ||||
|             } | ||||
| 
 | ||||
| @ -137,6 +145,7 @@ export class AddonModAssignPrefetchHandlerService extends CoreCourseActivityPref | ||||
|      * @param assign Assign. | ||||
|      * @param submitId User ID of the submission to get. | ||||
|      * @param blindMarking True if blind marking, false otherwise. | ||||
|      * @param canViewAllSubmissions Whether the user can view all submissions. | ||||
|      * @param siteId Site ID. If not defined, current site. | ||||
|      * @return Promise resolved with array of files. | ||||
|      */ | ||||
| @ -144,6 +153,7 @@ export class AddonModAssignPrefetchHandlerService extends CoreCourseActivityPref | ||||
|         assign: AddonModAssignAssign, | ||||
|         submitId: number, | ||||
|         blindMarking: boolean, | ||||
|         canViewAllSubmissions: boolean, | ||||
|         siteId?: string, | ||||
|     ): Promise<CoreWSFile[]> { | ||||
| 
 | ||||
| @ -154,8 +164,15 @@ export class AddonModAssignPrefetchHandlerService extends CoreCourseActivityPref | ||||
|         }); | ||||
|         const userSubmission = AddonModAssign.getSubmissionObjectFromAttempt(assign, submissionStatus.lastattempt); | ||||
| 
 | ||||
|         // Get intro and activity files from the submission status if it's a student.
 | ||||
|         // It's ok if they were already obtained from the assignment instance, they won't be downloaded twice.
 | ||||
|         const files = canViewAllSubmissions ? | ||||
|             [] : | ||||
|             (submissionStatus.assignmentdata?.attachments?.intro || []) | ||||
|                 .concat(submissionStatus.assignmentdata?.attachments?.activity || []); | ||||
| 
 | ||||
|         if (!submissionStatus.lastattempt || !userSubmission) { | ||||
|             return []; | ||||
|             return files; | ||||
|         } | ||||
| 
 | ||||
|         const promises: Promise<CoreWSFile[]>[] = []; | ||||
| @ -176,7 +193,7 @@ export class AddonModAssignPrefetchHandlerService extends CoreCourseActivityPref | ||||
| 
 | ||||
|         const filesLists = await Promise.all(promises); | ||||
| 
 | ||||
|         return [].concat.apply([], filesLists); | ||||
|         return files.concat.apply(files, filesLists); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | ||||
| @ -18,6 +18,7 @@ import { CoreCourseContentsPage } from '@features/course/pages/contents/contents | ||||
| import { IonContent } from '@ionic/angular'; | ||||
| import { CoreNavigator } from '@services/navigator'; | ||||
| import { CoreTimeUtils } from '@services/utils/time'; | ||||
| import { CoreTime } from '@singletons/time'; | ||||
| import { AddonModChat, AddonModChatChat, AddonModChatProvider } from '../../services/chat'; | ||||
| import { AddonModChatModuleHandlerService } from '../../services/handlers/module'; | ||||
| 
 | ||||
| @ -67,7 +68,7 @@ export class AddonModChatIndexComponent extends CoreCourseModuleMainActivityComp | ||||
|         if (this.chat.chattime && this.chat.schedule && span > 0) { | ||||
|             this.chatInfo = { | ||||
|                 date: CoreTimeUtils.userDate(this.chat.chattime * 1000), | ||||
|                 fromnow: CoreTimeUtils.formatTime(span), | ||||
|                 fromnow: CoreTime.formatTime(span), | ||||
|             }; | ||||
|         } else { | ||||
|             this.chatInfo = undefined; | ||||
|  | ||||
| @ -16,7 +16,6 @@ import { Injectable } from '@angular/core'; | ||||
| 
 | ||||
| import { CoreSites, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@services/sites'; | ||||
| import { CoreWSExternalWarning, CoreWSExternalFile, CoreWSFile } from '@services/ws'; | ||||
| import { CoreTimeUtils } from '@services/utils/time'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; | ||||
| import { CoreCourseLogHelper } from '@features/course/services/log-helper'; | ||||
| @ -27,6 +26,7 @@ import { makeSingleton, Translate } from '@singletons/index'; | ||||
| import { CoreWSError } from '@classes/errors/wserror'; | ||||
| import { CoreError } from '@classes/errors/error'; | ||||
| import { AddonModH5PActivityAutoSyncData, AddonModH5PActivitySyncProvider } from './h5pactivity-sync'; | ||||
| import { CoreTime } from '@singletons/time'; | ||||
| 
 | ||||
| const ROOT_CACHE_KEY = 'mmaModH5PActivity:'; | ||||
| 
 | ||||
| @ -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 = CoreTime.formatTime(attempt.duration, 3); | ||||
|             formattedAttempt.durationCompact = CoreTime.formatTimeShort(attempt.duration); | ||||
|         } | ||||
| 
 | ||||
|         return formattedAttempt; | ||||
|  | ||||
| @ -25,7 +25,6 @@ import { CoreNavigator } from '@services/navigator'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CoreForms } from '@singletons/form'; | ||||
| import { CoreTextUtils } from '@services/utils/text'; | ||||
| import { CoreTimeUtils } from '@services/utils/time'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { CoreEventObserver, CoreEvents } from '@singletons/events'; | ||||
| import { AddonModLessonRetakeFinishedInSyncDBRecord } from '../../services/database/lesson'; | ||||
| @ -47,6 +46,7 @@ import { | ||||
|     AddonModLessonSyncResult, | ||||
| } from '../../services/lesson-sync'; | ||||
| import { AddonModLessonModuleHandlerService } from '../../services/handlers/module'; | ||||
| import { CoreTime } from '@singletons/time'; | ||||
| 
 | ||||
| /** | ||||
|  * Component that displays a lesson entry page. | ||||
| @ -505,15 +505,15 @@ export class AddonModLessonIndexComponent extends CoreCourseModuleMainActivityCo | ||||
|         // Format times and grades.
 | ||||
|         if (formattedData.avetime != null && formattedData.numofattempts) { | ||||
|             formattedData.avetime = Math.floor(formattedData.avetime / formattedData.numofattempts); | ||||
|             this.avetimeReadable = CoreTimeUtils.formatTime(formattedData.avetime); | ||||
|             this.avetimeReadable = CoreTime.formatTime(formattedData.avetime); | ||||
|         } | ||||
| 
 | ||||
|         if (formattedData.hightime != null) { | ||||
|             this.hightimeReadable = CoreTimeUtils.formatTime(formattedData.hightime); | ||||
|             this.hightimeReadable = CoreTime.formatTime(formattedData.hightime); | ||||
|         } | ||||
| 
 | ||||
|         if (formattedData.lowtime != null) { | ||||
|             this.lowtimeReadable = CoreTimeUtils.formatTime(formattedData.lowtime); | ||||
|             this.lowtimeReadable = CoreTime.formatTime(formattedData.lowtime); | ||||
|         } | ||||
| 
 | ||||
|         if (formattedData.lessonscored) { | ||||
|  | ||||
| @ -30,7 +30,7 @@ | ||||
|         <div *ngIf="lesson" [ngClass]='{"addon-mod_lesson-slideshow": lesson.slideshow}' | ||||
|             [ngStyle]="{'width': lessonWidth, 'height': lessonHeight}"> | ||||
| 
 | ||||
|             <core-timer *ngIf="endTime" [endTime]="endTime" (finished)="timeUp()" | ||||
|             <core-timer *ngIf="endTime" [endTime]="endTime" (finished)="timeUp()" [timeLeftClassThreshold]="-1" | ||||
|                 [timerText]="'addon.mod_lesson.timeremaining' | translate"> | ||||
|             </core-timer> | ||||
| 
 | ||||
|  | ||||
| @ -34,8 +34,8 @@ import { | ||||
|     AddonModLessonUserAttemptAnswerPageWSData, | ||||
| } from '../../services/lesson'; | ||||
| import { AddonModLessonAnswerData, AddonModLessonHelper } from '../../services/lesson-helper'; | ||||
| import { CoreTimeUtils } from '@services/utils/time'; | ||||
| import { CoreCourse } from '@features/course/services/course'; | ||||
| import { CoreTime } from '@singletons/time'; | ||||
| 
 | ||||
| /** | ||||
|  * Page that displays a retake made by a certain user. | ||||
| @ -222,7 +222,7 @@ export class AddonModLessonUserRetakePage implements OnInit { | ||||
|         if (formattedData.userstats.gradeinfo) { | ||||
|             // Completed.
 | ||||
|             formattedData.userstats.grade = CoreTextUtils.roundToDecimals(formattedData.userstats.grade, 2); | ||||
|             this.timeTakenReadable = CoreTimeUtils.formatTime(formattedData.userstats.timetotake); | ||||
|             this.timeTakenReadable = CoreTime.formatTime(formattedData.userstats.timetotake); | ||||
|         } | ||||
| 
 | ||||
|         // Format pages data.
 | ||||
|  | ||||
| @ -26,6 +26,7 @@ import { | ||||
|     AddonModLessonGetPageDataWSResponse, | ||||
|     AddonModLessonProvider, | ||||
| } from './lesson'; | ||||
| import { CoreTime } from '@singletons/time'; | ||||
| 
 | ||||
| /** | ||||
|  * Helper service that provides some features for quiz. | ||||
| @ -531,7 +532,7 @@ export class AddonModLessonHelperProvider { | ||||
|             } | ||||
|             data.timestart = CoreTimeUtils.userDate(retake.timestart * 1000); | ||||
|             if (includeDuration) { | ||||
|                 data.duration = CoreTimeUtils.formatTime(retake.timeend - retake.timestart); | ||||
|                 data.duration = CoreTime.formatTime(retake.timeend - retake.timestart); | ||||
|             } | ||||
|         } else { | ||||
|             // The user has not completed the retake.
 | ||||
|  | ||||
| @ -16,7 +16,7 @@ import { Component, Input, OnInit } from '@angular/core'; | ||||
| import { FormGroup } from '@angular/forms'; | ||||
| 
 | ||||
| import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '@addons/mod/quiz/services/quiz'; | ||||
| import { CoreTimeUtils } from '@services/utils/time'; | ||||
| import { CoreTime } from '@singletons/time'; | ||||
| 
 | ||||
| /** | ||||
|  * Component to render the preflight for time limit. | ||||
| @ -41,7 +41,7 @@ export class AddonModQuizAccessTimeLimitComponent implements OnInit { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.readableTimeLimit = CoreTimeUtils.formatTime(this.quiz?.timelimit); | ||||
|         this.readableTimeLimit = CoreTime.formatTime(this.quiz?.timelimit); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -24,7 +24,6 @@ import { CoreNavigator } from '@services/navigator'; | ||||
| import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; | ||||
| import { CoreSync } from '@services/sync'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CoreTimeUtils } from '@services/utils/time'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { ModalController, Translate } from '@singletons'; | ||||
| import { CoreEvents } from '@singletons/events'; | ||||
| @ -47,6 +46,7 @@ import { AddonModQuizSync } from '../../services/quiz-sync'; | ||||
| import { CanLeave } from '@guards/can-leave'; | ||||
| import { CoreForms } from '@singletons/form'; | ||||
| import { CoreDom } from '@singletons/dom'; | ||||
| import { CoreTime } from '@singletons/time'; | ||||
| 
 | ||||
| /** | ||||
|  * Page that allows attempting a quiz. | ||||
| @ -352,7 +352,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave { | ||||
|             } | ||||
| 
 | ||||
|             if (this.quiz!.timelimit && this.quiz!.timelimit > 0) { | ||||
|                 this.readableTimeLimit = CoreTimeUtils.formatTime(this.quiz.timelimit); | ||||
|                 this.readableTimeLimit = CoreTime.formatTime(this.quiz.timelimit); | ||||
|             } | ||||
| 
 | ||||
|             // Get access information for the quiz.
 | ||||
|  | ||||
| @ -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); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -19,10 +19,10 @@ import { CoreQuestionHelper } from '@features/question/services/question-helper' | ||||
| import { IonContent, IonRefresher } from '@ionic/angular'; | ||||
| import { CoreNavigator } from '@services/navigator'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CoreTimeUtils } from '@services/utils/time'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { Translate } from '@singletons'; | ||||
| import { CoreDom } from '@singletons/dom'; | ||||
| import { CoreTime } from '@singletons/time'; | ||||
| import { | ||||
|     AddonModQuizNavigationModalComponent, | ||||
|     AddonModQuizNavigationModalReturn, | ||||
| @ -276,11 +276,11 @@ export class AddonModQuizReviewPage implements OnInit { | ||||
|         const timeTaken = (this.attempt.timefinish || 0) - (this.attempt.timestart || 0); | ||||
|         if (timeTaken > 0) { | ||||
|             // Format time taken.
 | ||||
|             this.timeTaken = CoreTimeUtils.formatTime(timeTaken); | ||||
|             this.timeTaken = CoreTime.formatTime(timeTaken); | ||||
| 
 | ||||
|             // Calculate overdue time.
 | ||||
|             if (this.quiz.timelimit && timeTaken > this.quiz.timelimit + 60) { | ||||
|                 this.overTime = CoreTimeUtils.formatTime(timeTaken - this.quiz.timelimit); | ||||
|                 this.overTime = CoreTime.formatTime(timeTaken - this.quiz.timelimit); | ||||
|             } | ||||
|         } else { | ||||
|             this.timeTaken = undefined; | ||||
|  | ||||
| @ -1,10 +1,21 @@ | ||||
| <ion-item class="core-timer" role="timer" [ngClass]="{'ion-text-center': align == 'center', 'ion-text-end': align == 'right'}"> | ||||
| <ion-item *ngIf="mode !== modeBasic" class="core-timer" role="timer" | ||||
|     [ngClass]="{'ion-text-center': align == 'center', 'ion-text-end': align == 'right'}"> | ||||
|     <ion-icon name="fas-clock" slot="start" aria-hidden="true"></ion-icon> | ||||
|     <ion-label> | ||||
|         <span *ngIf="timeLeft && timeLeft > 0 && timerText" class="core-timer-text">{{ timerText }}</span> | ||||
|         <span *ngIf="timeLeft && timeLeft > 0" class="core-timer-time-left">{{ timeLeft | coreSecondsToHMS }}</span> | ||||
|         <span class="core-timesup" *ngIf="timeLeft !== undefined && timeLeft <= 0"> | ||||
|             {{ 'core.timesup' | translate }} | ||||
|         </span> | ||||
|         <ng-container *ngTemplateOutlet="timerTemplate"></ng-container> | ||||
|     </ion-label> | ||||
| </ion-item> | ||||
| 
 | ||||
| <div *ngIf="mode === modeBasic" class="core-timer ion-padding" role="timer" | ||||
|     [ngClass]="{'ion-text-center': align == 'center', 'ion-text-end': align == 'right'}"> | ||||
|     <ng-container *ngTemplateOutlet="timerTemplate"></ng-container> | ||||
| </div> | ||||
| 
 | ||||
| <ng-template #timerTemplate> | ||||
|     <span *ngIf="timerText" class="core-timer-text">{{ timerText }}</span> | ||||
|     <span *ngIf="timeLeft && timeLeft > 0" class="core-timer-time-left">{{ timeLeft | coreSecondsToHMS }}</span> | ||||
|     <span class="core-timesup" *ngIf="timeLeft !== undefined && timeLeft <= 0"> | ||||
|         <ng-container *ngIf="timeUpText">{{ timeUpText }}</ng-container> | ||||
|         <ng-container *ngIf="!timeUpText">{{ 'core.timesup' | translate }}</ng-container> | ||||
|     </span> | ||||
| </ng-template> | ||||
|  | ||||
| @ -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); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -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<void>(); // 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', | ||||
| } | ||||
|  | ||||
| @ -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<void> { | ||||
|     await Promise.all([ | ||||
| @ -27,5 +28,6 @@ export default async function(): Promise<void> { | ||||
|         CoreLang.initialize(), | ||||
|         CoreLocalNotifications.initialize(), | ||||
|         CoreUpdateManager.initialize(), | ||||
|         CoreTimeUtils.initialize(), | ||||
|     ]); | ||||
| } | ||||
|  | ||||
| @ -14,7 +14,7 @@ | ||||
| 
 | ||||
| import { Pipe, PipeTransform } from '@angular/core'; | ||||
| import { CoreLogger } from '@singletons/logger'; | ||||
| import { CoreTimeUtils } from '@services/utils/time'; | ||||
| import { CoreTime } from '@singletons/time'; | ||||
| 
 | ||||
| /** | ||||
|  * Filter to turn a number of seconds to a duration. E.g. 60 -> 1 minute. | ||||
| @ -48,7 +48,7 @@ export class CoreDurationPipe implements PipeTransform { | ||||
|             seconds = numberSeconds; | ||||
|         } | ||||
| 
 | ||||
|         return CoreTimeUtils.formatTime(seconds); | ||||
|         return CoreTime.formatTime(seconds); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -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<HTMLIonToastElement> { | ||||
|         // 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. | ||||
|      * | ||||
|  | ||||
| @ -15,8 +15,8 @@ | ||||
| import { Injectable } from '@angular/core'; | ||||
| 
 | ||||
| import moment, { LongDateFormatKey } from 'moment'; | ||||
| import { CoreConstants } from '@/core/constants'; | ||||
| import { makeSingleton, Translate } from '@singletons'; | ||||
| import { CoreTime } from '@singletons/time'; | ||||
| 
 | ||||
| /* | ||||
|  * "Utils" service with helper functions for date and time. | ||||
| @ -68,6 +68,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,69 +156,26 @@ 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. | ||||
|      * @deprecated since app 4.0. Use CoreTime.formatTime instead. | ||||
|      */ | ||||
|     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); | ||||
| 
 | ||||
|         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; | ||||
|     formatTime(seconds: number, precision = 2): string { | ||||
|         return CoreTime.formatTime(seconds, precision); | ||||
|     } | ||||
| 
 | ||||
|         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'); | ||||
|     /** | ||||
|      * Converts a number of seconds into a short human readable format: minutes and seconds, in fromat: 3' 27''. | ||||
|      * | ||||
|      * @param seconds Seconds | ||||
|      * @return Short human readable text. | ||||
|      * @deprecated since app 4.0. Use CoreTime.formatTimeShort instead. | ||||
|      */ | ||||
|     formatTimeShort(duration: number): string { | ||||
|         return CoreTime.formatTimeShort(duration); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -213,35 +184,10 @@ export class CoreTimeUtilsProvider { | ||||
|      * @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 app 4.0. Use CoreTime.formatTime instead. | ||||
|      */ | ||||
|     formatDuration(duration: number, precision?: number): string { | ||||
|         precision = precision || 5; | ||||
| 
 | ||||
|         const eventDuration = moment.duration(duration, 'seconds'); | ||||
|         let durationString = ''; | ||||
| 
 | ||||
|         if (precision && eventDuration.years() > 0) { | ||||
|             durationString += ' ' + moment.duration(eventDuration.years(), 'years').humanize(); | ||||
|             precision--; | ||||
|         } | ||||
|         if (precision && eventDuration.months() > 0) { | ||||
|             durationString += ' ' + moment.duration(eventDuration.months(), 'months').humanize(); | ||||
|             precision--; | ||||
|         } | ||||
|         if (precision && eventDuration.days() > 0) { | ||||
|             durationString += ' ' + moment.duration(eventDuration.days(), 'days').humanize(); | ||||
|             precision--; | ||||
|         } | ||||
|         if (precision && eventDuration.hours() > 0) { | ||||
|             durationString += ' ' + moment.duration(eventDuration.hours(), 'hours').humanize(); | ||||
|             precision--; | ||||
|         } | ||||
|         if (precision && eventDuration.minutes() > 0) { | ||||
|             durationString += ' ' + moment.duration(eventDuration.minutes(), 'minutes').humanize(); | ||||
|             precision--; | ||||
|         } | ||||
| 
 | ||||
|         return durationString.trim(); | ||||
|         return CoreTime.formatTime(duration, precision); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -249,21 +195,10 @@ export class CoreTimeUtilsProvider { | ||||
|      * | ||||
|      * @param duration Duration in seconds | ||||
|      * @return Duration in a short human readable format. | ||||
|      * @deprecated since app 4.0. Use CoreTime.formatTimeShort instead. | ||||
|      */ | ||||
|     formatDurationShort(duration: number): string { | ||||
|         const minutes = Math.floor(duration / 60); | ||||
|         const seconds = duration - minutes * 60; | ||||
|         const durations = <string[]>[]; | ||||
| 
 | ||||
|         if (minutes > 0) { | ||||
|             durations.push(minutes + '\''); | ||||
|         } | ||||
| 
 | ||||
|         if (seconds > 0 || minutes === 0) { | ||||
|             durations.push(seconds + '\'\''); | ||||
|         } | ||||
| 
 | ||||
|         return durations.join(' '); | ||||
|         return CoreTime.formatTimeShort(duration); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | ||||
| @ -12,11 +12,76 @@ | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import moment from 'moment'; | ||||
| 
 | ||||
| /** | ||||
|  * Singleton with helper functions for time operations. | ||||
|  */ | ||||
| export class CoreTime { | ||||
| 
 | ||||
|     /** | ||||
|      * 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. | ||||
|      */ | ||||
|     static formatTime(seconds: number, precision = 2): string { | ||||
|         precision = precision || 6; // Use max precision if 0 is passed.
 | ||||
| 
 | ||||
|         const eventDuration = moment.duration(Math.abs(seconds), 'seconds'); | ||||
|         let durationString = ''; | ||||
| 
 | ||||
|         if (precision && eventDuration.years() > 0) { | ||||
|             durationString += ' ' + moment.duration(eventDuration.years(), 'years').humanize(); | ||||
|             precision--; | ||||
|         } | ||||
|         if (precision && eventDuration.months() > 0) { | ||||
|             durationString += ' ' + moment.duration(eventDuration.months(), 'months').humanize(); | ||||
|             precision--; | ||||
|         } | ||||
|         if (precision && eventDuration.days() > 0) { | ||||
|             durationString += ' ' + moment.duration(eventDuration.days(), 'days').humanize(); | ||||
|             precision--; | ||||
|         } | ||||
|         if (precision && eventDuration.hours() > 0) { | ||||
|             durationString += ' ' + moment.duration(eventDuration.hours(), 'hours').humanize(); | ||||
|             precision--; | ||||
|         } | ||||
|         if (precision && eventDuration.minutes() > 0) { | ||||
|             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(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Converts a number of seconds into a short human readable format: minutes and seconds, in fromat: 3' 27''. | ||||
|      * | ||||
|      * @param seconds Seconds | ||||
|      * @return Short human readable text. | ||||
|      */ | ||||
|     static formatTimeShort(duration: number): string { | ||||
|         const minutes = Math.floor(duration / 60); | ||||
|         const seconds = duration - minutes * 60; | ||||
|         const durations = <string[]>[]; | ||||
| 
 | ||||
|         if (minutes > 0) { | ||||
|             durations.push(minutes + '\''); | ||||
|         } | ||||
| 
 | ||||
|         if (seconds > 0 || minutes === 0) { | ||||
|             durations.push(seconds + '\'\''); | ||||
|         } | ||||
| 
 | ||||
|         return durations.join(' '); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Wrap a function so that it is called only once. | ||||
|      * | ||||
|  | ||||
| @ -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; | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user