Merge pull request #3001 from dpalou/MOBILE-3909

Mobile 3909
main
Pau Ferrer Ocaña 2021-11-22 14:55:39 +01:00 committed by GitHub
commit 6703b89b94
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 1176 additions and 454 deletions

View File

@ -140,6 +140,7 @@
"addon.calendar.sunday": "calendar", "addon.calendar.sunday": "calendar",
"addon.calendar.thu": "calendar", "addon.calendar.thu": "calendar",
"addon.calendar.thursday": "calendar", "addon.calendar.thursday": "calendar",
"addon.calendar.timebefore": "local_moodlemobileapp",
"addon.calendar.today": "calendar", "addon.calendar.today": "calendar",
"addon.calendar.tomorrow": "calendar", "addon.calendar.tomorrow": "calendar",
"addon.calendar.tue": "calendar", "addon.calendar.tue": "calendar",
@ -153,6 +154,7 @@
"addon.calendar.typeopen": "calendar", "addon.calendar.typeopen": "calendar",
"addon.calendar.typesite": "calendar", "addon.calendar.typesite": "calendar",
"addon.calendar.typeuser": "calendar", "addon.calendar.typeuser": "calendar",
"addon.calendar.units": "qtype_numerical",
"addon.calendar.upcomingevents": "calendar", "addon.calendar.upcomingevents": "calendar",
"addon.calendar.userevents": "calendar", "addon.calendar.userevents": "calendar",
"addon.calendar.wed": "calendar", "addon.calendar.wed": "calendar",
@ -1562,6 +1564,7 @@
"core.courses.therearecourses": "moodle", "core.courses.therearecourses": "moodle",
"core.courses.totalcoursesearchresults": "local_moodlemobileapp", "core.courses.totalcoursesearchresults": "local_moodlemobileapp",
"core.currentdevice": "local_moodlemobileapp", "core.currentdevice": "local_moodlemobileapp",
"core.custom": "form",
"core.datastoredoffline": "local_moodlemobileapp", "core.datastoredoffline": "local_moodlemobileapp",
"core.date": "moodle", "core.date": "moodle",
"core.day": "moodle", "core.day": "moodle",
@ -1944,6 +1947,8 @@
"core.maxsizeandattachments": "moodle", "core.maxsizeandattachments": "moodle",
"core.min": "moodle", "core.min": "moodle",
"core.mins": "moodle", "core.mins": "moodle",
"core.minute": "moodle",
"core.minutes": "moodle",
"core.misc": "admin", "core.misc": "admin",
"core.mod_assign": "assign/pluginname", "core.mod_assign": "assign/pluginname",
"core.mod_assignment": "assignment/pluginname", "core.mod_assignment": "assignment/pluginname",
@ -2284,6 +2289,8 @@
"core.viewprofile": "moodle", "core.viewprofile": "moodle",
"core.warningofflinedatadeleted": "local_moodlemobileapp", "core.warningofflinedatadeleted": "local_moodlemobileapp",
"core.warnopeninbrowser": "local_moodlemobileapp", "core.warnopeninbrowser": "local_moodlemobileapp",
"core.week": "moodle",
"core.weeks": "moodle",
"core.whatisyourage": "moodle", "core.whatisyourage": "moodle",
"core.wheredoyoulive": "moodle", "core.wheredoyoulive": "moodle",
"core.whoissiteadmin": "local_moodlemobileapp", "core.whoissiteadmin": "local_moodlemobileapp",

View File

@ -94,7 +94,7 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
this.obsDefaultTimeChange = CoreEvents.on(AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_CHANGED, () => { this.obsDefaultTimeChange = CoreEvents.on(AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_CHANGED, () => {
this.weeks.forEach((week) => { this.weeks.forEach((week) => {
week.days.forEach((day) => { week.days.forEach((day) => {
AddonCalendar.scheduleEventsNotifications(day.eventsFormated!); AddonCalendar.scheduleEventsNotifications(day.eventsFormated || []);
}); });
}); });
}, this.currentSiteId); }, this.currentSiteId);
@ -150,7 +150,7 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
if (this.weeks) { if (this.weeks) {
// Check if there's any change in the filter object. // Check if there's any change in the filter object.
const changes = this.differ.diff(this.filter!); const changes = this.differ.diff(this.filter || {});
if (changes) { if (changes) {
this.filterEvents(); this.filterEvents();
} }
@ -173,8 +173,8 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
this.offlineEvents = AddonCalendarHelper.classifyIntoMonths(events); this.offlineEvents = AddonCalendarHelper.classifyIntoMonths(events);
// Get the IDs of events edited in offline. // Get the IDs of events edited in offline.
const filtered = events.filter((event) => event.id! > 0); const filtered = events.filter((event) => event.id > 0);
this.offlineEditedEventsIds = filtered.map((event) => event.id!); this.offlineEditedEventsIds = filtered.map((event) => event.id);
return; return;
})); }));
@ -261,7 +261,7 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
isPast = day.ispast; isPast = day.ispast;
if (day.istoday) { if (day.istoday) {
day.eventsFormated!.forEach((event) => { day.eventsFormated?.forEach((event) => {
event.ispast = this.isEventPast(event); event.ispast = this.isEventPast(event);
}); });
} }
@ -306,8 +306,8 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
this.weeks.forEach((week) => { this.weeks.forEach((week) => {
week.days.forEach((day) => { week.days.forEach((day) => {
day.filteredEvents = AddonCalendarHelper.getFilteredEvents( day.filteredEvents = AddonCalendarHelper.getFilteredEvents(
day.eventsFormated!, day.eventsFormated || [],
this.filter!, this.filter,
this.categories, this.categories,
); );
@ -466,14 +466,14 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
week.days.forEach((day) => { week.days.forEach((day) => {
// Schedule notifications for the events retrieved (only future events will be scheduled). // Schedule notifications for the events retrieved (only future events will be scheduled).
AddonCalendar.scheduleEventsNotifications(day.eventsFormated!); AddonCalendar.scheduleEventsNotifications(day.eventsFormated || []);
if (monthOfflineEvents || this.deletedEvents.length) { if (monthOfflineEvents || this.deletedEvents.length) {
// There is offline data, merge it. // There is offline data, merge it.
if (this.deletedEvents.length) { if (this.deletedEvents.length) {
// Mark as deleted the events that were deleted in offline. // Mark as deleted the events that were deleted in offline.
day.eventsFormated!.forEach((event) => { day.eventsFormated?.forEach((event) => {
event.deleted = this.deletedEvents.indexOf(event.id) != -1; event.deleted = this.deletedEvents.indexOf(event.id) != -1;
}); });
} }
@ -483,10 +483,10 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
day.events = day.events.filter((event) => this.offlineEditedEventsIds.indexOf(event.id) == -1); day.events = day.events.filter((event) => this.offlineEditedEventsIds.indexOf(event.id) == -1);
} }
if (monthOfflineEvents && monthOfflineEvents[day.mday]) { if (monthOfflineEvents && monthOfflineEvents[day.mday] && day.eventsFormated) {
// Add the offline events (either new or edited). // Add the offline events (either new or edited).
day.eventsFormated = day.eventsFormated =
AddonCalendarHelper.sortEvents(day.eventsFormated!.concat(monthOfflineEvents[day.mday])); AddonCalendarHelper.sortEvents(day.eventsFormated.concat(monthOfflineEvents[day.mday]));
} }
} }
}); });
@ -505,7 +505,7 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
this.weeks.forEach((week) => { this.weeks.forEach((week) => {
week.days.forEach((day) => { week.days.forEach((day) => {
const event = day.eventsFormated!.find((event) => event.id == eventId); const event = day.eventsFormated?.find((event) => event.id == eventId);
if (event) { if (event) {
event.deleted = false; event.deleted = false;
@ -521,7 +521,7 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
* @return True if it's in the past. * @return True if it's in the past.
*/ */
protected isEventPast(event: { timestart: number; timeduration: number}): boolean { protected isEventPast(event: { timestart: number; timeduration: number}): boolean {
return (event.timestart + event.timeduration) < this.currentTime!; return (event.timestart + event.timeduration) < (this.currentTime || CoreTimeUtils.timestamp());
} }
/** /**

View File

@ -19,12 +19,14 @@ import { CoreSharedModule } from '@/core/shared.module';
import { AddonCalendarCalendarComponent } from './calendar/calendar'; import { AddonCalendarCalendarComponent } from './calendar/calendar';
import { AddonCalendarUpcomingEventsComponent } from './upcoming-events/upcoming-events'; import { AddonCalendarUpcomingEventsComponent } from './upcoming-events/upcoming-events';
import { AddonCalendarFilterPopoverComponent } from './filter/filter'; import { AddonCalendarFilterPopoverComponent } from './filter/filter';
import { AddonCalendarReminderTimeModalComponent } from './reminder-time-modal/reminder-time-modal';
@NgModule({ @NgModule({
declarations: [ declarations: [
AddonCalendarCalendarComponent, AddonCalendarCalendarComponent,
AddonCalendarUpcomingEventsComponent, AddonCalendarUpcomingEventsComponent,
AddonCalendarFilterPopoverComponent, AddonCalendarFilterPopoverComponent,
AddonCalendarReminderTimeModalComponent,
], ],
imports: [ imports: [
CoreSharedModule, CoreSharedModule,
@ -35,6 +37,7 @@ import { AddonCalendarFilterPopoverComponent } from './filter/filter';
AddonCalendarCalendarComponent, AddonCalendarCalendarComponent,
AddonCalendarUpcomingEventsComponent, AddonCalendarUpcomingEventsComponent,
AddonCalendarFilterPopoverComponent, AddonCalendarFilterPopoverComponent,
AddonCalendarReminderTimeModalComponent,
], ],
}) })
export class AddonCalendarComponentsModule {} export class AddonCalendarComponentsModule {}

View File

@ -0,0 +1,59 @@
<ion-header>
<ion-toolbar>
<h2>{{ 'addon.calendar.reminders' | translate }}</h2>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon slot="icon-only" name="fas-times" aria-hidden="true"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-radio-group [(ngModel)]="radioValue" class="ion-text-wrap">
<!-- Preset options. -->
<ion-item *ngIf="allowDisable">
<ion-label>
<p>{{ 'core.settings.disabled' | translate }}</p>
</ion-label>
<ion-radio slot="end" value="disabled"></ion-radio>
</ion-item>
<ion-item *ngFor="let option of presetOptions">
<ion-label>
<p>{{ option.label }}</p>
</ion-label>
<ion-radio slot="end" [value]="option.radioValue"></ion-radio>
</ion-item>
<!-- Custom value. -->
<ion-item lines="none" class="ion-text-wrap">
<ion-label>
<p>{{ 'core.custom' | translate }}</p>
</ion-label>
<ion-radio slot="end" value="custom"></ion-radio>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label></ion-label>
<div class="flex-row">
<!-- Input to enter the value. -->
<ion-input type="number" name="customvalue" [(ngModel)]="customValue" [disabled]="radioValue != 'custom'"
placeholder="10">
</ion-input>
<!-- Units. -->
<label class="accesshide" for="reminderUnits">{{ 'addon.calendar.units' | translate }}</label>
<ion-select id="reminderUnits" name="customunits" [(ngModel)]="customUnits" interface="action-sheet"
[disabled]="radioValue != 'custom'" slot="end"
[interfaceOptions]="{header: 'addon.calendar.units' | translate}">
<ion-select-option *ngFor="let option of customUnitsOptions" [value]="option.value">
{{ option.label | translate }}
</ion-select-option>
</ion-select>
</div>
</ion-item>
</ion-radio-group>
<ion-button class="ion-margin" expand="block" (click)="saveReminder()" [disabled]="radioValue == 'custom' && !customValue">
{{ 'core.done' | translate }}
</ion-button>
</ion-content>

View File

@ -0,0 +1,156 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { AddonCalendar, AddonCalendarReminderUnits, AddonCalendarValueAndUnit } from '@addons/calendar/services/calendar';
import { Component, Input, OnInit } from '@angular/core';
import { CoreDomUtils } from '@services/utils/dom';
import { ModalController } from '@singletons';
/**
* Modal to choose a reminder time.
*/
@Component({
selector: 'addon-calendar-new-reminder-modal',
templateUrl: 'reminder-time-modal.html',
})
export class AddonCalendarReminderTimeModalComponent implements OnInit {
@Input() initialValue?: AddonCalendarValueAndUnit;
@Input() allowDisable?: boolean;
radioValue = '5m';
customValue = '10';
customUnits = AddonCalendarReminderUnits.MINUTE;
presetOptions = [
{
radioValue: '5m',
value: 5,
unit: AddonCalendarReminderUnits.MINUTE,
label: '',
},
{
radioValue: '10m',
value: 10,
unit: AddonCalendarReminderUnits.MINUTE,
label: '',
},
{
radioValue: '30m',
value: 30,
unit: AddonCalendarReminderUnits.MINUTE,
label: '',
},
{
radioValue: '1h',
value: 1,
unit: AddonCalendarReminderUnits.HOUR,
label: '',
},
{
radioValue: '12h',
value: 12,
unit: AddonCalendarReminderUnits.HOUR,
label: '',
},
{
radioValue: '1d',
value: 1,
unit: AddonCalendarReminderUnits.DAY,
label: '',
},
];
customUnitsOptions = [
{
value: AddonCalendarReminderUnits.MINUTE,
label: 'core.minutes',
},
{
value: AddonCalendarReminderUnits.HOUR,
label: 'core.hours',
},
{
value: AddonCalendarReminderUnits.DAY,
label: 'core.days',
},
{
value: AddonCalendarReminderUnits.WEEK,
label: 'core.weeks',
},
];
/**
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
this.presetOptions.forEach((option) => {
option.label = AddonCalendar.getUnitValueLabel(option.value, option.unit);
});
if (!this.initialValue) {
return;
}
if (this.initialValue.value === 0) {
this.radioValue = 'disabled';
} else {
// Search if it's one of the preset options.
const option = this.presetOptions.find(option =>
option.value === this.initialValue?.value && option.unit === this.initialValue.unit);
if (option) {
this.radioValue = option.radioValue;
} else {
// It's a custom value.
this.radioValue = 'custom';
this.customValue = String(this.initialValue.value);
this.customUnits = this.initialValue.unit;
}
}
}
/**
* Close the modal.
*/
closeModal(): void {
ModalController.dismiss();
}
/**
* Save the reminder.
*/
saveReminder(): void {
if (this.radioValue === 'disabled') {
ModalController.dismiss(0);
} else if (this.radioValue === 'custom') {
const value = parseInt(this.customValue, 10);
if (!value) {
CoreDomUtils.showErrorModal('core.errorinvalidform', true);
return;
}
ModalController.dismiss(Math.abs(value) * this.customUnits);
} else {
const option = this.presetOptions.find(option => option.radioValue === this.radioValue);
if (!option) {
return;
}
ModalController.dismiss(option.unit * option.value);
}
}
}

View File

@ -106,7 +106,7 @@ export class AddonCalendarUpcomingEventsComponent implements OnInit, DoCheck, On
*/ */
ngDoCheck(): void { ngDoCheck(): void {
// Check if there's any change in the filter object. // Check if there's any change in the filter object.
const changes = this.differ.diff(this.filter!); const changes = this.differ.diff(this.filter || {});
if (changes) { if (changes) {
this.filterEvents(); this.filterEvents();
} }
@ -183,7 +183,7 @@ export class AddonCalendarUpcomingEventsComponent implements OnInit, DoCheck, On
// Re-calculate the formatted time so it uses the device date. // Re-calculate the formatted time so it uses the device date.
const promises = this.events.map((event) => const promises = this.events.map((event) =>
AddonCalendar.formatEventTime(event, this.timeFormat!).then((time) => { AddonCalendar.formatEventTime(event, this.timeFormat).then((time) => {
event.formattedtime = time; event.formattedtime = time;
return; return;
@ -221,7 +221,7 @@ export class AddonCalendarUpcomingEventsComponent implements OnInit, DoCheck, On
* Filter events based on the filter popover. * Filter events based on the filter popover.
*/ */
protected filterEvents(): void { protected filterEvents(): void {
this.filteredEvents = AddonCalendarHelper.getFilteredEvents(this.events, this.filter!, this.categories); this.filteredEvents = AddonCalendarHelper.getFilteredEvents(this.events, this.filter, this.categories);
} }
/** /**

View File

@ -41,6 +41,7 @@
"noevents": "There are no events", "noevents": "There are no events",
"nopermissiontoupdatecalendar": "Sorry, but you do not have permission to update the calendar event.", "nopermissiontoupdatecalendar": "Sorry, but you do not have permission to update the calendar event.",
"reminders": "Reminders", "reminders": "Reminders",
"units": "Units",
"repeatedevents": "Repeated events", "repeatedevents": "Repeated events",
"repeateditall": "Also apply changes to the other {{$a}} events in this repeat series", "repeateditall": "Also apply changes to the other {{$a}} events in this repeat series",
"repeateditthis": "Apply changes to this event only", "repeateditthis": "Apply changes to this event only",
@ -54,6 +55,7 @@
"sunday": "Sunday", "sunday": "Sunday",
"thu": "Thu", "thu": "Thu",
"thursday": "Thursday", "thursday": "Thursday",
"timebefore": "{{value}} {{units}} before",
"today": "Today", "today": "Today",
"tomorrow": "Tomorrow", "tomorrow": "Tomorrow",
"tue": "Tue", "tue": "Tue",
@ -73,4 +75,4 @@
"wednesday": "Wednesday", "wednesday": "Wednesday",
"when": "When", "when": "When",
"yesterday": "Yesterday" "yesterday": "Yesterday"
} }

View File

@ -286,7 +286,7 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy {
this.offlineEvents = AddonCalendarHelper.classifyIntoMonths(offlineEvents); this.offlineEvents = AddonCalendarHelper.classifyIntoMonths(offlineEvents);
// Get the IDs of events edited in offline. // Get the IDs of events edited in offline.
this.offlineEditedEventsIds = offlineEvents.filter((event) => event.id! > 0).map((event) => event.id!); this.offlineEditedEventsIds = offlineEvents.filter((event) => event.id > 0).map((event) => event.id);
return; return;
})); }));
@ -361,7 +361,7 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy {
const promises = this.events.map((event) => { const promises = this.events.map((event) => {
event.ispast = this.isPastDay || (this.isCurrentDay && this.isEventPast(event)); event.ispast = this.isPastDay || (this.isCurrentDay && this.isEventPast(event));
return AddonCalendar.formatEventTime(event, this.timeFormat!, true, dayTime).then((time) => { return AddonCalendar.formatEventTime(event, this.timeFormat, true, dayTime).then((time) => {
event.formattedtime = time; event.formattedtime = time;
return; return;
@ -521,12 +521,7 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy {
* @param eventId Event to load. * @param eventId Event to load.
*/ */
gotoEvent(eventId: number): void { gotoEvent(eventId: number): void {
if (eventId < 0) { CoreNavigator.navigateToSitePath(`/calendar/event/${eventId}`);
// It's an offline event, go to the edit page.
this.openEdit(eventId);
} else {
CoreNavigator.navigateToSitePath(`/calendar/event/${eventId}`);
}
} }
/** /**

View File

@ -113,120 +113,129 @@
</ion-item> </ion-item>
</ng-container> </ng-container>
<!-- Advanced options. --> <!-- Reminders. Right now, only allow adding them here for new events. -->
<ion-item button class="ion-text-wrap divider" (click)="toggleAdvanced()" detail="false"> <ng-container *ngIf="notificationsEnabled && !eventId">
<ion-icon *ngIf="!advanced" name="fas-caret-right" flip-rtl slot="start" aria-hidden="true"></ion-icon> <ion-item-divider class="addon-calendar-reminders-title">
<ion-icon *ngIf="advanced" name="fas-caret-down" slot="start" aria-hidden="true"></ion-icon> <ion-label>
<ion-label> <p class="item-heading">{{ 'addon.calendar.reminders' | translate }}</p>
<p class="item-heading" *ngIf="!advanced">{{ 'core.showmore' | translate }}</p>
<p class="item-heading" *ngIf="advanced">{{ 'core.showless' | translate }}</p>
</ion-label>
</ion-item>
<div [hidden]="!advanced">
<!-- Description. -->
<ion-item class="ion-text-wrap">
<ion-label position="stacked">
<p class="item-heading">{{ 'core.description' | translate }}</p>
</ion-label> </ion-label>
<core-rich-text-editor [control]="descriptionControl" [attr.aria-label]="'core.description' | translate" <ion-button fill="clear" color="dark" (click)="addReminder()" slot="end"
[placeholder]="'core.description' | translate" name="description" [component]="component" [attr.aria-label]="'addon.calendar.setnewreminder' | translate">
[componentId]="eventId" [autoSave]="false"></core-rich-text-editor> <ion-icon name="fas-plus" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</ion-item-divider>
<ion-item *ngFor="let reminder of reminders" class="ion-text-wrap">
<ion-label>
<p>{{ reminder.label }}</p>
</ion-label>
<ion-button fill="clear" (click)="removeReminder(reminder)" [attr.aria-label]="'core.delete' | translate"
slot="end">
<ion-icon name="fas-trash" color="danger" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</ion-item> </ion-item>
</ng-container>
<!-- Location. --> <!-- Duration. -->
<div class="ion-text-wrap addon-calendar-radio-container">
<ion-radio-group formControlName="duration">
<ion-item-divider class="addon-calendar-radio-title">
<ion-label>
<p class="item-heading">{{ 'addon.calendar.eventduration' | translate }}</p>
</ion-label>
</ion-item-divider>
<ion-item>
<ion-label>
<p>{{ 'addon.calendar.durationnone' | translate }}</p>
</ion-label>
<ion-radio slot="end" [value]="0"></ion-radio>
</ion-item>
<ion-item lines="none">
<ion-label>
<p>{{ 'addon.calendar.durationuntil' | translate }}</p>
</ion-label>
<ion-radio slot="end" [value]="1"></ion-radio>
</ion-item>
<ion-item *ngIf="form.controls.duration.value === 1">
<ion-label position="stacked"></ion-label>
<ion-datetime formControlName="timedurationuntil" [max]="maxDate" [min]="minDate"
[placeholder]="'addon.calendar.durationuntil' | translate"
[displayFormat]="dateFormat" display-timezone="utc">
</ion-datetime>
</ion-item>
<ion-item lines="none">
<ion-label>
<p>{{ 'addon.calendar.durationminutes' | translate }}</p>
</ion-label>
<ion-radio slot="end" [value]="2"></ion-radio>
</ion-item>
<ion-item *ngIf="form.controls.duration.value === 2">
<ion-label class="sr-only">{{ 'addon.calendar.durationminutes' | translate }}</ion-label>
<ion-input type="number" name="timedurationminutes" slot="end"
[placeholder]="'addon.calendar.durationminutes' | translate"
formControlName="timedurationminutes"></ion-input>
</ion-item>
</ion-radio-group>
</div>
<!-- Repeat (for new events). -->
<ng-container *ngIf="!eventId || eventId < 0">
<ion-item class="ion-text-wrap divider" lines="none">
<ion-label>
<p class="item-heading">{{ 'addon.calendar.repeatevent' | translate }}</p>
</ion-label>
<ion-checkbox slot="end" formControlName="repeat"></ion-checkbox>
</ion-item>
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label position="stacked"> <ion-label position="stacked">
<p class="item-heading">{{ 'core.location' | translate }}</p> <p>{{ 'addon.calendar.repeatweeksl' | translate }}</p>
</ion-label> </ion-label>
<ion-input type="text" name="location" [placeholder]="'core.location' | translate" formControlName="location"> <ion-input type="number" name="repeats" formControlName="repeats" [disabled]="!form.controls.repeat.value">
</ion-input> </ion-input>
</ion-item> </ion-item>
</ng-container>
<!-- Duration. --> <!-- Apply to all events or just this one (editing repeated events). -->
<div class="ion-text-wrap addon-calendar-radio-container"> <div *ngIf="eventRepeatId" class="ion-text-wrap addon-calendar-radio-container">
<ion-radio-group formControlName="duration"> <ion-radio-group formControlName="repeateditall">
<ion-item-divider class="addon-calendar-radio-title"> <ion-item-divider class="addon-calendar-radio-title">
<ion-label>
<p class="item-heading">{{ 'addon.calendar.eventduration' | translate }}</p>
</ion-label>
</ion-item-divider>
<ion-item>
<ion-label>
<p>{{ 'addon.calendar.durationnone' | translate }}</p>
</ion-label>
<ion-radio slot="end" [value]="0"></ion-radio>
</ion-item>
<ion-item lines="none">
<ion-label>
<p>{{ 'addon.calendar.durationuntil' | translate }}</p>
</ion-label>
<ion-radio slot="end" [value]="1"></ion-radio>
</ion-item>
<ion-item>
<ion-label position="stacked"></ion-label>
<ion-datetime formControlName="timedurationuntil" [max]="maxDate" [min]="minDate"
[placeholder]="'addon.calendar.durationuntil' | translate"
[displayFormat]="dateFormat" [disabled]="form.controls.duration.value != 1"
display-timezone="utc">
</ion-datetime>
</ion-item>
<ion-item lines="none">
<ion-label>
<p>{{ 'addon.calendar.durationminutes' | translate }}</p>
</ion-label>
<ion-radio slot="end" [value]="2"></ion-radio>
</ion-item>
<ion-item>
<ion-label class="sr-only">{{ 'addon.calendar.durationminutes' | translate }}</ion-label>
<ion-input type="number" name="timedurationminutes" slot="end"
[placeholder]="'addon.calendar.durationminutes' | translate"
formControlName="timedurationminutes" [disabled]="form.controls.duration.value != 2"></ion-input>
</ion-item>
</ion-radio-group>
</div>
<!-- Repeat (for new events). -->
<ng-container *ngIf="!eventId || eventId < 0">
<ion-item class="ion-text-wrap divider" lines="none">
<ion-label> <ion-label>
<p class="item-heading">{{ 'addon.calendar.repeatevent' | translate }}</p> <p class="item-heading">{{ 'addon.calendar.repeatedevents' | translate }}</p>
</ion-label> </ion-label>
<ion-checkbox slot="end" formControlName="repeat"></ion-checkbox> </ion-item-divider>
</ion-item> <ion-item>
<ion-item class="ion-text-wrap"> <ion-label>
<ion-label position="stacked"> <p>{{ 'addon.calendar.repeateditall' | translate:{$a: otherEventsCount} }}</p>
<p>{{ 'addon.calendar.repeatweeksl' | translate }}</p>
</ion-label> </ion-label>
<ion-input type="number" name="repeats" formControlName="repeats" [disabled]="!form.controls.repeat.value"> <ion-radio slot="end" value="1"></ion-radio>
</ion-input>
</ion-item> </ion-item>
</ng-container> <ion-item>
<ion-label>
<!-- Apply to all events or just this one (editing repeated events). --> <p>{{ 'addon.calendar.repeateditthis' | translate }}</p>
<div *ngIf="eventRepeatId" class="ion-text-wrap addon-calendar-radio-container"> </ion-label>
<ion-radio-group formControlName="repeateditall"> <ion-radio slot="end" value="0"></ion-radio>
<ion-item-divider class="addon-calendar-radio-title"> </ion-item>
<ion-label> </ion-radio-group>
<p class="item-heading">{{ 'addon.calendar.repeatedevents' | translate }}</p>
</ion-label>
</ion-item-divider>
<ion-item>
<ion-label>
<p>{{ 'addon.calendar.repeateditall' | translate:{$a: otherEventsCount} }}</p>
</ion-label>
<ion-radio slot="end" value="1"></ion-radio>
</ion-item>
<ion-item>
<ion-label>
<p>{{ 'addon.calendar.repeateditthis' | translate }}</p>
</ion-label>
<ion-radio slot="end" value="0"></ion-radio>
</ion-item>
</ion-radio-group>
</div>
</div> </div>
<!-- Description. -->
<ion-item class="ion-text-wrap">
<ion-label position="stacked">
<p class="item-heading">{{ 'core.description' | translate }}</p>
</ion-label>
<core-rich-text-editor [control]="descriptionControl" [attr.aria-label]="'core.description' | translate"
[placeholder]="'core.description' | translate" name="description" [component]="component"
[componentId]="eventId" [autoSave]="false"></core-rich-text-editor>
</ion-item>
<!-- Location. -->
<ion-item class="ion-text-wrap">
<ion-label position="stacked">
<p class="item-heading">{{ 'core.location' | translate }}</p>
</ion-label>
<ion-input type="text" name="location" [placeholder]="'core.location' | translate" formControlName="location">
</ion-input>
</ion-item>
<ion-item> <ion-item>
<ion-label> <ion-label>
<ion-row> <ion-row>

View File

@ -33,7 +33,7 @@ import {
AddonCalendarSubmitCreateUpdateFormDataWSParams, AddonCalendarSubmitCreateUpdateFormDataWSParams,
} from '../../services/calendar'; } from '../../services/calendar';
import { AddonCalendarOffline } from '../../services/calendar-offline'; import { AddonCalendarOffline } from '../../services/calendar-offline';
import { AddonCalendarEventTypeOption, AddonCalendarHelper } from '../../services/calendar-helper'; import { AddonCalendarEventReminder, AddonCalendarEventTypeOption, AddonCalendarHelper } from '../../services/calendar-helper';
import { AddonCalendarSync, AddonCalendarSyncProvider } from '../../services/calendar-sync'; import { AddonCalendarSync, AddonCalendarSyncProvider } from '../../services/calendar-sync';
import { CoreSite } from '@classes/site'; import { CoreSite } from '@classes/site';
import { Translate } from '@singletons'; import { Translate } from '@singletons';
@ -43,6 +43,8 @@ import { CoreError } from '@classes/errors/error';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
import { CanLeave } from '@guards/can-leave'; import { CanLeave } from '@guards/can-leave';
import { CoreForms } from '@singletons/form'; import { CoreForms } from '@singletons/form';
import { CoreLocalNotifications } from '@services/local-notifications';
import { AddonCalendarReminderTimeModalComponent } from '@addons/calendar/components/reminder-time-modal/reminder-time-modal';
/** /**
* Page that displays a form to create/edit an event. * Page that displays a form to create/edit an event.
@ -68,7 +70,6 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave {
groups: CoreGroup[] = []; groups: CoreGroup[] = [];
loadingGroups = false; loadingGroups = false;
courseGroupSet = false; courseGroupSet = false;
advanced = false;
errors: Record<string, string>; errors: Record<string, string>;
error = false; error = false;
eventRepeatId?: number; eventRepeatId?: number;
@ -83,6 +84,10 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave {
groupControl: FormControl; groupControl: FormControl;
descriptionControl: FormControl; descriptionControl: FormControl;
// Reminders.
notificationsEnabled = false;
reminders: AddonCalendarEventCandidateReminder[] = [];
protected courseId!: number; protected courseId!: number;
protected originalData?: AddonCalendarOfflineEventDBRecord; protected originalData?: AddonCalendarOfflineEventDBRecord;
protected currentSite: CoreSite; protected currentSite: CoreSite;
@ -95,6 +100,7 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave {
protected fb: FormBuilder, protected fb: FormBuilder,
) { ) {
this.currentSite = CoreSites.getRequiredCurrentSite(); this.currentSite = CoreSites.getRequiredCurrentSite();
this.notificationsEnabled = CoreLocalNotifications.isAvailable();
this.errors = { this.errors = {
required: Translate.instant('core.required'), required: Translate.instant('core.required'),
}; };
@ -140,6 +146,7 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave {
this.form.addControl('timedurationuntil', this.fb.control(currentDate)); this.form.addControl('timedurationuntil', this.fb.control(currentDate));
this.form.addControl('courseid', this.fb.control(this.courseId)); this.form.addControl('courseid', this.fb.control(this.courseId));
this.initReminders();
this.fetchData().finally(() => { this.fetchData().finally(() => {
this.originalData = CoreUtils.clone(this.form.value); this.originalData = CoreUtils.clone(this.form.value);
this.loaded = true; this.loaded = true;
@ -171,17 +178,19 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave {
if (this.eventId && !this.gotEventData) { if (this.eventId && !this.gotEventData) {
// Editing an event, get the event data. Wait for sync first. // Editing an event, get the event data. Wait for sync first.
const eventId = this.eventId;
promises.push(AddonCalendarSync.waitForSync(AddonCalendarSyncProvider.SYNC_ID).then(async () => { promises.push(AddonCalendarSync.waitForSync(AddonCalendarSyncProvider.SYNC_ID).then(async () => {
// Do not block if the scope is already destroyed. // Do not block if the scope is already destroyed.
if (!this.isDestroyed && this.eventId) { if (!this.isDestroyed && this.eventId) {
CoreSync.blockOperation(AddonCalendarProvider.COMPONENT, this.eventId); CoreSync.blockOperation(AddonCalendarProvider.COMPONENT, eventId);
} }
let eventForm: AddonCalendarEvent | AddonCalendarOfflineEventDBRecord | undefined; let eventForm: AddonCalendarEvent | AddonCalendarOfflineEventDBRecord | undefined;
// Get the event offline data if there's any. // Get the event offline data if there's any.
try { try {
eventForm = await AddonCalendarOffline.getEvent(this.eventId!); eventForm = await AddonCalendarOffline.getEvent(eventId);
this.hasOffline = true; this.hasOffline = true;
} catch { } catch {
@ -189,9 +198,9 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave {
this.hasOffline = false; this.hasOffline = false;
} }
if (this.eventId! > 0) { if (eventId > 0) {
// It's an online event. get its data from server. // It's an online event. get its data from server.
const event = await AddonCalendar.getEventById(this.eventId!); const event = await AddonCalendar.getEventById(eventId);
if (!eventForm) { if (!eventForm) {
eventForm = event; // Use offline data first. eventForm = event; // Use offline data first.
@ -431,13 +440,6 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave {
} }
} }
/**
* Show or hide advanced form fields.
*/
toggleAdvanced(): void {
this.advanced = !this.advanced;
}
selectDuration(duration: string): void { selectDuration(duration: string): void {
this.form.controls.duration.setValue(duration); this.form.controls.duration.setValue(duration);
} }
@ -517,7 +519,9 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave {
let event: AddonCalendarEvent | AddonCalendarOfflineEventDBRecord; let event: AddonCalendarEvent | AddonCalendarOfflineEventDBRecord;
try { try {
const result = await AddonCalendar.submitEvent(this.eventId, data); const result = await AddonCalendar.submitEvent(this.eventId, data, {
reminders: this.reminders,
});
event = result.event; event = result.event;
CoreForms.triggerFormSubmittedEvent(this.formElement, result.sent, this.currentSite.getId()); CoreForms.triggerFormSubmittedEvent(this.formElement, result.sent, this.currentSite.getId());
@ -562,7 +566,10 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave {
if (event) { if (event) {
CoreEvents.trigger( CoreEvents.trigger(
AddonCalendarProvider.NEW_EVENT_EVENT, AddonCalendarProvider.NEW_EVENT_EVENT,
{ eventId: event.id! }, {
eventId: event.id,
oldEventId: this.eventId,
},
this.currentSite.getId(), this.currentSite.getId(),
); );
} else { } else {
@ -578,10 +585,15 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave {
* Discard an offline saved discussion. * Discard an offline saved discussion.
*/ */
async discard(): Promise<void> { async discard(): Promise<void> {
if (!this.eventId) {
return;
}
try { try {
await CoreDomUtils.showConfirm(Translate.instant('core.areyousure')); await CoreDomUtils.showConfirm(Translate.instant('core.areyousure'));
try { try {
await AddonCalendarOffline.deleteEvent(this.eventId!); await AddonCalendarOffline.deleteEvent(this.eventId);
CoreForms.triggerFormCancelledEvent(this.formElement, this.currentSite.getId()); CoreForms.triggerFormCancelledEvent(this.formElement, this.currentSite.getId());
@ -620,6 +632,69 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave {
} }
} }
/**
* Init reminders.
*
* @return Promise resolved when done.
*/
protected async initReminders(): Promise<void> {
if (!this.notificationsEnabled) {
return;
}
// Check if default reminders are enabled.
const defaultTime = await AddonCalendar.getDefaultNotificationTime(this.currentSite.getId());
if (defaultTime === 0) {
return;
}
const data = AddonCalendarProvider.convertSecondsToValueAndUnit(defaultTime);
// Add default reminder.
this.reminders.push({
time: null,
value: data.value,
unit: data.unit,
label: AddonCalendar.getUnitValueLabel(data.value, data.unit, true),
});
}
/**
* Add a reminder.
*/
async addReminder(): Promise<void> {
const reminderTime = await CoreDomUtils.openModal<number>({
component: AddonCalendarReminderTimeModalComponent,
});
if (reminderTime === undefined) {
// User canceled.
return;
}
const data = AddonCalendarProvider.convertSecondsToValueAndUnit(reminderTime);
// Add reminder.
this.reminders.push({
time: reminderTime,
value: data.value,
unit: data.unit,
label: AddonCalendar.getUnitValueLabel(data.value, data.unit),
});
}
/**
* Remove a reminder.
*
* @param reminder The reminder to remove.
*/
removeReminder(reminder: AddonCalendarEventCandidateReminder): void {
const index = this.reminders.indexOf(reminder);
if (index != -1) {
this.reminders.splice(index, 1);
}
}
/** /**
* Page destroyed. * Page destroyed.
*/ */
@ -629,3 +704,5 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave {
} }
} }
type AddonCalendarEventCandidateReminder = Omit<AddonCalendarEventReminder, 'id'|'eventid'>;

View File

@ -21,8 +21,8 @@
[priority]="400" [content]="'core.settings.synchronizenow' | translate" (action)="doRefresh(undefined, $event, true)" [priority]="400" [content]="'core.settings.synchronizenow' | translate" (action)="doRefresh(undefined, $event, true)"
[iconAction]="syncIcon" [closeOnClick]="false"> [iconAction]="syncIcon" [closeOnClick]="false">
</core-context-menu-item> </core-context-menu-item>
<core-context-menu-item [hidden]="!canEdit || !event || !event.canedit || event.deleted" [priority]="300" <core-context-menu-item [hidden]="!event || !event.canedit || event.deleted || (!canEdit && event.id > 0)"
[content]="'core.edit' | translate" (action)="openEdit()" iconAction="fas-edit"> [priority]="300" [content]="'core.edit' | translate" (action)="openEdit()" iconAction="fas-edit">
</core-context-menu-item> </core-context-menu-item>
<core-context-menu-item [hidden]="!event || !event.candelete || event.deleted" [priority]="200" <core-context-menu-item [hidden]="!event || !event.candelete || event.deleted" [priority]="200"
[content]="'core.delete' | translate" (action)="deleteEvent()" [content]="'core.delete' | translate" (action)="deleteEvent()"
@ -123,34 +123,26 @@
</ion-label> </ion-label>
</ion-item> </ion-item>
<ng-container *ngFor="let reminder of reminders"> <ng-container *ngFor="let reminder of reminders">
<ion-item *ngIf="reminder.time > 0 || defaultTime > 0" class="ion-text-wrap" <ion-item *ngIf="reminder.timestamp > 0" class="ion-text-wrap"
[class.item-dimmed]="(reminder.time == -1 ? (event.timestart - defaultTime) : reminder.time) <= currentTime!"> [class.item-dimmed]="reminder.timestamp <= currentTime">
<ion-label> <ion-label>
<p *ngIf="reminder.time == -1"> <p>{{ reminder.label }}</p>
{{ 'core.defaultvalue' | translate :{$a: ((event.timestart - defaultTime) * 1000) | coreFormatDate } }}
</p>
<p *ngIf="reminder.time > 0">{{ reminder.time * 1000 | coreFormatDate }}</p>
</ion-label> </ion-label>
<ion-button fill="clear" (click)="cancelNotification(reminder.id, $event)" <ion-button fill="clear" (click)="cancelNotification(reminder.id, $event)"
[attr.aria-label]="'core.delete' | translate" slot="end" [attr.aria-label]="'core.delete' | translate" slot="end" *ngIf="reminder.timestamp > currentTime">
*ngIf="(reminder.time == -1 ? (event.timestart - defaultTime) : reminder.time) > currentTime!">
<ion-icon name="fas-trash" color="danger" slot="icon-only" aria-hidden="true"></ion-icon> <ion-icon name="fas-trash" color="danger" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button> </ion-button>
</ion-item> </ion-item>
</ng-container> </ng-container>
<ng-container *ngIf="event.timestart + event.timeduration > currentTime!"> <ng-container *ngIf="event.timestart > currentTime">
<ion-item> <ion-item>
<ion-label> <ion-label>
<ion-button expand="block" color="primary" (click)="notificationPicker.open()"> <ion-button expand="block" color="primary" (click)="addReminder()">
{{ 'addon.calendar.setnewreminder' | translate }} {{ 'addon.calendar.setnewreminder' | translate }}
</ion-button> </ion-button>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-datetime #notificationPicker hidden [(ngModel)]="notificationTimeText"
[displayFormat]="notificationFormat" [min]="notificationMin" [max]="notificationMax"
[doneText]="'core.add' | translate" (ionChange)="addNotificationTime()" [monthNames]="monthNames">
</ion-datetime>
</ng-container> </ng-container>
</ion-card> </ion-card>
</core-loading> </core-loading>

View File

@ -20,7 +20,7 @@ import {
AddonCalendarEventToDisplay, AddonCalendarEventToDisplay,
AddonCalendarProvider, AddonCalendarProvider,
} from '../../services/calendar'; } from '../../services/calendar';
import { AddonCalendarHelper } from '../../services/calendar-helper'; import { AddonCalendarEventReminder, AddonCalendarHelper } from '../../services/calendar-helper';
import { AddonCalendarOffline } from '../../services/calendar-offline'; import { AddonCalendarOffline } from '../../services/calendar-offline';
import { AddonCalendarSync, AddonCalendarSyncEvents, AddonCalendarSyncProvider } from '../../services/calendar-sync'; import { AddonCalendarSync, AddonCalendarSyncEvents, AddonCalendarSyncProvider } from '../../services/calendar-sync';
import { CoreApp } from '@services/app'; import { CoreApp } from '@services/app';
@ -36,10 +36,9 @@ import { Network, NgZone, Translate } from '@singletons';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { AddonCalendarReminderDBRecord } from '../../services/database/calendar';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { CoreConstants } from '@/core/constants'; import { CoreConstants } from '@/core/constants';
import { CoreLang } from '@services/lang'; import { AddonCalendarReminderTimeModalComponent } from '@addons/calendar/components/reminder-time-modal/reminder-time-modal';
/** /**
* Page that displays a single calendar event. * Page that displays a single calendar event.
@ -53,17 +52,16 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
protected eventId!: number; protected eventId!: number;
protected siteHomeId: number; protected siteHomeId: number;
protected newEventObserver: CoreEventObserver;
protected editEventObserver: CoreEventObserver; protected editEventObserver: CoreEventObserver;
protected syncObserver: CoreEventObserver; protected syncObserver: CoreEventObserver;
protected manualSyncObserver: CoreEventObserver; protected manualSyncObserver: CoreEventObserver;
protected onlineObserver: Subscription; protected onlineObserver: Subscription;
protected defaultTimeChangedObserver: CoreEventObserver;
protected currentSiteId: string; protected currentSiteId: string;
protected updateCurrentTime?: number;
eventLoaded = false; eventLoaded = false;
notificationFormat?: string;
notificationMin?: string;
notificationMax?: string;
notificationTimeText?: string;
event?: AddonCalendarEventToDisplay; event?: AddonCalendarEventToDisplay;
courseId?: number; courseId?: number;
courseName = ''; courseName = '';
@ -72,19 +70,16 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
notificationsEnabled = false; notificationsEnabled = false;
moduleUrl = ''; moduleUrl = '';
categoryPath = ''; categoryPath = '';
currentTime?: number; currentTime = -1;
defaultTime = 0; reminders: AddonCalendarEventReminder[] = [];
reminders: AddonCalendarReminderDBRecord[] = [];
canEdit = false; canEdit = false;
hasOffline = false; hasOffline = false;
isOnline = false; isOnline = false;
syncIcon = CoreConstants.ICON_LOADING; // Sync icon. syncIcon = CoreConstants.ICON_LOADING; // Sync icon.
monthNames?: string[];
constructor( constructor(
protected route: ActivatedRoute, protected route: ActivatedRoute,
) { ) {
this.notificationsEnabled = CoreLocalNotifications.isAvailable(); this.notificationsEnabled = CoreLocalNotifications.isAvailable();
this.siteHomeId = CoreSites.getCurrentSiteHomeId(); this.siteHomeId = CoreSites.getCurrentSiteHomeId();
this.currentSiteId = CoreSites.getCurrentSiteId(); this.currentSiteId = CoreSites.getCurrentSiteId();
@ -94,7 +89,16 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
// Listen for event edited. If current event is edited, reload the data. // Listen for event edited. If current event is edited, reload the data.
this.editEventObserver = CoreEvents.on(AddonCalendarProvider.EDIT_EVENT_EVENT, (data) => { this.editEventObserver = CoreEvents.on(AddonCalendarProvider.EDIT_EVENT_EVENT, (data) => {
if (data && data.eventId == this.eventId) { if (data && data.eventId === this.eventId) {
this.eventLoaded = false;
this.refreshEvent(true, false);
}
}, this.currentSiteId);
// Listen for event created. If user edits the data of a new offline event or it's sent to server, this event is triggered.
this.newEventObserver = CoreEvents.on(AddonCalendarProvider.NEW_EVENT_EVENT, (data) => {
if (this.eventId < 0 && data && (data.eventId === this.eventId || data.oldEventId === this.eventId)) {
this.eventId = data.eventId;
this.eventLoaded = false; this.eventLoaded = false;
this.refreshEvent(true, false); this.refreshEvent(true, false);
} }
@ -121,21 +125,35 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
this.isOnline = CoreApp.isOnline(); this.isOnline = CoreApp.isOnline();
}); });
}); });
// Reload reminders if default notification time changes.
this.defaultTimeChangedObserver = CoreEvents.on(AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_CHANGED, () => {
this.loadReminders();
if (this.event) {
AddonCalendar.scheduleEventsNotifications([this.event]);
}
}, this.currentSiteId);
// Set and update current time. Use a 5 seconds error margin.
this.currentTime = CoreTimeUtils.timestamp();
this.updateCurrentTime = window.setInterval(() => {
this.currentTime = CoreTimeUtils.timestamp();
}, 5000);
} }
protected async initReminders(): Promise<void> { /**
if (this.notificationsEnabled) { * Load reminders.
this.monthNames = CoreLang.getMonthNames(); *
* @return Promise resolved when done.
this.reminders = await AddonCalendar.getEventReminders(this.eventId); */
this.defaultTime = await AddonCalendar.getDefaultNotificationTime() * 60; protected async loadReminders(): Promise<void> {
if (!this.notificationsEnabled || !this.event) {
// Calculate format to use. return;
this.notificationFormat =
CoreTimeUtils.fixFormatForDatetime(CoreTimeUtils.convertPHPToMoment(
Translate.instant('core.strftimedatetime'),
));
} }
const reminders = await AddonCalendar.getEventReminders(this.eventId, this.currentSiteId);
this.reminders = await AddonCalendarHelper.formatReminders(reminders, this.event.timestart, this.currentSiteId);
} }
/** /**
@ -155,7 +173,6 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
this.syncIcon = CoreConstants.ICON_LOADING; this.syncIcon = CoreConstants.ICON_LOADING;
this.fetchEvent(); this.fetchEvent();
this.initReminders();
} }
/** /**
@ -166,48 +183,22 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
* @return Promise resolved when done. * @return Promise resolved when done.
*/ */
async fetchEvent(sync = false, showErrors = false): Promise<void> { async fetchEvent(sync = false, showErrors = false): Promise<void> {
let deleted = false;
this.isOnline = CoreApp.isOnline(); this.isOnline = CoreApp.isOnline();
if (sync) { if (sync) {
// Try to synchronize offline events. const deleted = await this.syncEvents(showErrors);
try {
const result = await AddonCalendarSync.syncEvents();
if (result.warnings && result.warnings.length) {
CoreDomUtils.showErrorModal(result.warnings[0]);
}
if (result.deleted && result.deleted.indexOf(this.eventId) != -1) { if (deleted) {
// This event was deleted during the sync. return;
deleted = true;
}
if (result.updated) {
// Trigger a manual sync event.
result.source = 'event';
CoreEvents.trigger(
AddonCalendarSyncProvider.MANUAL_SYNCED,
result,
this.currentSiteId,
);
}
} catch (error) {
if (showErrors) {
CoreDomUtils.showErrorModalDefault(error, 'core.errorsync', true);
}
} }
} }
if (deleted) {
return;
}
try { try {
// Get the event data. // Get the event data.
const event = await AddonCalendar.getEventById(this.eventId); if (this.eventId >= 0) {
this.event = await AddonCalendarHelper.formatEventData(event); const event = await AddonCalendar.getEventById(this.eventId);
this.event = await AddonCalendarHelper.formatEventData(event);
}
try { try {
const offlineEvent = AddonCalendarHelper.formatOfflineEventData( const offlineEvent = AddonCalendarHelper.formatOfflineEventData(
@ -216,20 +207,27 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
// There is offline data, apply it. // There is offline data, apply it.
this.hasOffline = true; this.hasOffline = true;
this.event = Object.assign(this.event || {}, offlineEvent);
this.event = Object.assign(this.event, offlineEvent);
} catch { } catch {
// No offline data. // No offline data.
this.hasOffline = false; this.hasOffline = false;
if (this.eventId < 0) {
// It's an offline event, but it wasn't found. Shouldn't happen.
CoreDomUtils.showErrorModal('Event not found.');
CoreNavigator.back();
return;
}
} }
this.currentTime = CoreTimeUtils.timestamp(); if (!this.event) {
this.notificationMin = CoreTimeUtils.userDate(this.currentTime * 1000, 'YYYY-MM-DDTHH:mm', false); return; // At this point we should always have the event, adding this check to avoid TS errors.
this.notificationMax = CoreTimeUtils.userDate( }
(this.event!.timestart + this.event!.timeduration) * 1000,
'YYYY-MM-DDTHH:mm', // Load reminders, and re-schedule them if needed (maybe the event time has changed).
false, this.loadReminders();
); AddonCalendar.scheduleEventsNotifications([this.event]);
// Reset some of the calculated data. // Reset some of the calculated data.
this.categoryPath = ''; this.categoryPath = '';
@ -237,18 +235,19 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
this.courseUrl = ''; this.courseUrl = '';
this.moduleUrl = ''; this.moduleUrl = '';
if (this.event!.moduleIcon) { if (this.event.moduleIcon) {
// It's a module event, translate the module name to the current language. // It's a module event, translate the module name to the current language.
const name = CoreCourse.translateModuleName(this.event!.modulename || ''); const name = CoreCourse.translateModuleName(this.event.modulename || '');
if (name.indexOf('core.mod_') === -1) { if (name.indexOf('core.mod_') === -1) {
this.event!.modulename = name; this.event.modulename = name;
} }
// Get the module URL. // Get the module URL.
this.moduleUrl = this.event!.url || ''; this.moduleUrl = this.event.url || '';
} }
const promises: Promise<void>[] = []; const promises: Promise<void>[] = [];
const event = this.event;
const courseId = this.event.courseid; const courseId = this.event.courseid;
if (courseId != this.siteHomeId) { if (courseId != this.siteHomeId) {
@ -262,16 +261,7 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
// If it's a group event, get the name of the group. // If it's a group event, get the name of the group.
if (courseId && this.event.groupid) { if (courseId && this.event.groupid) {
promises.push(CoreGroups.getUserGroupsInCourse(courseId).then((groups) => { promises.push(this.loadGroupName(this.event, courseId));
const group = groups.find((group) => group.id == this.event!.groupid);
this.groupName = group ? group.name : '';
return;
}).catch(() => {
// Error getting groups, just don't show the group name.
this.groupName = '';
}));
} }
if (this.event.iscategoryevent && this.event.category) { if (this.event.iscategoryevent && this.event.category) {
@ -286,14 +276,14 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
// Check if event was deleted in offine. // Check if event was deleted in offine.
promises.push(AddonCalendarOffline.isEventDeleted(this.eventId).then((deleted) => { promises.push(AddonCalendarOffline.isEventDeleted(this.eventId).then((deleted) => {
this.event!.deleted = deleted; event.deleted = deleted;
return; return;
})); }));
// Re-calculate the formatted time so it uses the device date. // Re-calculate the formatted time so it uses the device date.
promises.push(AddonCalendar.getCalendarTimeFormat().then(async (timeFormat) => { promises.push(AddonCalendar.getCalendarTimeFormat().then(async (timeFormat) => {
this.event!.formattedtime = await AddonCalendar.formatEventTime(this.event!, timeFormat); event.formattedtime = await AddonCalendar.formatEventTime(event, timeFormat);
return; return;
})); }));
@ -308,24 +298,88 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
} }
/** /**
* Add a reminder for this event. * Sync offline events.
*
* @param showErrors Whether to show sync errors to the user.
* @return Promise resolved with boolean: whether event was deleted on sync.
*/ */
async addNotificationTime(): Promise<void> { protected async syncEvents(showErrors = false): Promise<boolean> {
if (this.notificationTimeText && this.event && this.event.id) { let deleted = false;
let notificationTime = CoreTimeUtils.convertToTimestamp(this.notificationTimeText);
const currentTime = CoreTimeUtils.timestamp(); // Try to synchronize offline events.
const minute = Math.floor(currentTime / 60) * 60; try {
const result = await AddonCalendarSync.syncEvents();
// Check if the notification time is in the same minute as we are, so the notification is triggered. if (result.warnings && result.warnings.length) {
if (notificationTime >= minute && notificationTime < minute + 60) { CoreDomUtils.showErrorModal(result.warnings[0]);
notificationTime = currentTime + 1;
} }
await AddonCalendar.addEventReminder(this.event, notificationTime); if (result.deleted && result.deleted.indexOf(this.eventId) != -1) {
this.reminders = await AddonCalendar.getEventReminders(this.eventId); // This event was deleted during the sync.
this.notificationTimeText = undefined; deleted = true;
} else if (this.eventId < 0 && result.offlineIdMap[this.eventId]) {
// Event was created, use the online ID.
this.eventId = result.offlineIdMap[this.eventId];
}
if (result.updated) {
// Trigger a manual sync event.
result.source = 'event';
CoreEvents.trigger(
AddonCalendarSyncProvider.MANUAL_SYNCED,
result,
this.currentSiteId,
);
}
} catch (error) {
if (showErrors) {
CoreDomUtils.showErrorModalDefault(error, 'core.errorsync', true);
}
} }
return deleted;
}
/**
* Load group name.
*
* @param event Event.
* @param courseId Course ID.
* @return Promise resolved when done.
*/
protected async loadGroupName(event: AddonCalendarEventToDisplay, courseId: number): Promise<void> {
try {
const groups = await CoreGroups.getUserGroupsInCourse(courseId);
const group = groups.find((group) => group.id == event.groupid);
this.groupName = group ? group.name : '';
} catch {
// Error getting groups, just don't show the group name.
this.groupName = '';
}
}
/**
* Add a reminder for this event.
*/
async addReminder(): Promise<void> {
if (!this.event || !this.event.id) {
return;
}
const reminderTime = await CoreDomUtils.openModal<number>({
component: AddonCalendarReminderTimeModalComponent,
});
if (reminderTime === undefined) {
// User canceled.
return;
}
await AddonCalendar.addEventReminder(this.event, reminderTime, this.currentSiteId);
await this.loadReminders();
} }
/** /**
@ -345,7 +399,7 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
try { try {
await AddonCalendar.deleteEventReminder(id); await AddonCalendar.deleteEventReminder(id);
this.reminders = await AddonCalendar.getEventReminders(this.eventId); await this.loadReminders();
} catch (error) { } catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'Error deleting reminder'); CoreDomUtils.showErrorModalDefault(error, 'Error deleting reminder');
} finally { } finally {
@ -387,7 +441,9 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
const promises: Promise<void>[] = []; const promises: Promise<void>[] = [];
promises.push(AddonCalendar.invalidateEvent(this.eventId)); if (this.eventId > 0) {
promises.push(AddonCalendar.invalidateEvent(this.eventId));
}
promises.push(AddonCalendar.invalidateTimeFormat()); promises.push(AddonCalendar.invalidateTimeFormat());
await CoreUtils.allPromisesIgnoringErrors(promises); await CoreUtils.allPromisesIgnoringErrors(promises);
@ -454,9 +510,14 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
const modal = await CoreDomUtils.showModalLoading('core.sending', true); const modal = await CoreDomUtils.showModalLoading('core.sending', true);
try { try {
const sent = await AddonCalendar.deleteEvent(this.event.id, this.event.name, deleteAll); let onlineEventDeleted = false;
if (this.event.id < 0) {
await AddonCalendarOffline.deleteEvent(this.event.id);
} else {
onlineEventDeleted = await AddonCalendar.deleteEvent(this.event.id, this.event.name, deleteAll);
}
if (sent) { if (onlineEventDeleted) {
// Event deleted, invalidate right days & months. // Event deleted, invalidate right days & months.
try { try {
await AddonCalendarHelper.refreshAfterChangeEvent(this.event, deleteAll ? this.event.eventcount : 1); await AddonCalendarHelper.refreshAfterChangeEvent(this.event, deleteAll ? this.event.eventcount : 1);
@ -466,12 +527,16 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
} }
// Trigger an event. // Trigger an event.
CoreEvents.trigger(AddonCalendarProvider.DELETED_EVENT_EVENT, { if (this.event.id < 0) {
eventId: this.eventId, CoreEvents.trigger(AddonCalendarProvider.NEW_EVENT_DISCARDED_EVENT, {}, CoreSites.getCurrentSiteId());
sent: sent, } else {
}, CoreSites.getCurrentSiteId()); CoreEvents.trigger(AddonCalendarProvider.DELETED_EVENT_EVENT, {
eventId: this.eventId,
sent: onlineEventDeleted,
}, CoreSites.getCurrentSiteId());
}
if (sent) { if (onlineEventDeleted || this.event.id < 0) {
CoreDomUtils.showToast('addon.calendar.eventcalendareventdeleted', true, 3000); CoreDomUtils.showToast('addon.calendar.eventcalendareventdeleted', true, 3000);
// Event deleted, close the view. // Event deleted, close the view.
@ -532,11 +597,21 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
// Event was deleted, close the view. // Event was deleted, close the view.
CoreNavigator.back(); CoreNavigator.back();
} else if (data.events && (!isManual || data.source != 'event')) { } else if (data.events && (!isManual || data.source != 'event')) {
const event = data.events.find((ev) => ev.id == this.eventId); if (this.eventId < 0) {
if (data.offlineIdMap[this.eventId]) {
// Event was created, use the online ID.
this.eventId = data.offlineIdMap[this.eventId];
if (event) { this.eventLoaded = false;
this.eventLoaded = false; this.refreshEvent();
this.refreshEvent(); }
} else {
const event = data.events.find((ev) => ev.id == this.eventId);
if (event) {
this.eventLoaded = false;
this.refreshEvent();
}
} }
} }
} }
@ -545,10 +620,12 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
* Page destroyed. * Page destroyed.
*/ */
ngOnDestroy(): void { ngOnDestroy(): void {
this.editEventObserver?.off(); this.editEventObserver.off();
this.syncObserver?.off(); this.syncObserver.off();
this.manualSyncObserver?.off(); this.manualSyncObserver.off();
this.onlineObserver?.unsubscribe(); this.onlineObserver.unsubscribe();
this.newEventObserver.off();
clearInterval(this.updateCurrentTime);
} }
} }

View File

@ -301,12 +301,7 @@ export class AddonCalendarIndexPage implements OnInit, OnDestroy {
* @param eventId Event to load. * @param eventId Event to load.
*/ */
gotoEvent(eventId: number): void { gotoEvent(eventId: number): void {
if (eventId < 0) { CoreNavigator.navigateToSitePath(`/calendar/event/${eventId}`);
// It's an offline event, go to the edit page.
this.openEdit(eventId);
} else {
CoreNavigator.navigateToSitePath(`/calendar/event/${eventId}`);
}
} }
/** /**

View File

@ -8,18 +8,10 @@
</ion-header> </ion-header>
<ion-content> <ion-content>
<ion-list> <ion-list>
<ion-item *ngIf="defaultTime != -1"> <ion-item *ngIf="defaultTimeLabel">
<ion-label>{{ 'addon.calendar.defaultnotificationtime' | translate }}</ion-label> <ion-label>{{ 'addon.calendar.defaultnotificationtime' | translate }}</ion-label>
<ion-select [(ngModel)]="defaultTime" (ionChange)="updateDefaultTime(defaultTime)" interface="action-sheet" <ion-select [(ngModel)]="defaultTimeLabel" (click)="changeDefaultTime($event)">
[interfaceOptions]="{header: 'addon.calendar.defaultnotificationtime' | translate}"> <ion-select-option [value]="defaultTimeLabel">{{ defaultTimeLabel }}</ion-select-option>
<ion-select-option [value]="0">{{ 'core.settings.disabled' | translate }}</ion-select-option>
<ion-select-option [value]="10">{{ 600 | coreDuration }}</ion-select-option>
<ion-select-option [value]="30">{{ 1800 | coreDuration }}</ion-select-option>
<ion-select-option [value]="60">{{ 3600 | coreDuration }}</ion-select-option>
<ion-select-option [value]="120">{{ 7200 | coreDuration }}</ion-select-option>
<ion-select-option [value]="360">{{ 21600 | coreDuration }}</ion-select-option>
<ion-select-option [value]="720">{{ 43200 | coreDuration }}</ion-select-option>
<ion-select-option [value]="1440">{{ 86400 | coreDuration }}</ion-select-option>
</ion-select> </ion-select>
</ion-item> </ion-item>
</ion-list> </ion-list>

View File

@ -13,9 +13,16 @@
// limitations under the License. // limitations under the License.
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { AddonCalendar, AddonCalendarProvider } from '../../services/calendar'; import {
AddonCalendar,
AddonCalendarProvider,
AddonCalendarReminderUnits,
AddonCalendarValueAndUnit,
} from '../../services/calendar';
import { CoreEvents } from '@singletons/events'; import { CoreEvents } from '@singletons/events';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { AddonCalendarReminderTimeModalComponent } from '@addons/calendar/components/reminder-time-modal/reminder-time-modal';
/** /**
* Page that displays the calendar settings. * Page that displays the calendar settings.
@ -26,13 +33,51 @@ import { CoreSites } from '@services/sites';
}) })
export class AddonCalendarSettingsPage implements OnInit { export class AddonCalendarSettingsPage implements OnInit {
defaultTime = -1; defaultTimeLabel = '';
protected defaultTime: AddonCalendarValueAndUnit = {
value: 0,
unit: AddonCalendarReminderUnits.MINUTE,
};
/** /**
* View loaded. * View loaded.
*/ */
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
this.defaultTime = await AddonCalendar.getDefaultNotificationTime(); const defaultTime = await AddonCalendar.getDefaultNotificationTime();
this.defaultTime = AddonCalendarProvider.convertSecondsToValueAndUnit(defaultTime);
this.defaultTimeLabel = AddonCalendar.getUnitValueLabel(this.defaultTime.value, this.defaultTime.unit);
}
/**
* Change default time.
*
* @param e Event.
* @return Promise resolved when done.
*/
async changeDefaultTime(e: Event): Promise<void> {
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
const reminderTime = await CoreDomUtils.openModal<number>({
component: AddonCalendarReminderTimeModalComponent,
componentProps: {
initialValue: this.defaultTime,
allowDisable: true,
},
});
if (reminderTime === undefined) {
// User canceled.
return;
}
this.defaultTime = AddonCalendarProvider.convertSecondsToValueAndUnit(reminderTime);
this.defaultTimeLabel = AddonCalendar.getUnitValueLabel(this.defaultTime.value, this.defaultTime.unit);
this.updateDefaultTime(reminderTime);
} }
/** /**

View File

@ -23,6 +23,7 @@ import {
AddonCalendarEventType, AddonCalendarEventType,
AddonCalendarGetEventsEvent, AddonCalendarGetEventsEvent,
AddonCalendarProvider, AddonCalendarProvider,
AddonCalendarReminderUnits,
AddonCalendarWeek, AddonCalendarWeek,
AddonCalendarWeekDay, AddonCalendarWeekDay,
} from './calendar'; } from './calendar';
@ -35,6 +36,7 @@ import { makeSingleton } from '@singletons';
import { AddonCalendarSyncInvalidateEvent } from './calendar-sync'; import { AddonCalendarSyncInvalidateEvent } from './calendar-sync';
import { AddonCalendarOfflineEventDBRecord } from './database/calendar-offline'; import { AddonCalendarOfflineEventDBRecord } from './database/calendar-offline';
import { CoreCategoryData } from '@features/courses/services/courses'; import { CoreCategoryData } from '@features/courses/services/courses';
import { AddonCalendarReminderDBRecord } from './database/calendar';
/** /**
* Context levels enumeration. * Context levels enumeration.
@ -220,7 +222,7 @@ export class AddonCalendarHelperProvider {
formatOfflineEventData(event: AddonCalendarOfflineEventDBRecord): AddonCalendarEventToDisplay { formatOfflineEventData(event: AddonCalendarOfflineEventDBRecord): AddonCalendarEventToDisplay {
const eventFormatted: AddonCalendarEventToDisplay = { const eventFormatted: AddonCalendarEventToDisplay = {
id: event.id!, id: event.id,
name: event.name, name: event.name,
timestart: event.timestart, timestart: event.timestart,
eventtype: event.eventtype, eventtype: event.eventtype,
@ -243,6 +245,8 @@ export class AddonCalendarHelperProvider {
format: 1, format: 1,
visible: 1, visible: 1,
offline: true, offline: true,
canedit: event.id < 0,
candelete: event.id < 0,
timeduration: 0, timeduration: 0,
}; };
@ -282,6 +286,55 @@ export class AddonCalendarHelperProvider {
} }
} }
/**
* Format reminders, adding calculated data.
*
* @param reminders Reminders.
* @param timestart Event timestart.
* @param siteId Site ID.
* @return Formatted reminders.
*/
async formatReminders(
reminders: AddonCalendarReminderDBRecord[],
timestart: number,
siteId?: string,
): Promise<AddonCalendarEventReminder[]> {
const defaultTime = await AddonCalendar.getDefaultNotificationTime(siteId);
const formattedReminders = <AddonCalendarEventReminder[]> reminders;
const eventTimestart = timestart;
let defaultTimeValue: number | undefined;
let defaultTimeUnit: AddonCalendarReminderUnits | undefined;
if (defaultTime > 0) {
const data = AddonCalendarProvider.convertSecondsToValueAndUnit(defaultTime);
defaultTimeValue = data.value;
defaultTimeUnit = data.unit;
}
return formattedReminders.map((reminder) => {
if (reminder.time === null) {
// Default time. Check if default notifications are disabled.
if (defaultTimeValue !== undefined && defaultTimeUnit) {
reminder.value = defaultTimeValue;
reminder.unit = defaultTimeUnit;
reminder.timestamp = eventTimestart - reminder.value * reminder.unit;
}
} else {
const data = AddonCalendarProvider.convertSecondsToValueAndUnit(reminder.time);
reminder.value = data.value;
reminder.unit = data.unit;
reminder.timestamp = eventTimestart - reminder.time;
}
if (reminder.value && reminder.unit) {
reminder.label = AddonCalendar.getUnitValueLabel(reminder.value, reminder.unit, reminder.time === null);
}
return reminder;
});
}
/** /**
* Get options (name & value) for each allowed event type. * Get options (name & value) for each allowed event type.
* *
@ -335,7 +388,7 @@ export class AddonCalendarHelperProvider {
year: number, year: number,
month: number, month: number,
siteId?: string, siteId?: string,
): Promise<{ daynames: Partial<AddonCalendarDayName>[]; weeks: Partial<AddonCalendarWeek>[] }> { ): Promise<{ daynames: Partial<AddonCalendarDayName>[]; weeks: AddonCalendarWeek[] }> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
// Get starting week day user preference, fallback to site configuration. // Get starting week day user preference, fallback to site configuration.
let startWeekDayStr = site.getStoredConfig('calendar_startwday'); let startWeekDayStr = site.getStoredConfig('calendar_startwday');
@ -344,7 +397,7 @@ export class AddonCalendarHelperProvider {
const today = moment(); const today = moment();
const isCurrentMonth = today.year() == year && today.month() == month - 1; const isCurrentMonth = today.year() == year && today.month() == month - 1;
const weeks: Partial<AddonCalendarWeek>[] = []; const weeks: AddonCalendarWeek[] = [];
let date = moment({ year, month: month - 1, date: 1 }); let date = moment({ year, month: month - 1, date: 1 });
for (let mday = 1; mday <= date.daysInMonth(); mday++) { for (let mday = 1; mday <= date.daysInMonth(); mday++) {
@ -371,7 +424,7 @@ export class AddonCalendarHelperProvider {
} }
// Add day to current week. // Add day to current week.
weeks[weeks.length - 1].days!.push({ weeks[weeks.length - 1].days.push({
events: [], events: [],
hasevents: false, hasevents: false,
mday: date.date(), mday: date.date(),
@ -450,11 +503,11 @@ export class AddonCalendarHelperProvider {
*/ */
getFilteredEvents( getFilteredEvents(
events: AddonCalendarEventToDisplay[], events: AddonCalendarEventToDisplay[],
filter: AddonCalendarFilter, filter: AddonCalendarFilter | undefined,
categories: { [id: number]: CoreCategoryData }, categories: { [id: number]: CoreCategoryData },
): AddonCalendarEventToDisplay[] { ): AddonCalendarEventToDisplay[] {
// Do not filter. // Do not filter.
if (!filter.filtered) { if (!filter || !filter.filtered) {
return events; return events;
} }
@ -475,9 +528,9 @@ export class AddonCalendarHelperProvider {
* Check if an event should be displayed based on the filter. * Check if an event should be displayed based on the filter.
* *
* @param event Event object. * @param event Event object.
* @param categories Categories indexed by ID.
* @param courseId Course ID to filter. * @param courseId Course ID to filter.
* @param categoryId Category ID the course belongs to. * @param categoryId Category ID the course belongs to.
* @param categories Categories indexed by ID.
* @return Whether it should be displayed. * @return Whether it should be displayed.
*/ */
protected shouldDisplayEvent( protected shouldDisplayEvent(
@ -492,7 +545,7 @@ export class AddonCalendarHelperProvider {
} }
if (event.eventtype == 'category' && categories) { if (event.eventtype == 'category' && categories) {
if (!event.categoryid || !Object.keys(categories).length) { if (!event.categoryid || !Object.keys(categories).length || !categoryId) {
// We can't tell if the course belongs to the category, display them all. // We can't tell if the course belongs to the category, display them all.
return true; return true;
} }
@ -503,7 +556,7 @@ export class AddonCalendarHelperProvider {
} }
// Check parent categories. // Check parent categories.
let category = categories[categoryId!]; let category = categories[categoryId];
while (category) { while (category) {
if (!category.parent) { if (!category.parent) {
// Category doesn't have parent, stop. // Category doesn't have parent, stop.
@ -567,7 +620,7 @@ export class AddonCalendarHelperProvider {
await AddonCalendar.getLocalEventsByRepeatIdFromLocalDb(eventData.repeatid, site.id); await AddonCalendar.getLocalEventsByRepeatIdFromLocalDb(eventData.repeatid, site.id);
await CoreUtils.allPromises(repeatedEvents.map((event) => await CoreUtils.allPromises(repeatedEvents.map((event) =>
AddonCalendar.invalidateEvent(event.id!))); AddonCalendar.invalidateEvent(event.id)));
return; return;
} }
@ -670,7 +723,7 @@ export class AddonCalendarHelperProvider {
*/ */
refreshAfterChangeEvent( refreshAfterChangeEvent(
event: { event: {
id?: number; id: number;
repeatid?: number; repeatid?: number;
timestart: number; timestart: number;
}, },
@ -679,7 +732,7 @@ export class AddonCalendarHelperProvider {
): Promise<void> { ): Promise<void> {
return this.refreshAfterChangeEvents( return this.refreshAfterChangeEvents(
[{ [{
id: event.id!, id: event.id,
repeatid: event.repeatid, repeatid: event.repeatid,
timestart: event.timestart, timestart: event.timestart,
repeated: repeated, repeated: repeated,
@ -725,3 +778,13 @@ export type AddonCalendarEventTypeOption = {
name: string; name: string;
value: AddonCalendarEventType; value: AddonCalendarEventType;
}; };
/**
* Formatted event reminder.
*/
export type AddonCalendarEventReminder = AddonCalendarReminderDBRecord & {
value?: number; // Amount of time.
unit?: AddonCalendarReminderUnits; // Units.
timestamp?: number; // Timestamp (in seconds).
label?: string; // Label to represent the reminder.
};

View File

@ -110,7 +110,7 @@ export class AddonCalendarOfflineProvider {
async getAllEditedEventsIds(siteId?: string): Promise<number[]> { async getAllEditedEventsIds(siteId?: string): Promise<number[]> {
const events = await this.getAllEditedEvents(siteId); const events = await this.getAllEditedEvents(siteId);
return events.map((event) => event.id!); return events.map((event) => event.id);
} }
/** /**
@ -215,20 +215,18 @@ export class AddonCalendarOfflineProvider {
/** /**
* Offline version for adding a new discussion to a forum. * Offline version for adding a new discussion to a forum.
* *
* @param eventId Event ID. If it's a new event, set it to undefined/null. * @param eventId Event ID. Negative value to edit offline event. If it's a new event, set it to undefined/null.
* @param data Event data. * @param data Event data.
* @param timeCreated The time the event was created. If not defined, current time.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the stored event. * @return Promise resolved with the stored event.
*/ */
async saveEvent( async saveEvent(
eventId: number | undefined, eventId: number | undefined,
data: AddonCalendarSubmitCreateUpdateFormDataWSParams, data: AddonCalendarSubmitCreateUpdateFormDataWSParams,
timeCreated?: number,
siteId?: string, siteId?: string,
): Promise<AddonCalendarOfflineEventDBRecord> { ): Promise<AddonCalendarOfflineEventDBRecord> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
timeCreated = timeCreated || Date.now(); const timeCreated = Date.now();
const event: AddonCalendarOfflineEventDBRecord = { const event: AddonCalendarOfflineEventDBRecord = {
id: eventId || -timeCreated, id: eventId || -timeCreated,
name: data.name, name: data.name,

View File

@ -100,9 +100,10 @@ export class AddonCalendarSyncProvider extends CoreSyncBaseProvider<AddonCalenda
async syncEvents(siteId?: string): Promise<AddonCalendarSyncEvents> { async syncEvents(siteId?: string): Promise<AddonCalendarSyncEvents> {
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
if (this.isSyncing(AddonCalendarSyncProvider.SYNC_ID, siteId)) { const currentSyncPromise = this.getOngoingSync(AddonCalendarSyncProvider.SYNC_ID, siteId);
if (currentSyncPromise) {
// There's already a sync ongoing for this site, return the promise. // There's already a sync ongoing for this site, return the promise.
return this.getOngoingSync(AddonCalendarSyncProvider.SYNC_ID, siteId)!; return currentSyncPromise;
} }
this.logger.debug('Try to sync calendar events for site ' + siteId); this.logger.debug('Try to sync calendar events for site ' + siteId);
@ -123,6 +124,7 @@ export class AddonCalendarSyncProvider extends CoreSyncBaseProvider<AddonCalenda
const result: AddonCalendarSyncEvents = { const result: AddonCalendarSyncEvents = {
warnings: [], warnings: [],
events: [], events: [],
offlineIdMap: {},
deleted: [], deleted: [],
toinvalidate: [], toinvalidate: [],
updated: false, updated: false,
@ -255,10 +257,13 @@ export class AddonCalendarSyncProvider extends CoreSyncBaseProvider<AddonCalenda
); // Clone the object because it will be modified in the submit function. ); // Clone the object because it will be modified in the submit function.
try { try {
const newEvent = await AddonCalendar.submitEventOnline(eventId > 0 ? eventId : 0, data, siteId); const newEvent = await AddonCalendar.submitEventOnline(eventId, data, siteId);
result.updated = true; result.updated = true;
result.events.push(newEvent); result.events.push(newEvent);
if (eventId < 0) {
result.offlineIdMap[eventId] = newEvent.id;
}
// Add data to invalidate. // Add data to invalidate.
const numberOfRepetitions = data.repeat ? data.repeats : const numberOfRepetitions = data.repeat ? data.repeats :
@ -272,7 +277,7 @@ export class AddonCalendarSyncProvider extends CoreSyncBaseProvider<AddonCalenda
}); });
// Event sent, delete the offline data. // Event sent, delete the offline data.
return AddonCalendarOffline.deleteEvent(event.id!, siteId); return AddonCalendarOffline.deleteEvent(event.id, siteId);
} catch (error) { } catch (error) {
if (!CoreUtils.isWebServiceError(error)) { if (!CoreUtils.isWebServiceError(error)) {
@ -283,7 +288,7 @@ export class AddonCalendarSyncProvider extends CoreSyncBaseProvider<AddonCalenda
// The WebService has thrown an error, this means that the event cannot be created. Delete it. // The WebService has thrown an error, this means that the event cannot be created. Delete it.
result.updated = true; result.updated = true;
await AddonCalendarOffline.deleteEvent(event.id!, siteId); await AddonCalendarOffline.deleteEvent(event.id, siteId);
// Event deleted, add a warning. // Event deleted, add a warning.
this.addOfflineDataDeletedWarning(result.warnings, event.name, error); this.addOfflineDataDeletedWarning(result.warnings, event.name, error);
@ -297,6 +302,7 @@ export const AddonCalendarSync = makeSingleton(AddonCalendarSyncProvider);
export type AddonCalendarSyncEvents = { export type AddonCalendarSyncEvents = {
warnings: string[]; warnings: string[];
events: AddonCalendarEvent[]; events: AddonCalendarEvent[];
offlineIdMap: Record<number, number>; // Map offline ID with online ID for created events.
deleted: number[]; deleted: number[];
toinvalidate: AddonCalendarSyncInvalidateEvent[]; toinvalidate: AddonCalendarSyncInvalidateEvent[];
updated: boolean; updated: boolean;

View File

@ -53,6 +53,16 @@ export enum AddonCalendarEventType {
USER = 'user', USER = 'user',
} }
/**
* Units to set a reminder.
*/
export enum AddonCalendarReminderUnits {
MINUTE = CoreConstants.SECONDS_MINUTE,
HOUR = CoreConstants.SECONDS_HOUR,
DAY = CoreConstants.SECONDS_DAY,
WEEK = CoreConstants.SECONDS_WEEK,
}
declare module '@singletons/events' { declare module '@singletons/events' {
/** /**
@ -72,6 +82,21 @@ declare module '@singletons/events' {
} }
const REMINDER_UNITS_LABELS = {
single: {
[AddonCalendarReminderUnits.MINUTE]: 'core.minute',
[AddonCalendarReminderUnits.HOUR]: 'core.hour',
[AddonCalendarReminderUnits.DAY]: 'core.day',
[AddonCalendarReminderUnits.WEEK]: 'core.week',
},
multi: {
[AddonCalendarReminderUnits.MINUTE]: 'core.minutes',
[AddonCalendarReminderUnits.HOUR]: 'core.hours',
[AddonCalendarReminderUnits.DAY]: 'core.days',
[AddonCalendarReminderUnits.WEEK]: 'core.weeks',
},
};
/** /**
* Service to handle calendar events. * Service to handle calendar events.
*/ */
@ -82,7 +107,7 @@ export class AddonCalendarProvider {
static readonly COMPONENT = 'AddonCalendarEvents'; static readonly COMPONENT = 'AddonCalendarEvents';
static readonly DEFAULT_NOTIFICATION_TIME_CHANGED = 'AddonCalendarDefaultNotificationTimeChangedEvent'; static readonly DEFAULT_NOTIFICATION_TIME_CHANGED = 'AddonCalendarDefaultNotificationTimeChangedEvent';
static readonly DEFAULT_NOTIFICATION_TIME_SETTING = 'mmaCalendarDefaultNotifTime'; static readonly DEFAULT_NOTIFICATION_TIME_SETTING = 'mmaCalendarDefaultNotifTime';
static readonly DEFAULT_NOTIFICATION_TIME = 60; static readonly DEFAULT_NOTIFICATION_TIME = 3600;
static readonly STARTING_WEEK_DAY = 'addon_calendar_starting_week_day'; static readonly STARTING_WEEK_DAY = 'addon_calendar_starting_week_day';
static readonly NEW_EVENT_EVENT = 'addon_calendar_new_event'; static readonly NEW_EVENT_EVENT = 'addon_calendar_new_event';
static readonly NEW_EVENT_DISCARDED_EVENT = 'addon_calendar_new_event_discarded'; static readonly NEW_EVENT_DISCARDED_EVENT = 'addon_calendar_new_event_discarded';
@ -156,6 +181,41 @@ export class AddonCalendarProvider {
return !!site?.isVersionGreaterEqualThan('3.7.1'); return !!site?.isVersionGreaterEqualThan('3.7.1');
} }
/**
* Given a number of seconds, convert it to a unit&value format compatible with reminders.
*
* @param seconds Number of seconds.
* @return Value and unit.
*/
static convertSecondsToValueAndUnit(seconds: number): AddonCalendarValueAndUnit {
if (seconds <= 0) {
return {
value: 0,
unit: AddonCalendarReminderUnits.MINUTE,
};
} else if (seconds % AddonCalendarReminderUnits.WEEK === 0) {
return {
value: seconds / AddonCalendarReminderUnits.WEEK,
unit: AddonCalendarReminderUnits.WEEK,
};
} else if (seconds % AddonCalendarReminderUnits.DAY === 0) {
return {
value: seconds / AddonCalendarReminderUnits.DAY,
unit: AddonCalendarReminderUnits.DAY,
};
} else if (seconds % AddonCalendarReminderUnits.HOUR === 0) {
return {
value: seconds / AddonCalendarReminderUnits.HOUR,
unit: AddonCalendarReminderUnits.HOUR,
};
} else {
return {
value: seconds / AddonCalendarReminderUnits.MINUTE,
unit: AddonCalendarReminderUnits.MINUTE,
};
}
}
/** /**
* Delete an event. * Delete an event.
* *
@ -248,7 +308,7 @@ export class AddonCalendarProvider {
REMINDERS_TABLE, REMINDERS_TABLE,
{ eventid: eventId }, { eventid: eventId },
).then((reminders) => ).then((reminders) =>
Promise.all(reminders.map((reminder) => this.deleteEventReminder(reminder.id!, siteId))))); Promise.all(reminders.map((reminder) => this.deleteEventReminder(reminder.id, siteId)))));
try { try {
await Promise.all(promises); await Promise.all(promises);
@ -306,7 +366,7 @@ export class AddonCalendarProvider {
*/ */
async formatEventTime( async formatEventTime(
event: AddonCalendarEventToDisplay, event: AddonCalendarEventToDisplay,
format: string, format?: string,
useCommonWords = true, useCommonWords = true,
seenDay?: number, seenDay?: number,
showTime = 0, showTime = 0,
@ -548,7 +608,7 @@ export class AddonCalendarProvider {
* Get the configured default notification time. * Get the configured default notification time.
* *
* @param siteId ID of the site. If not defined, use current site. * @param siteId ID of the site. If not defined, use current site.
* @return Promise resolved with the default time. * @return Promise resolved with the default time (in seconds).
*/ */
async getDefaultNotificationTime(siteId?: string): Promise<number> { async getDefaultNotificationTime(siteId?: string): Promise<number> {
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
@ -656,18 +716,18 @@ export class AddonCalendarProvider {
eventConverted.iscategoryevent = originalEvent.eventtype == AddonCalendarEventType.CATEGORY; eventConverted.iscategoryevent = originalEvent.eventtype == AddonCalendarEventType.CATEGORY;
eventConverted.normalisedeventtype = this.getEventType(recordAsRecord); eventConverted.normalisedeventtype = this.getEventType(recordAsRecord);
try { try {
eventConverted.category = CoreTextUtils.parseJSON(recordAsRecord.category!); eventConverted.category = CoreTextUtils.parseJSON(recordAsRecord.category || '');
} catch { } catch {
// Ignore errors. // Ignore errors.
} }
try { try {
eventConverted.course = CoreTextUtils.parseJSON(recordAsRecord.course!); eventConverted.course = CoreTextUtils.parseJSON(recordAsRecord.course || '');
} catch { } catch {
// Ignore errors. // Ignore errors.
} }
try { try {
eventConverted.subscription = CoreTextUtils.parseJSON(recordAsRecord.subscription!); eventConverted.subscription = CoreTextUtils.parseJSON(recordAsRecord.subscription || '');
} catch { } catch {
// Ignore errors. // Ignore errors.
} }
@ -678,24 +738,27 @@ export class AddonCalendarProvider {
/** /**
* Adds an event reminder and schedule a new notification. * Adds an event reminder and schedule a new notification.
* *
* @param event Event to update its notification time. * @param event Event to set the reminder.
* @param time New notification setting timestamp. * @param time Amount of seconds of the reminder. Undefined for default reminder.
* @param siteId ID of the site the event belongs to. If not defined, use current site. * @param siteId ID of the site the event belongs to. If not defined, use current site.
* @return Promise resolved when the notification is updated. * @return Promise resolved when the notification is updated.
*/ */
async addEventReminder( async addEventReminder(
event: { id: number; timestart: number; timeduration: number; name: string}, event: { id: number; timestart: number; name: string},
time: number, time?: number | null,
siteId?: string, siteId?: string,
): Promise<void> { ): Promise<void> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
const reminder: AddonCalendarReminderDBRecord = { const reminder: Partial<AddonCalendarReminderDBRecord> = {
eventid: event.id, eventid: event.id,
time: time, time: time ?? null,
}; };
const reminderId = await site.getDb().insertRecord(REMINDERS_TABLE, reminder); const reminderId = await site.getDb().insertRecord(REMINDERS_TABLE, reminder);
await this.scheduleEventNotification(event, reminderId, time, site.getId()); const timestamp = time ? event.timestart - time : time;
await this.scheduleEventNotification(event, reminderId, timestamp, site.getId());
} }
/** /**
@ -773,7 +836,7 @@ export class AddonCalendarProvider {
preSets.emergencyCache = false; preSets.emergencyCache = false;
} }
const response: AddonCalendarCalendarDay = await site.read('core_calendar_get_calendar_day_view', params, preSets); const response: AddonCalendarCalendarDay = await site.read('core_calendar_get_calendar_day_view', params, preSets);
this.storeEventsInLocalDB(response.events, siteId); this.storeEventsInLocalDB(response.events, { siteId });
return response; return response;
} }
@ -855,6 +918,10 @@ export class AddonCalendarProvider {
const start = initialTime + (CoreConstants.SECONDS_DAY * daysToStart); const start = initialTime + (CoreConstants.SECONDS_DAY * daysToStart);
const end = start + (CoreConstants.SECONDS_DAY * daysInterval) - 1; const end = start + (CoreConstants.SECONDS_DAY * daysInterval) - 1;
const events = {
courseids: <number[]> [],
groupids: <number[]> [],
};
const params: AddonCalendarGetCalendarEventsWSParams = { const params: AddonCalendarGetCalendarEventsWSParams = {
options: { options: {
userevents: true, userevents: true,
@ -862,23 +929,20 @@ export class AddonCalendarProvider {
timestart: start, timestart: start,
timeend: end, timeend: end,
}, },
events: { events: events,
courseids: [],
groupids: [],
},
}; };
const promises: Promise<void>[] = []; const promises: Promise<void>[] = [];
promises.push(CoreCourses.getUserCourses(false, siteId).then((courses) => { promises.push(CoreCourses.getUserCourses(false, siteId).then((courses) => {
params.events!.courseids = courses.map((course) => course.id); events.courseids = courses.map((course) => course.id);
params.events!.courseids.push(site.getSiteHomeId()); // Add front page. events.courseids.push(site.getSiteHomeId()); // Add front page.
return; return;
})); }));
promises.push(CoreGroups.getAllUserGroups(siteId).then((groups) => { promises.push(CoreGroups.getAllUserGroups(siteId).then((groups) => {
params.events!.groupids = groups.map((group) => group.id); events.groupids = groups.map((group) => group.id);
return; return;
})); }));
@ -976,7 +1040,7 @@ export class AddonCalendarProvider {
const response = await site.read<AddonCalendarMonth>('core_calendar_get_calendar_monthly_view', params, preSets); const response = await site.read<AddonCalendarMonth>('core_calendar_get_calendar_monthly_view', params, preSets);
response.weeks.forEach((week) => { response.weeks.forEach((week) => {
week.days.forEach((day) => { week.days.forEach((day) => {
this.storeEventsInLocalDB(day.events as AddonCalendarCalendarEvent[], siteId); this.storeEventsInLocalDB(day.events as AddonCalendarCalendarEvent[], { siteId });
}); });
}); });
@ -1022,6 +1086,35 @@ export class AddonCalendarProvider {
(categoryId ? categoryId : ''); (categoryId ? categoryId : '');
} }
/**
* Given a value and a unit, return the translated label.
*
* @param value Value.
* @param unit Unit.
* @param addDefaultLabel Whether to add the "Default" text.
* @return Translated label.
*/
getUnitValueLabel(value: number, unit: AddonCalendarReminderUnits, addDefaultLabel = false): string {
if (value === 0) {
return Translate.instant('core.settings.disabled');
}
const unitsLabel = value === 1 ?
REMINDER_UNITS_LABELS.single[unit] :
REMINDER_UNITS_LABELS.multi[unit];
const label = Translate.instant('addon.calendar.timebefore', {
units: Translate.instant(unitsLabel),
value: value,
});
if (addDefaultLabel) {
return Translate.instant('core.defaultvalue', { $a: label });
}
return label;
}
/** /**
* Get upcoming calendar events. * Get upcoming calendar events.
* *
@ -1060,7 +1153,7 @@ export class AddonCalendarProvider {
} }
const response = await site.read<AddonCalendarUpcoming>('core_calendar_get_calendar_upcoming_view', params, preSets); const response = await site.read<AddonCalendarUpcoming>('core_calendar_get_calendar_upcoming_view', params, preSets);
this.storeEventsInLocalDB(response.events, siteId); this.storeEventsInLocalDB(response.events, { siteId });
return response; return response;
} }
@ -1335,14 +1428,14 @@ export class AddonCalendarProvider {
* *
* @param event Event to schedule. * @param event Event to schedule.
* @param reminderId The reminder ID. * @param reminderId The reminder ID.
* @param time Notification setting time (in minutes). E.g. 10 means "notificate 10 minutes before start". * @param time Notification timestamp (in seconds). Undefined for default time.
* @param siteId Site ID the event belongs to. If not defined, use current site. * @param siteId Site ID the event belongs to. If not defined, use current site.
* @return Promise resolved when the notification is scheduled. * @return Promise resolved when the notification is scheduled.
*/ */
protected async scheduleEventNotification( protected async scheduleEventNotification(
event: { id: number; timestart: number; name: string}, event: { id: number; timestart: number; name: string},
reminderId: number, reminderId: number,
time: number, time?: number | null,
siteId?: string, siteId?: string,
): Promise<void> { ): Promise<void> {
@ -1357,16 +1450,16 @@ export class AddonCalendarProvider {
return CoreLocalNotifications.cancel(reminderId, AddonCalendarProvider.COMPONENT, siteId); return CoreLocalNotifications.cancel(reminderId, AddonCalendarProvider.COMPONENT, siteId);
} }
if (time == -1) { if (!time) {
// If time is -1, get event default time to calculate the notification time. // Get event default time to calculate the notification time.
time = await this.getDefaultNotificationTime(siteId); time = await this.getDefaultNotificationTime(siteId);
if (time == 0) { if (time === 0) {
// Default notification time is disabled, do not show. // Default notification time is disabled, do not show.
return CoreLocalNotifications.cancel(reminderId, AddonCalendarProvider.COMPONENT, siteId); return CoreLocalNotifications.cancel(reminderId, AddonCalendarProvider.COMPONENT, siteId);
} }
time = event.timestart - (time * 60); time = event.timestart - time;
} }
time = time * 1000; time = time * 1000;
@ -1417,17 +1510,18 @@ export class AddonCalendarProvider {
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
const promises = events.map(async (event) => { const promises = events.map(async (event) => {
const timeEnd = (event.timestart + event.timeduration) * 1000; if (event.timestart * 1000 <= Date.now()) {
// The event has already started, don't schedule it.
if (timeEnd <= new Date().getTime()) {
// The event has finished already, don't schedule it.
return this.deleteLocalEvent(event.id, siteId); return this.deleteLocalEvent(event.id, siteId);
} }
const reminders = await this.getEventReminders(event.id, siteId); const reminders = await this.getEventReminders(event.id, siteId);
const p2 = reminders.map((reminder: AddonCalendarReminderDBRecord) => const p2 = reminders.map((reminder) => {
this.scheduleEventNotification(event, (reminder.id!), reminder.time, siteId)); const time = reminder.time ? event.timestart - reminder.time : reminder.time;
return this.scheduleEventNotification(event, reminder.id, time, siteId);
});
await Promise.all(p2); await Promise.all(p2);
}); });
@ -1454,20 +1548,29 @@ export class AddonCalendarProvider {
* Store an event in local DB as it is. * Store an event in local DB as it is.
* *
* @param event Event to store. * @param event Event to store.
* @param siteId ID of the site the event belongs to. If not defined, use current site. * @param options Options.
* @return Promise resolved when stored. * @return Promise resolved when stored.
*/ */
async storeEventInLocalDb(event: AddonCalendarGetEventsEvent | AddonCalendarCalendarEvent, siteId?: string): Promise<void> { protected async storeEventInLocalDb(
const site = await CoreSites.getSite(siteId); event: AddonCalendarGetEventsEvent | AddonCalendarCalendarEvent | AddonCalendarEvent,
siteId = site.getId(); options: AddonCalendarStoreEventsOptions = {},
try { ): Promise<void> {
await this.getEventFromLocalDb(event.id, site.id); const site = await CoreSites.getSite(options.siteId);
} catch { const siteId = site.getId();
// Event does not exist. Check if any reminder exists first. const addDefaultReminder = options.addDefaultReminder ?? true;
const reminders = await this.getEventReminders(event.id, siteId);
if (reminders.length == 0) { if (addDefaultReminder) {
this.addEventReminder(event, -1, siteId); // Add default reminder if the event isn't stored already and doesn't have any reminder.
try {
await this.getEventFromLocalDb(event.id, siteId);
} catch {
// Event does not exist.
const reminders = await this.getEventReminders(event.id, siteId);
if (reminders.length === 0) {
// No reminders, create the default one.
this.addEventReminder(event, undefined, siteId);
}
} }
} }
@ -1505,12 +1608,17 @@ export class AddonCalendarProvider {
viewurl: event.viewurl, viewurl: event.viewurl,
isactionevent: event.isactionevent ? 1 : 0, isactionevent: event.isactionevent ? 1 : 0,
url: event.url, url: event.url,
islastday: event.islastday ? 1 : 0,
popupname: event.popupname,
mindaytimestamp: event.mindaytimestamp,
maxdaytimestamp: event.maxdaytimestamp,
draggable: event.draggable ? 1 : 0,
}); });
if ('islastday' in event) {
eventRecord = Object.assign(eventRecord, {
islastday: event.islastday ? 1 : 0,
popupname: event.popupname,
mindaytimestamp: event.mindaytimestamp,
maxdaytimestamp: event.maxdaytimestamp,
draggable: event.draggable ? 1 : 0,
});
}
} else if ('uuid' in event) { } else if ('uuid' in event) {
eventRecord = Object.assign(eventRecord, { eventRecord = Object.assign(eventRecord, {
courseid: event.courseid, courseid: event.courseid,
@ -1527,25 +1635,20 @@ export class AddonCalendarProvider {
* Store events in local DB. * Store events in local DB.
* *
* @param events Events to store. * @param events Events to store.
* @param siteId ID of the site the event belongs to. If not defined, use current site. * @param options Options.
* @return Promise resolved when the events are stored. * @return Promise resolved when the events are stored.
*/ */
protected async storeEventsInLocalDB( protected async storeEventsInLocalDB(
events: (AddonCalendarGetEventsEvent | AddonCalendarCalendarEvent)[], events: (AddonCalendarGetEventsEvent | AddonCalendarCalendarEvent | AddonCalendarEvent)[],
siteId?: string, options: AddonCalendarStoreEventsOptions = {},
): Promise<void> { ): Promise<void> {
const site = await CoreSites.getSite(siteId); await Promise.all(events.map((event) => this.storeEventInLocalDb(event, options)));
siteId = site.getId();
await Promise.all(events.map((event: AddonCalendarGetEventsEvent| AddonCalendarCalendarEvent) =>
// If event does not exist on the DB, schedule the reminder.
this.storeEventInLocalDb(event, siteId)));
} }
/** /**
* Submit a calendar event. * Submit a calendar event.
* *
* @param eventId ID of the event. If undefined/null, create a new event. * @param eventId ID of the event. Negative value to edit offline event. If undefined/null, create a new event.
* @param formData Form data. * @param formData Form data.
* @param timeCreated The time the event was created. Only if modifying a new offline event. * @param timeCreated The time the event was created. Only if modifying a new offline event.
* @param forceOffline True to always save it in offline. * @param forceOffline True to always save it in offline.
@ -1555,19 +1658,26 @@ export class AddonCalendarProvider {
async submitEvent( async submitEvent(
eventId: number | undefined, eventId: number | undefined,
formData: AddonCalendarSubmitCreateUpdateFormDataWSParams, formData: AddonCalendarSubmitCreateUpdateFormDataWSParams,
timeCreated?: number, options: AddonCalendarSubmitEventOptions = {},
forceOffline = false,
siteId?: string,
): Promise<{sent: boolean; event: AddonCalendarOfflineEventDBRecord | AddonCalendarEvent}> { ): Promise<{sent: boolean; event: AddonCalendarOfflineEventDBRecord | AddonCalendarEvent}> {
siteId = siteId || CoreSites.getCurrentSiteId(); const siteId = options.siteId || CoreSites.getCurrentSiteId();
// Function to store the event to be synchronized later. // Function to store the event to be synchronized later.
const storeOffline = (): Promise<{ sent: boolean; event: AddonCalendarOfflineEventDBRecord }> => const storeOffline = async (): Promise<{ sent: boolean; event: AddonCalendarOfflineEventDBRecord }> => {
AddonCalendarOffline.saveEvent(eventId, formData, timeCreated, siteId).then((event) => const event = await AddonCalendarOffline.saveEvent(eventId, formData, siteId);
({ sent: false, event }));
if (forceOffline || !CoreApp.isOnline()) { // Now save the reminders if any.
if (options.reminders) {
await CoreUtils.ignoreErrors(
Promise.all(options.reminders.map((reminder) => this.addEventReminder(event, reminder.time, siteId))),
);
}
return { sent: false, event };
};
if (options.forceOffline || !CoreApp.isOnline()) {
// App is offline, store the event. // App is offline, store the event.
return storeOffline(); return storeOffline();
} }
@ -1579,6 +1689,13 @@ export class AddonCalendarProvider {
try { try {
const event = await this.submitEventOnline(eventId, formData, siteId); const event = await this.submitEventOnline(eventId, formData, siteId);
// Now save the reminders if any.
if (options.reminders) {
await CoreUtils.ignoreErrors(
Promise.all(options.reminders.map((reminder) => this.addEventReminder(event, reminder.time, siteId))),
);
}
return ({ sent: true, event }); return ({ sent: true, event });
} catch (error) { } catch (error) {
if (error && !CoreUtils.isWebServiceError(error)) { if (error && !CoreUtils.isWebServiceError(error)) {
@ -1594,7 +1711,7 @@ export class AddonCalendarProvider {
/** /**
* Submit an event, either to create it or to edit it. It will fail if offline or cannot connect. * Submit an event, either to create it or to edit it. It will fail if offline or cannot connect.
* *
* @param eventId ID of the event. If undefined/null, create a new event. * @param eventId ID of the event. If undefined/null or negative number, create a new event.
* @param formData Form data. * @param formData Form data.
* @param siteId Site ID. If not provided, current site. * @param siteId Site ID. If not provided, current site.
* @return Promise resolved when done. * @return Promise resolved when done.
@ -1605,8 +1722,9 @@ export class AddonCalendarProvider {
siteId?: string, siteId?: string,
): Promise<AddonCalendarEvent> { ): Promise<AddonCalendarEvent> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
// Add data that is "hidden" in web. // Add data that is "hidden" in web.
formData.id = eventId; formData.id = eventId > 0 ? eventId : 0;
formData.userid = site.getUserId(); formData.userid = site.getUserId();
formData.visible = 1; formData.visible = 1;
formData.instance = 0; formData.instance = 0;
@ -1615,12 +1733,14 @@ export class AddonCalendarProvider {
} else { } else {
formData['_qf__core_calendar_local_event_forms_create'] = 1; formData['_qf__core_calendar_local_event_forms_create'] = 1;
} }
const params: AddonCalendarSubmitCreateUpdateFormWSParams = { const params: AddonCalendarSubmitCreateUpdateFormWSParams = {
formdata: CoreUtils.objectToGetParams(formData), formdata: CoreUtils.objectToGetParams(formData),
}; };
const result = const result =
await site.write<AddonCalendarSubmitCreateUpdateFormWSResponse>('core_calendar_submit_create_update_form', params); await site.write<AddonCalendarSubmitCreateUpdateFormWSResponse>('core_calendar_submit_create_update_form', params);
if (result.validationerror) {
if (result.validationerror || !result.event) {
// Simulate a WS error. // Simulate a WS error.
throw new CoreWSError({ throw new CoreWSError({
message: Translate.instant('core.invalidformdata'), message: Translate.instant('core.invalidformdata'),
@ -1628,7 +1748,19 @@ export class AddonCalendarProvider {
}); });
} }
return result.event!; if (eventId < 0) {
// Offline event has been sent. Change reminders eventid if any.
await CoreUtils.ignoreErrors(
site.getDb().updateRecords(REMINDERS_TABLE, { eventid: result.event.id }, { eventid: eventId }),
);
}
if (formData.id === 0) {
// Store the new event in local DB.
await CoreUtils.ignoreErrors(this.storeEventInLocalDb(result.event, { addDefaultReminder: false, siteId }));
}
return result.event;
} }
} }
@ -2088,7 +2220,8 @@ type AddonCalendarSubmitCreateUpdateFormWSParams = {
/** /**
* Form data on AddonCalendarSubmitCreateUpdateFormWSParams. * Form data on AddonCalendarSubmitCreateUpdateFormWSParams.
*/ */
export type AddonCalendarSubmitCreateUpdateFormDataWSParams = Omit<AddonCalendarOfflineEventDBRecord, 'description'> & { export type AddonCalendarSubmitCreateUpdateFormDataWSParams = Omit<AddonCalendarOfflineEventDBRecord, 'id'|'description'> & {
id?: number;
description?: { description?: {
text: string; text: string;
format: number; format: number;
@ -2143,6 +2276,7 @@ export type AddonCalendarEventToDisplay = Partial<AddonCalendarCalendarEvent> &
*/ */
export type AddonCalendarUpdatedEventEvent = { export type AddonCalendarUpdatedEventEvent = {
eventId: number; eventId: number;
oldEventId?: number; // Old event ID. Used when an offline event is sent.
sent?: boolean; sent?: boolean;
}; };
@ -2154,3 +2288,30 @@ type AddonCalendarPushNotificationData = {
reminderId: number; reminderId: number;
siteId: string; siteId: string;
}; };
/**
* Value and unit for reminders.
*/
export type AddonCalendarValueAndUnit = {
value: number;
unit: AddonCalendarReminderUnits;
};
/**
* Options to pass to submit event.
*/
export type AddonCalendarSubmitEventOptions = {
reminders?: {
time: number | null;
}[];
forceOffline?: boolean;
siteId?: string; // Site ID. If not defined, current site.
};
/**
* Options to pass to store events in local DB.
*/
export type AddonCalendarStoreEventsOptions = {
addDefaultReminder?: boolean; // Whether to add default reminder for new events with no reminders. Defaults to true.
siteId?: string; // Site ID. If not defined, current site.
};

View File

@ -135,7 +135,7 @@ export const CALENDAR_OFFLINE_SITE_SCHEMA: CoreSiteSchema = {
}; };
export type AddonCalendarOfflineEventDBRecord = { export type AddonCalendarOfflineEventDBRecord = {
id?: number; // Negative for offline entries. id: number; // Negative for offline entries.
name: string; name: string;
timestart: number; timestart: number;
eventtype: AddonCalendarEventType; eventtype: AddonCalendarEventType;

View File

@ -14,7 +14,8 @@
import { SQLiteDB } from '@classes/sqlitedb'; import { SQLiteDB } from '@classes/sqlitedb';
import { CoreSiteSchema } from '@services/sites'; import { CoreSiteSchema } from '@services/sites';
import { AddonCalendarEventType } from '../calendar'; import { CoreUtils } from '@services/utils/utils';
import { AddonCalendar, AddonCalendarEventType } from '../calendar';
/** /**
* Database variables for AddonDatabase service. * Database variables for AddonDatabase service.
@ -23,7 +24,7 @@ export const EVENTS_TABLE = 'addon_calendar_events_3';
export const REMINDERS_TABLE = 'addon_calendar_reminders'; export const REMINDERS_TABLE = 'addon_calendar_reminders';
export const CALENDAR_SITE_SCHEMA: CoreSiteSchema = { export const CALENDAR_SITE_SCHEMA: CoreSiteSchema = {
name: 'AddonCalendarProvider', name: 'AddonCalendarProvider',
version: 3, version: 4,
canBeCleared: [EVENTS_TABLE], canBeCleared: [EVENTS_TABLE],
tables: [ tables: [
{ {
@ -199,8 +200,9 @@ export const CALENDAR_SITE_SCHEMA: CoreSiteSchema = {
], ],
}, },
], ],
async migrate(db: SQLiteDB, oldVersion: number): Promise<void> { async migrate(db: SQLiteDB, oldVersion: number, siteId: string): Promise<void> {
if (oldVersion < 3) { if (oldVersion < 3) {
// Migrate calendar events. New format @since 3.7.
let oldTable = 'addon_calendar_events_2'; let oldTable = 'addon_calendar_events_2';
try { try {
@ -212,11 +214,51 @@ export const CALENDAR_SITE_SCHEMA: CoreSiteSchema = {
await db.migrateTable(oldTable, EVENTS_TABLE); await db.migrateTable(oldTable, EVENTS_TABLE);
} }
if (oldVersion < 4) {
// Migrate reminders. New format @since 4.0.
const defaultTime = await CoreUtils.ignoreErrors(AddonCalendar.getDefaultNotificationTime(siteId));
if (defaultTime) {
// Convert from minutes to seconds.
AddonCalendar.setDefaultNotificationTime(defaultTime * 60, siteId);
}
const records = await db.getAllRecords<AddonCalendarReminderDBRecord>(REMINDERS_TABLE);
const events: Record<number, AddonCalendarEventDBRecord> = {};
await Promise.all(records.map(async (record) => {
// Get the event to compare the reminder time with the event time.
if (!events[record.eventid]) {
try {
events[record.eventid] = await db.getRecord(EVENTS_TABLE, { id: record.eventid });
} catch {
// Event not found in local DB, shouldn't happen. Delete the reminder.
await db.deleteRecords(REMINDERS_TABLE, { id: record.id });
return;
}
}
if (!record.time || record.time === -1) {
// Default reminder. Use null now.
record.time = null;
} else if (record.time > events[record.eventid].timestart) {
// Reminder is after the event, delete it.
await db.deleteRecords(REMINDERS_TABLE, { id: record.id });
return;
} else {
record.time = events[record.eventid].timestart - record.time;
}
return this.insertRecord(REMINDERS_TABLE, record);
}));
}
}, },
}; };
export type AddonCalendarEventDBRecord = { export type AddonCalendarEventDBRecord = {
id?: number; id: number;
name: string; name: string;
description: string; description: string;
eventtype: AddonCalendarEventType; eventtype: AddonCalendarEventType;
@ -257,7 +299,7 @@ export type AddonCalendarEventDBRecord = {
}; };
export type AddonCalendarReminderDBRecord = { export type AddonCalendarReminderDBRecord = {
id?: number; id: number;
eventid: number; eventid: number;
time: number; time: number | null; // Number of seconds before the event, null for default time.
}; };

View File

@ -147,9 +147,10 @@ export class AddonMessagesSyncProvider extends CoreSyncBaseProvider<AddonMessage
const syncId = this.getSyncId(conversationId, userId); const syncId = this.getSyncId(conversationId, userId);
if (this.isSyncing(syncId, siteId)) { const currentSyncPromise = this.getOngoingSync(syncId, siteId);
if (currentSyncPromise) {
// There's already a sync ongoing for this conversation, return the promise. // There's already a sync ongoing for this conversation, return the promise.
return this.getOngoingSync(syncId, siteId)!; return currentSyncPromise;
} }
return this.addOngoingSync(syncId, this.performSyncDiscussion(conversationId, userId, siteId), siteId); return this.addOngoingSync(syncId, this.performSyncDiscussion(conversationId, userId, siteId), siteId);

View File

@ -163,9 +163,10 @@ export class AddonModAssignSyncProvider extends CoreCourseActivitySyncBaseProvid
async syncAssign(assignId: number, siteId?: string): Promise<AddonModAssignSyncResult> { async syncAssign(assignId: number, siteId?: string): Promise<AddonModAssignSyncResult> {
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
if (this.isSyncing(assignId, siteId)) { const currentSyncPromise = this.getOngoingSync(assignId, siteId);
if (currentSyncPromise) {
// There's already a sync ongoing for this assign, return the promise. // There's already a sync ongoing for this assign, return the promise.
return this.getOngoingSync(assignId, siteId)!; return currentSyncPromise;
} }
// Verify that assign isn't blocked. // Verify that assign isn't blocked.

View File

@ -122,9 +122,10 @@ export class AddonModChoiceSyncProvider extends CoreCourseActivitySyncBaseProvid
siteId = site.getId(); siteId = site.getId();
const syncId = this.getSyncId(choiceId, userId); const syncId = this.getSyncId(choiceId, userId);
if (this.isSyncing(syncId, siteId)) { const currentSyncPromise = this.getOngoingSync(syncId, siteId);
if (currentSyncPromise) {
// There's already a sync ongoing for this discussion, return the promise. // There's already a sync ongoing for this discussion, return the promise.
return this.getOngoingSync(syncId, siteId)!; return currentSyncPromise;
} }
this.logger.debug(`Try to sync choice '${choiceId}' for user '${userId}'`); this.logger.debug(`Try to sync choice '${choiceId}' for user '${userId}'`);

View File

@ -135,9 +135,10 @@ export class AddonModDataSyncProvider extends CoreCourseActivitySyncBaseProvider
syncDatabase(dataId: number, siteId?: string): Promise<AddonModDataSyncResult> { syncDatabase(dataId: number, siteId?: string): Promise<AddonModDataSyncResult> {
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
if (this.isSyncing(dataId, siteId)) { const currentSyncPromise = this.getOngoingSync(dataId, siteId);
if (currentSyncPromise) {
// There's already a sync ongoing for this database, return the promise. // There's already a sync ongoing for this database, return the promise.
return this.getOngoingSync(dataId, siteId)!; return currentSyncPromise;
} }
// Verify that database isn't blocked. // Verify that database isn't blocked.

View File

@ -130,9 +130,10 @@ export class AddonModFeedbackSyncProvider extends CoreCourseActivitySyncBaseProv
syncFeedback(feedbackId: number, siteId?: string): Promise<AddonModFeedbackSyncResult> { syncFeedback(feedbackId: number, siteId?: string): Promise<AddonModFeedbackSyncResult> {
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
if (this.isSyncing(feedbackId, siteId)) { const currentSyncPromise = this.getOngoingSync(feedbackId, siteId);
if (currentSyncPromise) {
// There's already a sync ongoing for this feedback, return the promise. // There's already a sync ongoing for this feedback, return the promise.
return this.getOngoingSync(feedbackId, siteId)!; return currentSyncPromise;
} }
// Verify that feedback isn't blocked. // Verify that feedback isn't blocked.

View File

@ -198,10 +198,11 @@ export class AddonModForumSyncProvider extends CoreCourseActivitySyncBaseProvide
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
const syncId = this.getForumSyncId(forumId, userId); const syncId = this.getForumSyncId(forumId, userId);
const currentSyncPromise = this.getOngoingSync(syncId, siteId);
if (this.isSyncing(syncId, siteId)) { if (currentSyncPromise) {
// There's already a sync ongoing for this discussion, return the promise. // There's already a sync ongoing for this discussion, return the promise.
return this.getOngoingSync(syncId, siteId)!; return currentSyncPromise;
} }
// Verify that forum isn't blocked. // Verify that forum isn't blocked.
@ -429,10 +430,11 @@ export class AddonModForumSyncProvider extends CoreCourseActivitySyncBaseProvide
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
const syncId = this.getDiscussionSyncId(discussionId, userId); const syncId = this.getDiscussionSyncId(discussionId, userId);
const currentSyncPromise = this.getOngoingSync(syncId, siteId);
if (this.isSyncing(syncId, siteId)) { if (currentSyncPromise) {
// There's already a sync ongoing for this discussion, return the promise. // There's already a sync ongoing for this discussion, return the promise.
return this.getOngoingSync(syncId, siteId)!; return currentSyncPromise;
} }
// Verify that forum isn't blocked. // Verify that forum isn't blocked.

View File

@ -144,9 +144,10 @@ export class AddonModGlossarySyncProvider extends CoreCourseActivitySyncBaseProv
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
const syncId = this.getGlossarySyncId(glossaryId, userId); const syncId = this.getGlossarySyncId(glossaryId, userId);
if (this.isSyncing(syncId, siteId)) { const currentSyncPromise = this.getOngoingSync(syncId, siteId);
if (currentSyncPromise) {
// There's already a sync ongoing for this glossary, return the promise. // There's already a sync ongoing for this glossary, return the promise.
return this.getOngoingSync(syncId, siteId)!; return currentSyncPromise;
} }
// Verify that glossary isn't blocked. // Verify that glossary isn't blocked.

View File

@ -108,9 +108,10 @@ export class AddonModH5PActivitySyncProvider extends CoreCourseActivitySyncBaseP
throw new CoreNetworkError(); throw new CoreNetworkError();
} }
if (this.isSyncing(contextId, siteId)) { const currentSyncPromise = this.getOngoingSync(contextId, siteId);
if (currentSyncPromise) {
// There's already a sync ongoing for this discussion, return the promise. // There's already a sync ongoing for this discussion, return the promise.
return this.getOngoingSync(contextId, siteId)!; return currentSyncPromise;
} }
return this.addOngoingSync(contextId, this.syncActivityData(contextId, siteId), siteId); return this.addOngoingSync(contextId, this.syncActivityData(contextId, siteId), siteId);

View File

@ -263,9 +263,10 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
syncQuiz(quiz: AddonModQuizQuizWSData, askPreflight?: boolean, siteId?: string): Promise<AddonModQuizSyncResult> { syncQuiz(quiz: AddonModQuizQuizWSData, askPreflight?: boolean, siteId?: string): Promise<AddonModQuizSyncResult> {
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
if (this.isSyncing(quiz.id, siteId)) { const currentSyncPromise = this.getOngoingSync(quiz.id, siteId);
if (currentSyncPromise) {
// There's already a sync ongoing for this quiz, return the promise. // There's already a sync ongoing for this quiz, return the promise.
return this.getOngoingSync(quiz.id, siteId)!; return currentSyncPromise;
} }
// Verify that quiz isn't blocked. // Verify that quiz isn't blocked.

View File

@ -579,9 +579,10 @@ export class AddonModScormSyncProvider extends CoreCourseActivitySyncBaseProvide
syncScorm(scorm: AddonModScormScorm, siteId?: string): Promise<AddonModScormSyncResult> { syncScorm(scorm: AddonModScormScorm, siteId?: string): Promise<AddonModScormSyncResult> {
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
if (this.isSyncing(scorm.id, siteId)) { const currentSyncPromise = this.getOngoingSync(scorm.id, siteId);
if (currentSyncPromise) {
// There's already a sync ongoing for this SCORM, return the promise. // There's already a sync ongoing for this SCORM, return the promise.
return this.getOngoingSync(scorm.id, siteId)!; return currentSyncPromise;
} }
// Verify that SCORM isn't blocked. // Verify that SCORM isn't blocked.

View File

@ -124,10 +124,11 @@ export class AddonModSurveySyncProvider extends CoreCourseActivitySyncBaseProvid
userId = userId || site.getUserId(); userId = userId || site.getUserId();
const syncId = this.getSyncId(surveyId, userId); const syncId = this.getSyncId(surveyId, userId);
const currentSyncPromise = this.getOngoingSync(syncId, siteId);
if (this.isSyncing(syncId, siteId)) { if (currentSyncPromise) {
// There's already a sync ongoing for this site, return the promise. // There's already a sync ongoing for this site, return the promise.
return this.getOngoingSync(syncId, siteId)!; return currentSyncPromise;
} }
this.logger.debug(`Try to sync survey '${surveyId}' for user '${userId}'`); this.logger.debug(`Try to sync survey '${surveyId}' for user '${userId}'`);

View File

@ -167,10 +167,11 @@ export class AddonModWikiSyncProvider extends CoreSyncBaseProvider<AddonModWikiS
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
const subwikiBlockId = this.getSubwikiBlockId(subwikiId, wikiId, userId, groupId); const subwikiBlockId = this.getSubwikiBlockId(subwikiId, wikiId, userId, groupId);
const currentSyncPromise = this.getOngoingSync(subwikiBlockId, siteId);
if (this.isSyncing(subwikiBlockId, siteId)) { if (currentSyncPromise) {
// There's already a sync ongoing for this subwiki, return the promise. // There's already a sync ongoing for this subwiki, return the promise.
return this.getOngoingSync(subwikiBlockId, siteId)!; return currentSyncPromise;
} }
// Verify that subwiki isn't blocked. // Verify that subwiki isn't blocked.

View File

@ -129,9 +129,10 @@ export class AddonModWorkshopSyncProvider extends CoreSyncBaseProvider<AddonModW
syncWorkshop(workshopId: number, siteId?: string): Promise<AddonModWorkshopSyncResult> { syncWorkshop(workshopId: number, siteId?: string): Promise<AddonModWorkshopSyncResult> {
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
if (this.isSyncing(workshopId, siteId)) { const currentSyncPromise = this.getOngoingSync(workshopId, siteId);
if (currentSyncPromise) {
// There's already a sync ongoing for this discussion, return the promise. // There's already a sync ongoing for this discussion, return the promise.
return this.getOngoingSync(workshopId, siteId)!; return currentSyncPromise;
} }
// Verify that workshop isn't blocked. // Verify that workshop isn't blocked.

View File

@ -112,9 +112,10 @@ export class AddonNotesSyncProvider extends CoreSyncBaseProvider<AddonNotesSyncR
syncNotes(courseId: number, siteId?: string): Promise<AddonNotesSyncResult> { syncNotes(courseId: number, siteId?: string): Promise<AddonNotesSyncResult> {
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
if (this.isSyncing(courseId, siteId)) { const currentSyncPromise = this.getOngoingSync(courseId, siteId);
if (currentSyncPromise) {
// There's already a sync ongoing for notes, return the promise. // There's already a sync ongoing for notes, return the promise.
return this.getOngoingSync(courseId, siteId)!; return currentSyncPromise;
} }
this.logger.debug('Try to sync notes for course ' + courseId); this.logger.debug('Try to sync notes for course ' + courseId);

View File

@ -105,7 +105,7 @@ export class CoreSyncBaseProvider<T = void> {
try { try {
return await promise; return await promise;
} finally { } finally {
delete this.syncPromises[siteId!][uniqueId]; delete this.syncPromises[siteId][uniqueId];
} }
} }
@ -133,15 +133,11 @@ export class CoreSyncBaseProvider<T = void> {
*/ */
getOngoingSync(id: string | number, siteId?: string): Promise<T> | undefined { getOngoingSync(id: string | number, siteId?: string): Promise<T> | undefined {
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
if (!this.isSyncing(id, siteId)) {
return;
}
// There's already a sync ongoing for this id, return the promise.
const uniqueId = this.getUniqueSyncId(id); const uniqueId = this.getUniqueSyncId(id);
return this.syncPromises[siteId][uniqueId]; if (this.syncPromises[siteId] && this.syncPromises[siteId][uniqueId] !== undefined) {
return this.syncPromises[siteId][uniqueId];
}
} }
/** /**
@ -223,11 +219,7 @@ export class CoreSyncBaseProvider<T = void> {
* @return Whether it's synchronizing. * @return Whether it's synchronizing.
*/ */
isSyncing(id: string | number, siteId?: string): boolean { isSyncing(id: string | number, siteId?: string): boolean {
siteId = siteId || CoreSites.getCurrentSiteId(); return !!this.getOngoingSync(id, siteId);
const uniqueId = this.getUniqueSyncId(id);
return !!(this.syncPromises[siteId] && this.syncPromises[siteId][uniqueId]);
} }
/** /**
@ -331,10 +323,10 @@ export class CoreSyncBaseProvider<T = void> {
*/ */
protected get componentTranslate(): string { protected get componentTranslate(): string {
if (!this.componentTranslateInternal) { if (!this.componentTranslateInternal) {
this.componentTranslateInternal = Translate.instant(this.componentTranslatableString); this.componentTranslateInternal = <string> Translate.instant(this.componentTranslatableString);
} }
return this.componentTranslateInternal!; return this.componentTranslateInternal;
} }
} }

View File

@ -591,8 +591,7 @@ export class CoreSite {
return response; return response;
} catch (error) { } catch (error) {
if (error.errorcode == 'invalidtoken' || if (CoreUtils.isExpiredTokenError(error)) {
(error.errorcode == 'accessexception' && error.message.indexOf('Invalid token - token expired') > -1)) {
if (initialToken !== this.token && !retrying) { if (initialToken !== this.token && !retrying) {
// Token has changed, retry with the new token. // Token has changed, retry with the new token.
preSets.getFromCache = false; // Don't check cache now. Also, it will skip ongoingRequests. preSets.getFromCache = false; // Don't check cache now. Also, it will skip ongoingRequests.

View File

@ -158,10 +158,11 @@ export class CoreCommentsSyncProvider extends CoreSyncBaseProvider<CoreCommentsS
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
const syncId = this.getSyncId(contextLevel, instanceId, component, itemId, area); const syncId = this.getSyncId(contextLevel, instanceId, component, itemId, area);
const currentSyncPromise = this.getOngoingSync(syncId, siteId);
if (this.isSyncing(syncId, siteId)) { if (currentSyncPromise) {
// There's already a sync ongoing for comments, return the promise. // There's already a sync ongoing for comments, return the promise.
return this.getOngoingSync(syncId, siteId)!; return currentSyncPromise;
} }
this.logger.debug('Try to sync comments ' + syncId + ' in site ' + siteId); this.logger.debug('Try to sync comments ' + syncId + ' in site ' + siteId);

View File

@ -117,9 +117,10 @@ export class CoreCourseSyncProvider extends CoreSyncBaseProvider<CoreCourseSyncR
async syncCourse(courseId: number, siteId?: string): Promise<CoreCourseSyncResult> { async syncCourse(courseId: number, siteId?: string): Promise<CoreCourseSyncResult> {
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
if (this.isSyncing(courseId, siteId)) { const currentSyncPromise = this.getOngoingSync(courseId, siteId);
if (currentSyncPromise) {
// There's already a sync ongoing for this discussion, return the promise. // There's already a sync ongoing for this discussion, return the promise.
return this.getOngoingSync(courseId, siteId)!; return currentSyncPromise;
} }
this.logger.debug(`Try to sync course '${courseId}'`); this.logger.debug(`Try to sync course '${courseId}'`);

View File

@ -524,7 +524,13 @@ export class CorePushNotificationsProvider {
try { try {
response = await site.write<CoreUserRemoveUserDeviceWSResponse>('core_user_remove_user_device', data); response = await site.write<CoreUserRemoveUserDeviceWSResponse>('core_user_remove_user_device', data);
} catch (error) { } catch (error) {
if (CoreUtils.isWebServiceError(error)) { if (CoreUtils.isWebServiceError(error) || CoreUtils.isExpiredTokenError(error)) {
// Cannot unregister. Don't try again.
await CoreUtils.ignoreErrors(db.deleteRecords(PENDING_UNREGISTER_TABLE_NAME, {
token: site.getToken(),
siteid: site.getId(),
}));
throw error; throw error;
} }

View File

@ -157,9 +157,10 @@ export class CoreRatingSyncProvider extends CoreSyncBaseProvider<CoreRatingSyncI
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
const syncId = this.getItemSetSyncId(component, ratingArea, contextLevel, instanceId, itemSetId); const syncId = this.getItemSetSyncId(component, ratingArea, contextLevel, instanceId, itemSetId);
if (this.isSyncing(syncId, siteId)) { const currentSyncPromise = this.getOngoingSync(syncId, siteId);
if (currentSyncPromise) {
// There's already a sync ongoing for this item set, return the promise. // There's already a sync ongoing for this item set, return the promise.
return this.getOngoingSync(syncId, siteId)!; return currentSyncPromise;
} }
this.logger.debug(`Try to sync ratings of component '${component}' rating area '${ratingArea}'` + this.logger.debug(`Try to sync ratings of component '${component}' rating area '${ratingArea}'` +

View File

@ -54,10 +54,11 @@ export class CoreUserSyncProvider extends CoreSyncBaseProvider<string[]> {
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
const syncId = 'preferences'; const syncId = 'preferences';
const currentSyncPromise = this.getOngoingSync(syncId, siteId);
if (this.isSyncing(syncId, siteId)) { if (currentSyncPromise) {
// There's already a sync ongoing, return the promise. // There's already a sync ongoing, return the promise.
return this.getOngoingSync(syncId, siteId)!; return currentSyncPromise;
} }
this.logger.debug('Try to sync user preferences'); this.logger.debug('Try to sync user preferences');

View File

@ -60,6 +60,7 @@
"coursedetails": "Course details", "coursedetails": "Course details",
"coursenogroups": "You are not a member of any group of this course.", "coursenogroups": "You are not a member of any group of this course.",
"currentdevice": "Current device", "currentdevice": "Current device",
"custom": "Custom",
"datastoredoffline": "Data stored in the device because it couldn't be sent. It will be sent automatically later.", "datastoredoffline": "Data stored in the device because it couldn't be sent. It will be sent automatically later.",
"date": "Date", "date": "Date",
"day": "day", "day": "day",
@ -157,6 +158,8 @@
"maxsizeandattachments": "Maximum file size: {{$a.size}}, maximum number of files: {{$a.attachments}}", "maxsizeandattachments": "Maximum file size: {{$a.size}}, maximum number of files: {{$a.attachments}}",
"min": "min", "min": "min",
"mins": "mins", "mins": "mins",
"minute": "minute",
"minutes": "minutes",
"misc": "Miscellaneous", "misc": "Miscellaneous",
"mod_assign": "Assignment", "mod_assign": "Assignment",
"mod_assignment": "Assignment 2.2 (Disabled)", "mod_assignment": "Assignment 2.2 (Disabled)",
@ -330,6 +333,8 @@
"viewprofile": "View profile", "viewprofile": "View profile",
"warningofflinedatadeleted": "Offline data from {{component}} '{{name}}' has been deleted. {{error}}", "warningofflinedatadeleted": "Offline data from {{component}} '{{name}}' has been deleted. {{error}}",
"warnopeninbrowser": "<p>You are about to leave the app to open the following URL in your device's browser. Do you want to continue?</p>\n<p><b>{{url}}</b></p>", "warnopeninbrowser": "<p>You are about to leave the app to open the following URL in your device's browser. Do you want to continue?</p>\n<p><b>{{url}}</b></p>",
"week": "week",
"weeks": "weeks",
"whatisyourage": "What is your age?", "whatisyourage": "What is your age?",
"wheredoyoulive": "In which country do you live?", "wheredoyoulive": "In which country do you live?",
"whoissiteadmin": "\"Site Administrators\" are the people who manage the Moodle at your school/university/company or learning organisation. If you don't know how to contact them, please contact your teachers/trainers.", "whoissiteadmin": "\"Site Administrators\" are the people who manage the Moodle at your school/university/company or learning organisation. If you don't know how to contact them, please contact your teachers/trainers.",

View File

@ -653,9 +653,19 @@ export class CoreLocalNotificationsProvider {
*/ */
async trigger(notification: ILocalNotification): Promise<number> { async trigger(notification: ILocalNotification): Promise<number> {
const db = await this.appDB; const db = await this.appDB;
let time = Date.now();
if (notification.trigger?.at) {
// The type says "at" is a Date, but in Android we can receive timestamps instead.
if (typeof notification.trigger.at === 'number') {
time = <number> notification.trigger.at;
} else {
time = notification.trigger.at.getTime();
}
}
const entry = { const entry = {
id: notification.id, id: notification.id,
at: notification.trigger && notification.trigger.at ? notification.trigger.at.getTime() : Date.now(), at: time,
}; };
return db.insertRecord(TRIGGERED_TABLE_NAME, entry); return db.insertRecord(TRIGGERED_TABLE_NAME, entry);

View File

@ -850,7 +850,7 @@ export class CoreUtilsProvider {
} }
/** /**
* Given an error returned by a WS call, check if the error is generated by the app or it has been returned by the WebSwervice. * Given an error returned by a WS call, check if the error is generated by the app or it has been returned by the WebService.
* *
* @param error Error to check. * @param error Error to check.
* @return Whether the error was returned by the WebService. * @return Whether the error was returned by the WebService.
@ -858,10 +858,22 @@ export class CoreUtilsProvider {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
isWebServiceError(error: any): boolean { isWebServiceError(error: any): boolean {
return error && (typeof error.warningcode != 'undefined' || (typeof error.errorcode != 'undefined' && return error && (typeof error.warningcode != 'undefined' || (typeof error.errorcode != 'undefined' &&
error.errorcode != 'invalidtoken' && error.errorcode != 'userdeleted' && error.errorcode != 'upgraderunning' && error.errorcode != 'userdeleted' && error.errorcode != 'upgraderunning' &&
error.errorcode != 'forcepasswordchangenotice' && error.errorcode != 'usernotfullysetup' && error.errorcode != 'forcepasswordchangenotice' && error.errorcode != 'usernotfullysetup' &&
error.errorcode != 'sitepolicynotagreed' && error.errorcode != 'sitemaintenance' && error.errorcode != 'sitepolicynotagreed' && error.errorcode != 'sitemaintenance' &&
(error.errorcode != 'accessexception' || error.message.indexOf('Invalid token - token expired') == -1))); !this.isExpiredTokenError(error)));
}
/**
* Given an error returned by a WS call, check if the error is a token expired error.
*
* @param error Error to check.
* @return Whether the error is a token expired error.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isExpiredTokenError(error: any): boolean {
return error.errorcode === 'invalidtoken' ||
(error.errorcode === 'accessexception' && error.message.includes('Invalid token - token expired'));
} }
/** /**