diff --git a/local_moodleappbehat/tests/behat/behat_app.php b/local_moodleappbehat/tests/behat/behat_app.php index 527421934..531440a23 100644 --- a/local_moodleappbehat/tests/behat/behat_app.php +++ b/local_moodleappbehat/tests/behat/behat_app.php @@ -958,4 +958,46 @@ class behat_app extends behat_app_helper { $this->getSession()->switchToWindow($windowNames[1]); } + + /** + * Check if a notification has been triggered and is present. + * + * @Then /^a notification with title (".+") is( not)? present in the app$/ + * @param string $title Notification title + * @param bool $not Whether assert that the notification was not found + */ + public function notification_present_in_the_app(string $title, bool $not = false) { + $result = $this->runtime_js("notificationIsPresentWithText($title)"); + + if ($not && $result === 'YES') { + throw new ExpectationException("Notification is present", $this->getSession()->getDriver()); + } + + if (!$not && $result === 'NO') { + throw new ExpectationException("Notification is not present", $this->getSession()->getDriver()); + } + + if ($result !== 'YES' && $result !== 'NO') { + throw new DriverException('Error checking notification - ' . $result); + } + + return true; + } + + /** + * Close a notification present in the app + * + * @Then /^I close a notification with title (".+") in the app$/ + * @param string $title Notification title + */ + public function close_notification_app(string $title) { + $result = $this->runtime_js("closeNotification($title)"); + + if ($result !== 'OK') { + throw new DriverException('Error closing notification - ' . $result); + } + + return true; + } + } diff --git a/scripts/langindex.json b/scripts/langindex.json index 009c539ca..d99604aae 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -147,7 +147,6 @@ "addon.calendar.sunday": "calendar", "addon.calendar.thu": "calendar", "addon.calendar.thursday": "calendar", - "addon.calendar.timebefore": "local_moodlemobileapp", "addon.calendar.today": "calendar", "addon.calendar.tomorrow": "calendar", "addon.calendar.tue": "calendar", @@ -161,7 +160,6 @@ "addon.calendar.typeopen": "calendar", "addon.calendar.typesite": "calendar", "addon.calendar.typeuser": "calendar", - "addon.calendar.units": "qtype_numerical", "addon.calendar.upcomingevents": "calendar", "addon.calendar.userevents": "calendar", "addon.calendar.wed": "calendar", @@ -2154,6 +2152,18 @@ "core.rating.ratings": "rating", "core.redirectingtosite": "local_moodlemobileapp", "core.refresh": "moodle", + "core.reminders.atthetime": "local_moodlemobileapp", + "core.reminders.custom": "local_moodlemobileapp", + "core.reminders.customreminder": "local_moodlemobileapp", + "core.reminders.delete": "moodle", + "core.reminders.reminderset": "local_moodlemobileapp", + "core.reminders.reminderunset": "local_moodlemobileapp", + "core.reminders.setareminder": "local_moodlemobileapp", + "core.reminders.setareminderfor": "local_moodlemobileapp", + "core.reminders.setreminder": "local_moodlemobileapp", + "core.reminders.timebefore": "local_moodlemobileapp", + "core.reminders.units": "qtype_numerical", + "core.reminders.value": "local_moodlemobileapp", "core.remove": "moodle", "core.removefiles": "local_moodlemobileapp", "core.required": "moodle", diff --git a/src/addons/calendar/calendar.module.ts b/src/addons/calendar/calendar.module.ts index 3f4020e31..408011879 100644 --- a/src/addons/calendar/calendar.module.ts +++ b/src/addons/calendar/calendar.module.ts @@ -70,7 +70,7 @@ const mainMenuChildrenRoutes: Routes = [ await AddonCalendar.initialize(); - AddonCalendar.scheduleAllSitesEventsNotifications(); + AddonCalendar.updateAllSitesEventReminders(); }, }, ], diff --git a/src/addons/calendar/components/components.module.ts b/src/addons/calendar/components/components.module.ts index 68b2bdee2..fffd48aa8 100644 --- a/src/addons/calendar/components/components.module.ts +++ b/src/addons/calendar/components/components.module.ts @@ -19,25 +19,20 @@ import { CoreSharedModule } from '@/core/shared.module'; import { AddonCalendarCalendarComponent } from './calendar/calendar'; import { AddonCalendarUpcomingEventsComponent } from './upcoming-events/upcoming-events'; import { AddonCalendarFilterComponent } from './filter/filter'; -import { AddonCalendarReminderTimeModalComponent } from './reminder-time-modal/reminder-time-modal'; @NgModule({ declarations: [ AddonCalendarCalendarComponent, AddonCalendarUpcomingEventsComponent, AddonCalendarFilterComponent, - AddonCalendarReminderTimeModalComponent, ], imports: [ CoreSharedModule, ], - providers: [ - ], exports: [ AddonCalendarCalendarComponent, AddonCalendarUpcomingEventsComponent, AddonCalendarFilterComponent, - AddonCalendarReminderTimeModalComponent, ], }) export class AddonCalendarComponentsModule {} diff --git a/src/addons/calendar/components/reminder-time-modal/reminder-time-modal.html b/src/addons/calendar/components/reminder-time-modal/reminder-time-modal.html deleted file mode 100644 index 1071b92d9..000000000 --- a/src/addons/calendar/components/reminder-time-modal/reminder-time-modal.html +++ /dev/null @@ -1,62 +0,0 @@ - - - -

{{ 'addon.calendar.reminders' | translate }}

-
- - - - - -
-
- -
- - - - -

{{ 'core.settings.disabled' | translate }}

-
- -
- - -

{{ option.label }}

-
- -
- - - - -

{{ 'core.custom' | translate }}

-
- -
- - - -
- - - - - - - - - {{ option.label | translate }} - - -
-
-
- - - {{ 'core.done' | translate }} - -
-
diff --git a/src/addons/calendar/components/reminder-time-modal/reminder-time-modal.ts b/src/addons/calendar/components/reminder-time-modal/reminder-time-modal.ts deleted file mode 100644 index 1a10a9c27..000000000 --- a/src/addons/calendar/components/reminder-time-modal/reminder-time-modal.ts +++ /dev/null @@ -1,174 +0,0 @@ -// (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 { - 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); - } - } - - /** - * Custom value input clicked. - * - * @param ev Click event. - */ - async customInputClicked(ev: Event): Promise { - if (this.radioValue === 'custom') { - return; - } - - this.radioValue = 'custom'; - - const target = ev.target; - if (target) { - CoreDomUtils.focusElement(target); - } - } - -} diff --git a/src/addons/calendar/lang.json b/src/addons/calendar/lang.json index a71e8921b..c448ad2db 100644 --- a/src/addons/calendar/lang.json +++ b/src/addons/calendar/lang.json @@ -41,7 +41,6 @@ "noevents": "There are no events", "nopermissiontoupdatecalendar": "Sorry, but you do not have permission to update the calendar event.", "reminders": "Reminders", - "units": "Units", "repeatedevents": "Repeated events", "repeateditall": "Also apply changes to the other {{$a}} events in this repeat series", "repeateditthis": "Apply changes to this event only", @@ -55,7 +54,6 @@ "sunday": "Sunday", "thu": "Thu", "thursday": "Thursday", - "timebefore": "{{value}} {{units}} before", "today": "Today", "tomorrow": "Tomorrow", "tue": "Tue", diff --git a/src/addons/calendar/pages/edit-event/edit-event.html b/src/addons/calendar/pages/edit-event/edit-event.html index fe0a30baa..912194c76 100644 --- a/src/addons/calendar/pages/edit-event/edit-event.html +++ b/src/addons/calendar/pages/edit-event/edit-event.html @@ -113,7 +113,7 @@ - +

{{ 'addon.calendar.reminders' | translate }}

diff --git a/src/addons/calendar/pages/edit-event/edit-event.page.ts b/src/addons/calendar/pages/edit-event/edit-event.page.ts index b76cc4f96..79190818a 100644 --- a/src/addons/calendar/pages/edit-event/edit-event.page.ts +++ b/src/addons/calendar/pages/edit-event/edit-event.page.ts @@ -33,7 +33,7 @@ import { AddonCalendarSubmitCreateUpdateFormDataWSParams, } from '../../services/calendar'; import { AddonCalendarOffline } from '../../services/calendar-offline'; -import { AddonCalendarEventReminder, AddonCalendarEventTypeOption, AddonCalendarHelper } from '../../services/calendar-helper'; +import { AddonCalendarEventTypeOption, AddonCalendarHelper } from '../../services/calendar-helper'; import { AddonCalendarSync, AddonCalendarSyncProvider } from '../../services/calendar-sync'; import { CoreSite } from '@classes/site'; import { Translate } from '@singletons'; @@ -43,8 +43,8 @@ import { CoreError } from '@classes/errors/error'; import { CoreNavigator } from '@services/navigator'; import { CanLeave } from '@guards/can-leave'; import { CoreForms } from '@singletons/form'; -import { CoreLocalNotifications } from '@services/local-notifications'; -import { AddonCalendarReminderTimeModalComponent } from '@addons/calendar/components/reminder-time-modal/reminder-time-modal'; +import { CoreReminders, CoreRemindersService, CoreRemindersUnits } from '@features/reminders/services/reminders'; +import { CoreRemindersSetReminderMenuComponent } from '@features/reminders/components/set-reminder-menu/set-reminder-menu'; /** * Page that displays a form to create/edit an event. @@ -85,7 +85,7 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave { descriptionControl: FormControl; // Reminders. - notificationsEnabled = false; + remindersEnabled = false; reminders: AddonCalendarEventCandidateReminder[] = []; protected courseId!: number; @@ -100,7 +100,7 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave { protected fb: FormBuilder, ) { this.currentSite = CoreSites.getRequiredCurrentSite(); - this.notificationsEnabled = CoreLocalNotifications.isAvailable(); + this.remindersEnabled = CoreReminders.isEnabled(); this.errors = { required: Translate.instant('core.required'), }; @@ -133,7 +133,7 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave { } /** - * Component being initialized. + * @inheritdoc */ ngOnInit(): void { this.eventId = CoreNavigator.getRouteNumberParam('eventId') || undefined; @@ -156,7 +156,6 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave { /** * Fetch the data needed to render the form. * - * @param refresh Whether it's refreshing data. * @return Promise resolved when done. */ protected async fetchData(): Promise { @@ -273,15 +272,14 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave { return; } - const courseFillterFullname = (course: CoreCourseSearchedData | CoreEnrolledCourseData): Promise => - CoreFilterHelper.getFiltersAndFormatText(course.fullname, 'course', course.id) - .then((result) => { - course.fullname = result.text; - - return; - }).catch(() => { - // Ignore errors. - }); + const courseFillFullname = async (course: CoreCourseSearchedData | CoreEnrolledCourseData): Promise => { + try { + const result = await CoreFilterHelper.getFiltersAndFormatText(course.fullname, 'course', course.id); + course.fullname = result.text; + } catch { + // Ignore errors. + } + }; if (this.showAll) { // Remove site home from the list of courses. @@ -296,9 +294,9 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave { // Format the name of the courses. if ('contacts' in courses[0]) { - await Promise.all((courses as CoreCourseSearchedData[]).map(courseFillterFullname)); + await Promise.all((courses as CoreCourseSearchedData[]).map(courseFillFullname)); } else { - await Promise.all((courses as CoreEnrolledCourseData[]).map(courseFillterFullname)); + await Promise.all((courses as CoreEnrolledCourseData[]).map(courseFillFullname)); } // Sort courses by name. @@ -461,17 +459,17 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave { const timeDurationMinutes = parseInt(formData.timedurationminutes || '', 10); let error: string | undefined; - if (formData.eventtype == AddonCalendarEventType.COURSE && !formData.courseid) { + if (formData.eventtype === AddonCalendarEventType.COURSE && !formData.courseid) { error = 'core.selectacourse'; - } else if (formData.eventtype == AddonCalendarEventType.GROUP && !formData.groupcourseid) { + } else if (formData.eventtype === AddonCalendarEventType.GROUP && !formData.groupcourseid) { error = 'core.selectacourse'; - } else if (formData.eventtype == AddonCalendarEventType.GROUP && !formData.groupid) { + } else if (formData.eventtype === AddonCalendarEventType.GROUP && !formData.groupid) { error = 'core.selectagroup'; - } else if (formData.eventtype == AddonCalendarEventType.CATEGORY && !formData.categoryid) { + } else if (formData.eventtype === AddonCalendarEventType.CATEGORY && !formData.categoryid) { error = 'core.selectacategory'; - } else if (formData.duration == 1 && timeStartDate > timeUntilDate) { + } else if (formData.duration === 1 && timeStartDate > timeUntilDate) { error = 'addon.calendar.invalidtimedurationuntil'; - } else if (formData.duration == 2 && (isNaN(timeDurationMinutes) || timeDurationMinutes < 1)) { + } else if (formData.duration === 2 && (isNaN(timeDurationMinutes) || timeDurationMinutes < 1)) { error = 'addon.calendar.invalidtimedurationminutes'; } @@ -534,8 +532,11 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave { if (result.sent) { // Event created or edited, invalidate right days & months. - const numberOfRepetitions = formData.repeat ? formData.repeats : - (data.repeateditall && this.otherEventsCount ? this.otherEventsCount + 1 : 1); + const numberOfRepetitions = formData.repeat + ? formData.repeats + : (data.repeateditall && this.otherEventsCount + ? this.otherEventsCount + 1 + : 1); try { await AddonCalendarHelper.refreshAfterChangeEvent(result.event, numberOfRepetitions); @@ -645,24 +646,23 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave { */ protected async initReminders(): Promise { // Don't init reminders when editing an event. Right now, only allow adding reminders for new events. - if (!this.notificationsEnabled || this.eventId) { + if (!this.remindersEnabled || this.eventId) { return; } // Check if default reminders are enabled. - const defaultTime = await AddonCalendar.getDefaultNotificationTime(this.currentSite.getId()); - if (defaultTime === 0) { + const defaultTime = await CoreReminders.getDefaultNotificationTime(this.currentSite.getId()); + if (defaultTime === CoreRemindersService.DISABLED) { return; } - const data = AddonCalendarProvider.convertSecondsToValueAndUnit(defaultTime); + const data = CoreRemindersService.convertSecondsToValueAndUnit(defaultTime); // Add default reminder. this.reminders.push({ - time: null, value: data.value, unit: data.unit, - label: AddonCalendar.getUnitValueLabel(data.value, data.unit, true), + label: CoreReminders.getUnitValueLabel(data.value, data.unit, true), }); } @@ -670,8 +670,15 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave { * Add a reminder. */ async addReminder(): Promise { - const reminderTime = await CoreDomUtils.openModal({ - component: AddonCalendarReminderTimeModalComponent, + const formData = this.form.value; + const eventTime = CoreTimeUtils.convertToTimestamp(formData.timestart, true); + + const reminderTime = await CoreDomUtils.openPopover<{timeBefore: number}>({ + component: CoreRemindersSetReminderMenuComponent, + componentProps: { + eventTime, + }, + // TODO: Add event to open the popover in place. }); if (reminderTime === undefined) { @@ -679,14 +686,14 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave { return; } - const data = AddonCalendarProvider.convertSecondsToValueAndUnit(reminderTime); + const data = CoreRemindersService.convertSecondsToValueAndUnit(reminderTime.timeBefore); // Add reminder. this.reminders.push({ - time: reminderTime, + time: reminderTime.timeBefore, value: data.value, unit: data.unit, - label: AddonCalendar.getUnitValueLabel(data.value, data.unit), + label: CoreReminders.getUnitValueLabel(data.value, data.unit), }); } @@ -697,13 +704,13 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave { */ removeReminder(reminder: AddonCalendarEventCandidateReminder): void { const index = this.reminders.indexOf(reminder); - if (index != -1) { + if (index !== -1) { this.reminders.splice(index, 1); } } /** - * Page destroyed. + * @inheritdoc */ ngOnDestroy(): void { this.unblockSync(); @@ -712,4 +719,9 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave { } -type AddonCalendarEventCandidateReminder = Omit; +type AddonCalendarEventCandidateReminder = { + time?: number; // Undefined for default reminder. + value: number; // Amount of time. + unit: CoreRemindersUnits; // Units. + label: string; // Label to represent the reminder. +}; diff --git a/src/addons/calendar/pages/event/event.html b/src/addons/calendar/pages/event/event.html index 1a7587712..add0b9a28 100644 --- a/src/addons/calendar/pages/event/event.html +++ b/src/addons/calendar/pages/event/event.html @@ -127,7 +127,7 @@ - +

{{ 'addon.calendar.reminders' | translate }}

@@ -139,7 +139,7 @@

{{ reminder.label }}

{{ reminder.sublabel }}

- diff --git a/src/addons/calendar/pages/event/event.page.ts b/src/addons/calendar/pages/event/event.page.ts index 101c8eb90..907114a1e 100644 --- a/src/addons/calendar/pages/event/event.page.ts +++ b/src/addons/calendar/pages/event/event.page.ts @@ -28,7 +28,6 @@ import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreDomUtils, ToastDuration } from '@services/utils/dom'; import { CoreTextUtils } from '@services/utils/text'; import { CoreSites } from '@services/sites'; -import { CoreLocalNotifications } from '@services/local-notifications'; import { CoreCourse } from '@features/course/services/course'; import { CoreTimeUtils } from '@services/utils/time'; import { CoreGroups } from '@services/groups'; @@ -38,10 +37,11 @@ import { CoreNavigator } from '@services/navigator'; import { CoreUtils } from '@services/utils/utils'; import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; import { CoreConstants } from '@/core/constants'; -import { AddonCalendarReminderTimeModalComponent } from '@addons/calendar/components/reminder-time-modal/reminder-time-modal'; import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; import { AddonCalendarEventsSource } from '@addons/calendar/classes/events-source'; import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager'; +import { CoreReminders, CoreRemindersService } from '@features/reminders/services/reminders'; +import { CoreRemindersSetReminderMenuComponent } from '@features/reminders/components/set-reminder-menu/set-reminder-menu'; /** * Page that displays a single calendar event. @@ -71,7 +71,7 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy { courseName = ''; groupName?: string; courseUrl = ''; - notificationsEnabled = false; + remindersEnabled = false; moduleUrl = ''; categoryPath = ''; currentTime = -1; @@ -84,7 +84,7 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy { constructor( protected route: ActivatedRoute, ) { - this.notificationsEnabled = CoreLocalNotifications.isAvailable(); + this.remindersEnabled = CoreReminders.isEnabled(); this.siteHomeId = CoreSites.getCurrentSiteHomeId(); this.currentSiteId = CoreSites.getCurrentSiteId(); @@ -131,7 +131,7 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy { }); // Reload reminders if default notification time changes. - this.defaultTimeChangedObserver = CoreEvents.on(AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_CHANGED, () => { + this.defaultTimeChangedObserver = CoreEvents.on(CoreRemindersService.DEFAULT_NOTIFICATION_TIME_CHANGED, () => { this.loadReminders(); }, this.currentSiteId); @@ -148,16 +148,15 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy { * @return Promise resolved when done. */ protected async loadReminders(): Promise { - if (!this.notificationsEnabled || !this.event) { + if (!this.remindersEnabled || !this.event) { return; } - const reminders = await AddonCalendar.getEventReminders(this.eventId, this.currentSiteId); - this.reminders = await AddonCalendarHelper.formatReminders(reminders, this.event.timestart, this.currentSiteId); + this.reminders = await AddonCalendarHelper.getEventReminders(this.eventId, this.event.timestart, this.currentSiteId); } /** - * View loaded. + * @inheritdoc */ async ngOnInit(): Promise { try { @@ -387,8 +386,12 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy { return; } - const reminderTime = await CoreDomUtils.openModal({ - component: AddonCalendarReminderTimeModalComponent, + const reminderTime = await CoreDomUtils.openPopover<{timeBefore: number}>({ + component: CoreRemindersSetReminderMenuComponent, + componentProps: { + eventTime: this.event.timestart, + }, + // TODO: Add event to open the popover in place. }); if (reminderTime === undefined) { @@ -396,18 +399,18 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy { return; } - await AddonCalendar.addEventReminder(this.event, reminderTime, this.currentSiteId); + await AddonCalendar.addEventReminder(this.event, reminderTime.timeBefore, this.currentSiteId); await this.loadReminders(); } /** - * Cancel the selected notification. + * Delete the selected reminder. * * @param id Reminder ID. * @param e Click event. */ - async cancelNotification(id: number, e: Event): Promise { + async deleteReminder(id: number, e: Event): Promise { e.preventDefault(); e.stopPropagation(); @@ -417,7 +420,7 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy { const modal = await CoreDomUtils.showModalLoading('core.deleting', true); try { - await AddonCalendar.deleteEventReminder(id); + await CoreReminders.removeReminder(id); await this.loadReminders(); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'Error deleting reminder'); @@ -636,7 +639,7 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy { } /** - * Page destroyed. + * @inheritdoc */ ngOnDestroy(): void { this.editEventObserver.off(); diff --git a/src/addons/calendar/pages/index/index.html b/src/addons/calendar/pages/index/index.html index 61b07e7d5..50cdb0f25 100644 --- a/src/addons/calendar/pages/index/index.html +++ b/src/addons/calendar/pages/index/index.html @@ -15,8 +15,8 @@ iconAction="fas-th-list" (action)="toggleDisplay()"> - + [] = []; - notificationsEnabled = false; loaded = false; hasOffline = false; isOnline = false; @@ -165,8 +163,6 @@ export class AddonCalendarIndexPage implements OnInit, OnDestroy { * View loaded. */ ngOnInit(): void { - this.notificationsEnabled = CoreLocalNotifications.isAvailable(); - this.loadUpcoming = !!CoreNavigator.getRouteBooleanParam('upcoming'); this.showCalendar = !this.loadUpcoming; diff --git a/src/addons/calendar/pages/settings/settings.ts b/src/addons/calendar/pages/settings/settings.ts index ec1ee3455..dab3d7bab 100644 --- a/src/addons/calendar/pages/settings/settings.ts +++ b/src/addons/calendar/pages/settings/settings.ts @@ -13,16 +13,12 @@ // limitations under the License. import { Component, OnInit } from '@angular/core'; -import { - AddonCalendar, - AddonCalendarProvider, - AddonCalendarReminderUnits, - AddonCalendarValueAndUnit, -} from '../../services/calendar'; -import { CoreEvents } from '@singletons/events'; -import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; -import { AddonCalendarReminderTimeModalComponent } from '@addons/calendar/components/reminder-time-modal/reminder-time-modal'; +import { + CoreReminders, + CoreRemindersService, +} from '@features/reminders/services/reminders'; +import { CoreRemindersSetReminderMenuComponent } from '@features/reminders/components/set-reminder-menu/set-reminder-menu'; /** * Page that displays the calendar settings. @@ -35,19 +31,13 @@ export class AddonCalendarSettingsPage implements OnInit { defaultTimeLabel = ''; - protected defaultTime: AddonCalendarValueAndUnit = { - value: 0, - unit: AddonCalendarReminderUnits.MINUTE, - }; + protected defaultTime?: number; /** - * View loaded. + * @inheritdoc */ async ngOnInit(): Promise { - const defaultTime = await AddonCalendar.getDefaultNotificationTime(); - - this.defaultTime = AddonCalendarProvider.convertSecondsToValueAndUnit(defaultTime); - this.defaultTimeLabel = AddonCalendar.getUnitValueLabel(this.defaultTime.value, this.defaultTime.unit); + this.updateDefaultTimeLabel(); } /** @@ -61,12 +51,13 @@ export class AddonCalendarSettingsPage implements OnInit { e.stopImmediatePropagation(); e.preventDefault(); - const reminderTime = await CoreDomUtils.openModal({ - component: AddonCalendarReminderTimeModalComponent, + const reminderTime = await CoreDomUtils.openPopover<{timeBefore: number}>({ + component: CoreRemindersSetReminderMenuComponent, componentProps: { initialValue: this.defaultTime, - allowDisable: true, + noReminderLabel: 'core.settings.disabled', }, + event: e, }); if (reminderTime === undefined) { @@ -74,25 +65,18 @@ export class AddonCalendarSettingsPage implements OnInit { return; } - this.defaultTime = AddonCalendarProvider.convertSecondsToValueAndUnit(reminderTime); - this.defaultTimeLabel = AddonCalendar.getUnitValueLabel(this.defaultTime.value, this.defaultTime.unit); - - this.updateDefaultTime(reminderTime); + await CoreReminders.setDefaultNotificationTime(reminderTime.timeBefore ?? CoreRemindersService.DISABLED); + this.updateDefaultTimeLabel(); } /** - * Update default time. - * - * @param newTime New time. + * Update default time label. */ - updateDefaultTime(newTime: number): void { - AddonCalendar.setDefaultNotificationTime(newTime); + async updateDefaultTimeLabel(): Promise { + this.defaultTime = await CoreReminders.getDefaultNotificationTime(); - CoreEvents.trigger( - AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_CHANGED, - { time: newTime }, - CoreSites.getCurrentSiteId(), - ); + const defaultTime = CoreRemindersService.convertSecondsToValueAndUnit(this.defaultTime); + this.defaultTimeLabel = CoreReminders.getUnitValueLabel(defaultTime.value, defaultTime.unit); } } diff --git a/src/addons/calendar/services/calendar-helper.ts b/src/addons/calendar/services/calendar-helper.ts index 09b58b831..6ac5d6334 100644 --- a/src/addons/calendar/services/calendar-helper.ts +++ b/src/addons/calendar/services/calendar-helper.ts @@ -23,7 +23,6 @@ import { AddonCalendarEventType, AddonCalendarGetEventsEvent, AddonCalendarProvider, - AddonCalendarReminderUnits, AddonCalendarWeek, AddonCalendarWeekDay, } from './calendar'; @@ -36,8 +35,8 @@ import { makeSingleton } from '@singletons'; import { AddonCalendarSyncInvalidateEvent } from './calendar-sync'; import { AddonCalendarOfflineEventDBRecord } from './database/calendar-offline'; import { CoreCategoryData } from '@features/courses/services/courses'; -import { AddonCalendarReminderDBRecord } from './database/calendar'; import { CoreTimeUtils } from '@services/utils/time'; +import { CoreReminders, CoreRemindersService } from '@features/reminders/services/reminders'; /** * Context levels enumeration. @@ -123,7 +122,7 @@ export class AddonCalendarHelperProvider { * Classify events into their respective months and days. If an event duration covers more than one day, * it will be included in all the days it lasts. * - * @param events Events to classify. + * @param offlineEvents Events to classify. * @return Object with the classified events. */ classifyIntoMonths( @@ -219,7 +218,7 @@ export class AddonCalendarHelperProvider { /** * Convenience function to format some event data to be rendered. * - * @param e Event to format. + * @param event Event to format. */ formatOfflineEventData(event: AddonCalendarOfflineEventDBRecord): AddonCalendarEventToDisplay { @@ -295,48 +294,75 @@ export class AddonCalendarHelperProvider { * @param timestart Event timestart. * @param siteId Site ID. * @return Formatted reminders. + * @deprecated since 4.1 Use AddonCalendarHelper.getEventReminders. */ async formatReminders( - reminders: AddonCalendarReminderDBRecord[], + reminders: { eventid: number }[], timestart: number, siteId?: string, ): Promise { - const defaultTime = await AddonCalendar.getDefaultNotificationTime(siteId); - - const formattedReminders = 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; + if (!reminders.length) { + return []; } - return formattedReminders.map((reminder) => { - if (reminder.time === null) { + return AddonCalendarHelper.getEventReminders(reminders[0].eventid, timestart, siteId); + } + + /** + * Format reminders, adding calculated data. + * + * @param eventId Event Id. + * @param eventTimestart Event timestart. + * @param siteId Site ID. + * @return Formatted reminders. + */ + async getEventReminders( + eventId: number, + eventTimestart: number, + siteId?: string, + ): Promise { + const reminders = await CoreReminders.getReminders( + { + instanceId: eventId, + component: AddonCalendarProvider.COMPONENT, + }, + siteId, + ); + + if (!reminders.length) { + return []; + } + + const defaultTime = await CoreReminders.getDefaultNotificationTime(siteId); + let defaultLabel: string | undefined; + + if (defaultTime > CoreRemindersService.DISABLED) { + const data = CoreRemindersService.convertSecondsToValueAndUnit(defaultTime); + defaultLabel = CoreReminders.getUnitValueLabel(data.value, data.unit, true); + } + + return reminders.map((reminder) => { + const formatted: AddonCalendarEventReminder = { + id: reminder.id, + }; + + if (reminder.timebefore === CoreRemindersService.DEFAULT_REMINDER_TIMEBEFORE) { // 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; + if (defaultLabel !== undefined) { + formatted.label = defaultLabel; + formatted.timestamp = eventTimestart - defaultTime; } } else { - const data = AddonCalendarProvider.convertSecondsToValueAndUnit(reminder.time); - reminder.value = data.value; - reminder.unit = data.unit; - reminder.timestamp = eventTimestart - reminder.time; + const data = CoreRemindersService.convertSecondsToValueAndUnit(reminder.timebefore); + formatted.label = CoreReminders.getUnitValueLabel(data.value, data.unit, false); + formatted.timestamp = eventTimestart - reminder.timebefore; } - if (reminder.value && reminder.unit) { - reminder.label = AddonCalendar.getUnitValueLabel(reminder.value, reminder.unit, reminder.time === null); - if (reminder.timestamp) { - reminder.sublabel = CoreTimeUtils.userDate(reminder.timestamp * 1000, 'core.strftimedatetime'); - } + if (formatted.timestamp) { + formatted.sublabel = CoreTimeUtils.userDate(formatted.timestamp * 1000, 'core.strftimedatetime'); } - return reminder; + return formatted; }); } @@ -381,7 +407,7 @@ export class AddonCalendarHelperProvider { /** * Get the day "id". * - * @param day Day moment. + * @param moment Day moment. * @return The "id". */ getDayId(moment: moment.Moment): string { @@ -553,18 +579,18 @@ export class AddonCalendarHelperProvider { courseId: number, categoryId?: number, ): boolean { - if (event.eventtype == 'user' || event.eventtype == 'site') { + if (event.eventtype === 'user' || event.eventtype === 'site') { // User or site event, display it. return true; } - if (event.eventtype == 'category' && categories) { + if (event.eventtype === 'category' && categories) { if (!event.categoryid || !Object.keys(categories).length || !categoryId) { // We can't tell if the course belongs to the category, display them all. return true; } - if (event.categoryid == categoryId) { + if (event.categoryid === categoryId) { // The event is in the same category as the course, display it. return true; } @@ -577,7 +603,7 @@ export class AddonCalendarHelperProvider { break; } - if (event.categoryid == category.parent) { + if (event.categoryid === category.parent) { return true; } category = categories[category.parent]; @@ -796,9 +822,8 @@ export type AddonCalendarEventTypeOption = { /** * Formatted event reminder. */ -export type AddonCalendarEventReminder = AddonCalendarReminderDBRecord & { - value?: number; // Amount of time. - unit?: AddonCalendarReminderUnits; // Units. +export type AddonCalendarEventReminder = { + id: number; timestamp?: number; // Timestamp (in seconds). label?: string; // Label to represent the reminder. sublabel?: string; // Sub label. diff --git a/src/addons/calendar/services/calendar.ts b/src/addons/calendar/services/calendar.ts index f500eb1e6..bc13c16a9 100644 --- a/src/addons/calendar/services/calendar.ts +++ b/src/addons/calendar/services/calendar.ts @@ -23,12 +23,11 @@ import { CoreUtils } from '@services/utils/utils'; import { CoreGroups } from '@services/groups'; import { CoreLocalNotifications } from '@services/local-notifications'; import { CoreConfig } from '@services/config'; -import { ILocalNotification } from '@ionic-native/local-notifications'; import { AddonCalendarOffline } from './calendar-offline'; import { CoreUser } from '@features/user/services/user'; import { CoreWSExternalWarning, CoreWSDate } from '@services/ws'; import moment from 'moment-timezone'; -import { AddonCalendarEventDBRecord, AddonCalendarReminderDBRecord, EVENTS_TABLE, REMINDERS_TABLE } from './database/calendar'; +import { AddonCalendarEventDBRecord, EVENTS_TABLE } from './database/calendar'; import { CoreCourses } from '@features/courses/services/courses'; import { ContextLevel, CoreConstants } from '@/core/constants'; import { CoreWSError } from '@classes/errors/wserror'; @@ -39,9 +38,17 @@ import { SafeUrl } from '@angular/platform-browser'; import { CoreNavigator } from '@services/navigator'; import { AddonCalendarFilter } from './calendar-helper'; import { AddonCalendarSyncEvents, AddonCalendarSyncProvider } from './calendar-sync'; -import { CoreEvents } from '@singletons/events'; import { CoreText } from '@singletons/text'; import { CorePlatform } from '@services/platform'; +import { + CoreReminderData, + CoreReminders, + CoreRemindersPushNotificationData, + CoreRemindersService, + CoreRemindersUnits, + CoreReminderValueAndUnit, +} from '@features/reminders/services/reminders'; +import { CoreReminderDBRecord } from '@features/reminders/services/database/reminders'; const ROOT_CACHE_KEY = 'mmaCalendar:'; @@ -58,6 +65,8 @@ export enum AddonCalendarEventType { /** * Units to set a reminder. + * + * @deprecated since 4.1 Use CoreReminderUnits instead. */ export enum AddonCalendarReminderUnits { MINUTE = CoreConstants.SECONDS_MINUTE, @@ -85,21 +94,6 @@ 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. */ @@ -108,8 +102,7 @@ export class AddonCalendarProvider { static readonly DAYS_INTERVAL = 30; static readonly COMPONENT = 'AddonCalendarEvents'; - static readonly DEFAULT_NOTIFICATION_TIME_CHANGED = 'AddonCalendarDefaultNotificationTimeChangedEvent'; - static readonly DEFAULT_NOTIFICATION_TIME_SETTING = 'mmaCalendarDefaultNotifTime'; + static readonly STARTING_WEEK_DAY = 'addon_calendar_starting_week_day'; static readonly NEW_EVENT_EVENT = 'addon_calendar_new_event'; static readonly NEW_EVENT_DISCARDED_EVENT = 'addon_calendar_new_event_discarded'; @@ -188,34 +181,10 @@ export class AddonCalendarProvider { * * @param seconds Number of seconds. * @return Value and unit. + * @deprecated since 4.1 Use CoreRemindersService.convertSecondsToValueAndUnit instead. */ - 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, - }; - } + static convertSecondsToValueAndUnit(seconds: number): CoreReminderValueAndUnit { + return CoreRemindersService.convertSecondsToValueAndUnit(seconds); } /** @@ -306,17 +275,12 @@ export class AddonCalendarProvider { EVENTS_TABLE, { id: eventId }, )); - promises.push(site.getDb().getRecords( - REMINDERS_TABLE, - { eventid: eventId }, - ).then((reminders) => - Promise.all(reminders.map((reminder) => this.deleteEventReminder(reminder.id, siteId))))); + promises.push(CoreReminders.removeReminders({ + instanceId: eventId, + component: AddonCalendarProvider.COMPONENT, + } , siteId)); - try { - await Promise.all(promises); - } catch { - // Ignore errors. - } + await CoreUtils.ignoreErrors(Promise.all(promises)); } /** @@ -325,56 +289,40 @@ export class AddonCalendarProvider { * @return Promise resolved when done. */ async initialize(): Promise { - - CoreLocalNotifications.registerClick( + CoreLocalNotifications.registerClick( AddonCalendarProvider.COMPONENT, async (notification) => { - if (notification.eventId) { - await ApplicationInit.donePromise; + await ApplicationInit.donePromise; - const disabled = await this.isDisabled(notification.siteId); - if (disabled) { - // The calendar is disabled in the site, don't open it. - return; - } - - CoreNavigator.navigateToSitePath( - AddonCalendarMainMenuHandlerService.PAGE_NAME, - { - siteId: notification.siteId, - preferCurrentTab: false, - nextNavigation: { - path: `calendar/event/${notification.eventId}`, - isSitePath: true, - }, - }, - ); - } + this.notificationClicked(notification); }, ); + } - if (CoreLocalNotifications.isAvailable()) { - CoreEvents.on(AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_CHANGED, async (data) => { - const site = await CoreSites.getSite(data.siteId); - - // Get all the events that have a default reminder. - const query = 'SELECT events.*, reminders.id AS reminderid ' + - 'FROM ' + EVENTS_TABLE + ' events ' + - 'INNER JOIN ' + REMINDERS_TABLE + ' reminders ON events.id = reminders.eventid ' + - 'WHERE reminders.time IS NULL'; - - const result = await site.getDb().execute(query); - - // Reschedule all the default reminders. - for (let i = 0; i < result.rows.length; i++) { - const event = result.rows.item(i) as AddonCalendarEventDBRecord & { - reminderid: number; - }; - - this.scheduleEventNotification(event, event.reminderid, null, site.getId()); - } - }); + /** + * Notification has been clicked. + * + * @param notification Calendar notification. + * @return Promise resolved when done. + */ + async notificationClicked(notification: CoreRemindersPushNotificationData): Promise { + const disabled = await this.isDisabled(notification.siteId); + if (disabled) { + // The calendar is disabled in the site, don't open it. + return; } + + CoreNavigator.navigateToSitePath( + AddonCalendarMainMenuHandlerService.PAGE_NAME, + { + siteId: notification.siteId, + preferCurrentTab: false, + nextNavigation: { + path: `calendar/event/${notification.instanceId}`, + isSitePath: true, + }, + }, + ); } /** @@ -527,7 +475,7 @@ export class AddonCalendarProvider { await site.read('core_calendar_get_allowed_event_types', params, preSets); // Convert the array to an object. - const result = {}; + const result: {[name: string]: boolean} = {}; if (response.allowedeventtypes) { response.allowedeventtypes.forEach((type) => { result[type] = true; @@ -633,13 +581,10 @@ export class AddonCalendarProvider { * * @param siteId ID of the site. If not defined, use current site. * @return Promise resolved with the default time (in seconds). + * @deprecated since 4.1 Use CoreReminders.getDefaultNotificationTime instead. */ async getDefaultNotificationTime(siteId?: string): Promise { - siteId = siteId || CoreSites.getCurrentSiteId(); - - const key = AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_SETTING + '#' + siteId; - - return CoreConfig.get(key, CoreConstants.CONFIG.calendarreminderdefaultvalue || 3600); + return CoreReminders.getDefaultNotificationTime(siteId); } /** @@ -699,7 +644,7 @@ export class AddonCalendarProvider { await site.read('core_calendar_get_calendar_event_by_id', params, preSets); this.storeEventInLocalDb(response.event, { siteId }); - this.scheduleEventsNotifications([response.event], siteId); + this.updateEventsReminders([response.event], site.getId()); return response.event; } catch (error) { @@ -766,27 +711,43 @@ export class AddonCalendarProvider { * Adds an event reminder and schedule a new notification. * * @param event Event to set the reminder. - * @param time Amount of seconds of the reminder. Undefined for default reminder. + * @param timebefore 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. * @return Promise resolved when the notification is updated. */ async addEventReminder( - event: { id: number; timestart: number; name: string}, - time?: number | null, + event: AddonCalendarEvent | AddonCalendarEventDBRecord | AddonCalendarEventToDisplay | AddonCalendarOfflineEventDBRecord, + timebefore?: number, siteId?: string, ): Promise { - const site = await CoreSites.getSite(siteId); - const reminder: Partial = { - eventid: event.id, - time: time ?? null, - timecreated: Date.now(), + + timebefore = timebefore ?? CoreRemindersService.DEFAULT_REMINDER_TIMEBEFORE; + + const previousReminders = await CoreReminders.getReminders({ + instanceId: event.id, + component: AddonCalendarProvider.COMPONENT, + }, siteId); + + if (previousReminders.some((reminder) => reminder.timebefore === timebefore)) { + // Already exists. + return; + } + + const url = 'url' in event + ? event.url || '' + : ''; + + const reminder: CoreReminderData = { + component: AddonCalendarProvider.COMPONENT, + instanceId: event.id, + type: event.eventtype, + time: event.timestart, + timebefore, + title: event.name, + url, }; - const reminderId = await site.getDb().insertRecord(REMINDERS_TABLE, reminder); - - const timestamp = time ? event.timestart - time : time; - - await this.scheduleEventNotification(event, reminderId, timestamp, site.getId()); + await CoreReminders.addReminder(reminder, siteId); } /** @@ -810,15 +771,10 @@ export class AddonCalendarProvider { * @param id Reminder ID. * @param siteId ID of the site the event belongs to. If not defined, use current site. * @return Promise resolved when the notification is updated. + * @deprecated since 4.1. Use CoreReminders.removeReminder instead. */ async deleteEventReminder(id: number, siteId?: string): Promise { - const site = await CoreSites.getSite(siteId); - - if (CoreLocalNotifications.isAvailable()) { - CoreLocalNotifications.cancel(id, AddonCalendarProvider.COMPONENT, site.getId()); - } - - await site.getDb().deleteRecords(REMINDERS_TABLE, { id: id }); + await CoreReminders.removeReminder(id, siteId); } /** @@ -865,7 +821,7 @@ export class AddonCalendarProvider { } const response: AddonCalendarCalendarDay = await site.read('core_calendar_get_calendar_day_view', params, preSets); this.storeEventsInLocalDB(response.events, { siteId }); - this.scheduleEventsNotifications(response.events, siteId); + this.updateEventsReminders(response.events, site.getId()); return response; } @@ -909,14 +865,16 @@ export class AddonCalendarProvider { /** * Get a calendar reminders from local Db. * - * @param id Event ID. + * @param eventId Event ID. * @param siteId ID of the site the event belongs to. If not defined, use current site. * @return Promise resolved when the event data is retrieved. + * @deprecated since 4.1. Use CoreReminders.getReminders instead. */ - async getEventReminders(id: number, siteId?: string): Promise { - const site = await CoreSites.getSite(siteId); - - return site.getDb().getRecords(REMINDERS_TABLE, { eventid: id }, 'timecreated ASC, time ASC'); + async getEventReminders(eventId: number, siteId?: string): Promise { + return CoreReminders.getReminders({ + instanceId: eventId, + component: AddonCalendarProvider.COMPONENT, + }, siteId); } /** @@ -1070,7 +1028,7 @@ export class AddonCalendarProvider { response.weeks.forEach((week) => { week.days.forEach((day) => { this.storeEventsInLocalDB(day.events, { siteId }); - this.scheduleEventsNotifications(day.events, siteId); + this.updateEventsReminders(day.events, site.getId()); }); }); @@ -1123,26 +1081,10 @@ export class AddonCalendarProvider { * @param unit Unit. * @param addDefaultLabel Whether to add the "Default" text. * @return Translated label. + * @deprecated since 4.1 Use CoreReminders.getUnitValueLabel instead. */ - 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; + getUnitValueLabel(value: number, unit: CoreRemindersUnits, addDefaultLabel = false): string { + return CoreReminders.getUnitValueLabel(value, unit, addDefaultLabel); } /** @@ -1184,7 +1126,7 @@ export class AddonCalendarProvider { const response = await site.read('core_calendar_get_calendar_upcoming_view', params, preSets); this.storeEventsInLocalDB(response.events, { siteId }); - this.scheduleEventsNotifications(response.events, siteId); + this.updateEventsReminders(response.events, site.getId()); return response; } @@ -1422,6 +1364,16 @@ export class AddonCalendarProvider { return this.isCalendarDisabledInSite(site); } + /** + * Get the next events for all the sites and schedules their notifications. + * + * @return Promise resolved when done. + * @deprecated since 4.1 Use AddonCalendar.updateAllSitesEventReminders. + */ + async scheduleAllSitesEventsNotifications(): Promise { + await AddonCalendar.updateAllSitesEventReminders(); + } + /** * Get the next events for all the sites and schedules their notifications. * If an event notification time is 0, cancel its scheduled notification (if any). @@ -1429,95 +1381,36 @@ export class AddonCalendarProvider { * * @return Promise resolved when all the notifications have been scheduled. */ - async scheduleAllSitesEventsNotifications(): Promise { + async updateAllSitesEventReminders(): Promise { await CorePlatform.ready(); - const notificationsEnabled = CoreLocalNotifications.isAvailable(); - const siteIds = await CoreSites.getSitesIds(); - const promises = siteIds.map((siteId: string) => async () => { - if (notificationsEnabled) { - // Check if calendar is disabled for the site. - const disabled = await this.isDisabled(siteId); - if (!disabled) { - // Get first events. - const events = await this.getEventsList(undefined, undefined, undefined, siteId); - await this.scheduleEventsNotifications(events, siteId); - } + await Promise.all(siteIds.map((siteId: string) => async () => { + + // Check if calendar is disabled for the site. + const disabled = await this.isDisabled(siteId); + if (!disabled) { + // Get first events. + const events = await this.getEventsList(undefined, undefined, undefined, siteId); + await this.updateEventsReminders(events, siteId); } - - return; - }); - - await Promise.all(promises); + })); } /** - * Schedules an event notification. If time is 0, cancel scheduled notification if any. - * If local notification plugin is not enabled, resolve the promise. + * Get the next events for all the sites and schedules their notifications. * - * @param event Event to schedule. - * @param reminderId The reminder ID. - * @param time Notification timestamp (in seconds). Undefined for default time. - * @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 done. + * @deprecated since 4.1. No replacement for that function. */ - protected async scheduleEventNotification( - event: { id: number; timestart: number; name: string}, - reminderId: number, - time?: number | null, + async scheduleEventsNotifications( + events: ({ id: number; timestart: number; timeduration: number; name: string})[], siteId?: string, ): Promise { - - if (!CoreLocalNotifications.isAvailable()) { - return; - } - siteId = siteId || CoreSites.getCurrentSiteId(); - if (time === 0) { - // Cancel if it was scheduled. - return CoreLocalNotifications.cancel(reminderId, AddonCalendarProvider.COMPONENT, siteId); - } - - if (!time) { - // Get event default time to calculate the notification time. - time = await this.getDefaultNotificationTime(siteId); - - if (time === 0) { - // Default notification time is disabled, do not show. - return CoreLocalNotifications.cancel(reminderId, AddonCalendarProvider.COMPONENT, siteId); - } - - time = event.timestart - time; - } - - time = time * 1000; - - if (time <= Date.now()) { - // This reminder is over, don't schedule. Cancel if it was scheduled. - return CoreLocalNotifications.cancel(reminderId, AddonCalendarProvider.COMPONENT, siteId); - } - - const notificationData: AddonCalendarPushNotificationData = { - eventId: event.id, - reminderId: reminderId, - siteId: siteId, - }; - - const notification: ILocalNotification = { - id: reminderId, - title: event.name, - text: CoreTimeUtils.userDate(event.timestart * 1000, 'core.strftimedaydatetime', true), - icon: 'file://assets/img/icons/calendar.png', - trigger: { - at: new Date(time), - }, - data: notificationData, - }; - - return CoreLocalNotifications.schedule(notification, AddonCalendarProvider.COMPONENT, siteId); + await AddonCalendar.updateEventsReminders(events, siteId); } /** @@ -1526,38 +1419,38 @@ export class AddonCalendarProvider { * If local notification plugin is not enabled, resolve the promise. * * @param events Events to schedule. - * @param siteId ID of the site the events belong to. If not defined, use current site. + * @param siteId ID of the site the events belong to. * @return Promise resolved when all the notifications have been scheduled. */ - async scheduleEventsNotifications( - events: ({ id: number; timestart: number; timeduration: number; name: string})[], - siteId?: string, + protected async updateEventsReminders( + events: ({ id: number; timestart: number; name: string})[], + siteId: string, ): Promise { - - if (!CoreLocalNotifications.isAvailable()) { - return; - } - - siteId = siteId || CoreSites.getCurrentSiteId(); - - const promises = events.map(async (event) => { + await Promise.all(events.map(async (event) => { if (event.timestart * 1000 <= Date.now()) { // The event has already started, don't schedule it. - return; + + // @TODO Decide when to completelly remove expired events. + return CoreReminders.cancelReminder(event.id, AddonCalendarProvider.COMPONENT, siteId); } - const reminders = await this.getEventReminders(event.id, siteId); + const reminders = await CoreReminders.getReminders({ + instanceId: event.id, + component: AddonCalendarProvider.COMPONENT, + }, siteId); - const p2 = reminders.map((reminder) => { - const time = reminder.time ? event.timestart - reminder.time : reminder.time; + await Promise.all(reminders.map(async (reminder) => { + if (reminder.time !== event.timestart || reminder.title !== event.name) { + reminder.time = event.timestart; + reminder.title = event.name; - return this.scheduleEventNotification(event, reminder.id, time, siteId); - }); - - await Promise.all(p2); - }); - - await Promise.all(promises); + CoreReminders.updateReminder( + reminder, + siteId, + ); + } + })); + })); } /** @@ -1566,13 +1459,10 @@ export class AddonCalendarProvider { * @param time New default time. * @param siteId ID of the site. If not defined, use current site. * @return Promise resolved when stored. + * @deprecated since 4.1 Use CoreReminders.setDefaultNotificationTime. */ async setDefaultNotificationTime(time: number, siteId?: string): Promise { - siteId = siteId || CoreSites.getCurrentSiteId(); - - const key = AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_SETTING + '#' + siteId; - - await CoreConfig.set(key, time); + await CoreReminders.setDefaultNotificationTime(time, siteId); } /** @@ -1587,24 +1477,8 @@ export class AddonCalendarProvider { options: AddonCalendarStoreEventsOptions = {}, ): Promise { const site = await CoreSites.getSite(options.siteId); - const siteId = site.getId(); const addDefaultReminder = options.addDefaultReminder ?? true; - if (addDefaultReminder) { - // 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); - } - } - } - // Don't store data that can be calculated like formattedtime, iscategoryevent, etc. let eventRecord: AddonCalendarEventDBRecord = { id: event.id, @@ -1659,9 +1533,40 @@ export class AddonCalendarProvider { }); } + if (addDefaultReminder) { + this.addDefaultEventReminder(eventRecord, site.getId()); + } + await site.getDb().insertRecord(EVENTS_TABLE, eventRecord); } + /** + * Adds the default event reminder. + * + * @param event Event to add the reminder to. + * @param siteId Site ID. If not defined, current site. + */ + protected async addDefaultEventReminder(event: AddonCalendarEventDBRecord, siteId?: string): Promise { + // Add default reminder if the event isn't stored already and doesn't have any reminder. + const eventExist = await CoreUtils.promiseWorks(this.getEventFromLocalDb(event.id, siteId)); + if (eventExist) { + return; + } + + const reminders = await CoreReminders.getReminders({ + instanceId: event.id, + component: AddonCalendarProvider.COMPONENT, + }, siteId); + + if (reminders.length > 0) { + // It already has reminders. + return; + } + + // No reminders, create the default one. + await this.addEventReminder(event, undefined, siteId); + } + /** * Store events in local DB. * @@ -1681,9 +1586,7 @@ export class AddonCalendarProvider { * * @param eventId ID of the event. Negative value to edit offline event. If undefined/null, create a new event. * @param formData Form data. - * @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 siteId Site ID. If not defined, current site. + * @param options Calendar submit event options. * @return Promise resolved with the event and a boolean indicating if data was sent to server or stored in offline. */ async submitEvent( @@ -1701,7 +1604,8 @@ export class AddonCalendarProvider { // Now save the reminders if any. if (options.reminders?.length) { await CoreUtils.ignoreErrors( - Promise.all(options.reminders.map((reminder) => this.addEventReminder(event, reminder.time, siteId))), + Promise.all(options.reminders.map((reminder) => + this.addEventReminder(event, reminder.time, siteId))), ); } @@ -1723,7 +1627,8 @@ export class AddonCalendarProvider { // Now save the reminders if any. if (options.reminders?.length) { await CoreUtils.ignoreErrors( - Promise.all(options.reminders.map((reminder) => this.addEventReminder(event, reminder.time, siteId))), + Promise.all(options.reminders.map((reminder) => + this.addEventReminder(event, reminder.time, siteId))), ); } @@ -1753,6 +1658,7 @@ export class AddonCalendarProvider { siteId?: string, ): Promise { const site = await CoreSites.getSite(siteId); + siteId = site.getId(); // Add data that is "hidden" in web. formData.id = eventId > 0 ? eventId : 0; @@ -1780,9 +1686,16 @@ export class AddonCalendarProvider { } if (eventId < 0) { - // Offline event has been sent. Change reminders eventid if any. + // Offline event has been sent. Change reminders instanceId if any. await CoreUtils.ignoreErrors( - site.getDb().updateRecords(REMINDERS_TABLE, { eventid: result.event.id }, { eventid: eventId }), + CoreReminders.updateReminders( + { instanceId: result.event.id }, + { + instanceId: eventId, + component: AddonCalendarProvider.COMPONENT, + }, + siteId, + ), ); } @@ -2316,29 +2229,19 @@ export type AddonCalendarUpdatedEventEvent = { sent?: boolean; }; -/** - * Additional data sent in push notifications, with some calculated data. - */ -type AddonCalendarPushNotificationData = { - eventId: number; - reminderId: number; - siteId: string; -}; - /** * Value and unit for reminders. + * + * @deprecated since 4.1, use CoreReminderValueAndUnit instead. */ -export type AddonCalendarValueAndUnit = { - value: number; - unit: AddonCalendarReminderUnits; -}; +export type AddonCalendarValueAndUnit = CoreReminderValueAndUnit; /** * Options to pass to submit event. */ export type AddonCalendarSubmitEventOptions = { reminders?: { - time: number | null; + time?: number; }[]; forceOffline?: boolean; siteId?: string; // Site ID. If not defined, current site. diff --git a/src/addons/calendar/services/database/calendar.ts b/src/addons/calendar/services/database/calendar.ts index 16da851ac..1b87cb281 100644 --- a/src/addons/calendar/services/database/calendar.ts +++ b/src/addons/calendar/services/database/calendar.ts @@ -13,19 +13,19 @@ // limitations under the License. import { SQLiteDB } from '@classes/sqlitedb'; +import { CoreRemindersService, CoreReminders } from '@features/reminders/services/reminders'; import { CoreConfig } from '@services/config'; import { CoreSiteSchema } from '@services/sites'; import { CoreUtils } from '@services/utils/utils'; -import { AddonCalendar, AddonCalendarEventType, AddonCalendarProvider } from '../calendar'; +import { AddonCalendarEventType } from '../calendar'; /** * Database variables for AddonCalendarProvider service. */ export const EVENTS_TABLE = 'addon_calendar_events_3'; -export const REMINDERS_TABLE = 'addon_calendar_reminders_2'; export const CALENDAR_SITE_SCHEMA: CoreSiteSchema = { name: 'AddonCalendarProvider', - version: 4, + version: 5, canBeCleared: [EVENTS_TABLE], tables: [ { @@ -179,105 +179,41 @@ export const CALENDAR_SITE_SCHEMA: CoreSiteSchema = { }, ], }, - { - name: REMINDERS_TABLE, - columns: [ - { - name: 'id', - type: 'INTEGER', - primaryKey: true, - }, - { - name: 'eventid', - type: 'INTEGER', - }, - { - name: 'time', - type: 'INTEGER', - }, - { - name: 'timecreated', - type: 'INTEGER', - }, - ], - uniqueKeys: [ - ['eventid', 'time'], - ], - }, ], async migrate(db: SQLiteDB, oldVersion: number, siteId: string): Promise { - if (oldVersion < 3) { - // Migrate calendar events. New format @since 3.7. - let oldTable = 'addon_calendar_events_2'; - - try { - await db.tableExists(oldTable); - } catch { - // The v2 table doesn't exist, try with v1. - oldTable = 'addon_calendar_events'; - } - - await db.migrateTable(oldTable, EVENTS_TABLE); - } - - if (oldVersion < 4) { - // Migrate default notification time if it was changed. - // Don't use getDefaultNotificationTime to be able to detect if the value was changed or not. - const key = AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_SETTING + '#' + siteId; - const defaultTime = await CoreUtils.ignoreErrors(CoreConfig.get(key, null)); - - if (defaultTime) { - // Convert from minutes to seconds. - AddonCalendar.setDefaultNotificationTime(defaultTime * 60, siteId); - } - - // Migrate reminders. New format @since 4.0. - const oldTable = 'addon_calendar_reminders'; - - try { - await db.tableExists(oldTable); - } catch (error) { - // Old table does not exist, ignore. - return; - } - - const records = await db.getAllRecords(oldTable); - const events: Record = {}; - - 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. Ignore the reminder. - 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, ignore it. - return; - } else { - // Remove seconds from the old reminder, it could include seconds by mistake. - record.time = events[record.eventid].timestart - Math.floor(record.time / 60) * 60; - } - - return db.insertRecord(REMINDERS_TABLE, record); - })); - - try { - await db.dropTable(oldTable); - } catch (error) { - // Error deleting old table, ignore. - } + if (oldVersion < 5) { + await migrateDefaultTime(siteId, oldVersion < 4); } }, }; +/** + * Migrate default notification time if it was changed. + * Don't use getDefaultNotificationTime to be able to detect if the value was changed or not. + * + * @param siteId Site ID to migrate. + * @param convertToSeconds If true, time will be converted to seconds. + */ +const migrateDefaultTime = async (siteId: string, convertToSeconds = false): Promise => { + + const key = 'mmaCalendarDefaultNotifTime#' + siteId; + try { + let defaultTime = await CoreConfig.get(key); + await CoreUtils.ignoreErrors(CoreConfig.delete(key)); + + if (defaultTime <= 0) { + defaultTime = CoreRemindersService.DISABLED; + } else if (convertToSeconds) { + // Convert from minutes to seconds. + defaultTime = defaultTime * 60; + } + + CoreReminders.setDefaultNotificationTime(defaultTime, siteId); + } catch { + // Ignore errors, already migrated. + } +}; + export type AddonCalendarEventDBRecord = { id: number; name: string; @@ -318,10 +254,3 @@ export type AddonCalendarEventDBRecord = { maxdaytimestamp?: number; draggable?: number; }; - -export type AddonCalendarReminderDBRecord = { - id: number; - eventid: number; - time: number | null; // Number of seconds before the event, null for default time. - timecreated?: number | null; -}; diff --git a/src/core/features/contentlinks/services/contentlinks-helper.ts b/src/core/features/contentlinks/services/contentlinks-helper.ts index e4d6379dd..a62abffb3 100644 --- a/src/core/features/contentlinks/services/contentlinks-helper.ts +++ b/src/core/features/contentlinks/services/contentlinks-helper.ts @@ -98,7 +98,6 @@ export class CoreContentLinksHelperProvider { * @param pageName Name of the page to go. * @param pageParams Params to send to the page. * @param siteId Site ID. If not defined, current site. - * @param checkMenu If true, check if the root page of a main menu tab. Only the page name will be checked. * @return Promise resolved when done. * @deprecated since 3.9.5. Use CoreNavigator.navigateToSitePath instead. */ diff --git a/src/core/features/course/components/components.module.ts b/src/core/features/course/components/components.module.ts index a8bbc04f1..f663770b5 100644 --- a/src/core/features/course/components/components.module.ts +++ b/src/core/features/course/components/components.module.ts @@ -29,6 +29,7 @@ import { CoreCourseModuleManualCompletionComponent } from './module-manual-compl import { CoreCourseModuleNavigationComponent } from './module-navigation/module-navigation'; import { CoreCourseModuleSummaryComponent } from './module-summary/module-summary'; import { CoreCourseCourseIndexTourComponent } from './course-index-tour/course-index-tour'; +import { CoreRemindersComponentsModule } from '@features/reminders/components/components.module'; @NgModule({ declarations: [ @@ -48,6 +49,7 @@ import { CoreCourseCourseIndexTourComponent } from './course-index-tour/course-i ], imports: [ CoreBlockComponentsModule, + CoreRemindersComponentsModule, CoreSharedModule, ], exports: [ diff --git a/src/core/features/course/components/module-info/core-course-module-info.html b/src/core/features/course/components/module-info/core-course-module-info.html index b338fc7d1..1e00b23b0 100644 --- a/src/core/features/course/components/module-info/core-course-module-info.html +++ b/src/core/features/course/components/module-info/core-course-module-info.html @@ -26,10 +26,9 @@
-

- {{ date.label }} - {{ date.readableTime }} -

+ +
diff --git a/src/core/features/course/components/module-info/course-module-info.scss b/src/core/features/course/components/module-info/course-module-info.scss index bf6e0d5ce..533696aae 100644 --- a/src/core/features/course/components/module-info/course-module-info.scss +++ b/src/core/features/course/components/module-info/course-module-info.scss @@ -48,12 +48,6 @@ padding-top: 8px; } - .core-module-dates ion-icon { - margin-left: 4px; - margin-right: 4px; - } - - .core-module-dates, .core-module-availabilityinfo { font-size: 90%; ion-icon { @@ -94,8 +88,6 @@ white-space: normal !important; } } - - } :host-context(.core-iframe-fullscreen) { diff --git a/src/core/features/course/components/module/core-course-module.html b/src/core/features/course/components/module/core-course-module.html index 8a099383d..5206b6397 100644 --- a/src/core/features/course/components/module/core-course-module.html +++ b/src/core/features/course/components/module/core-course-module.html @@ -81,10 +81,9 @@ *ngIf="(showActivityDates && module.dates && module.dates.length) || module.availabilityinfo">
-

- {{ date.label }} - {{ date.readableTime }} -

+ +
diff --git a/src/core/features/course/pages/course-summary/course-summary.html b/src/core/features/course/pages/course-summary/course-summary.html index 0336344b6..56e89e0fa 100644 --- a/src/core/features/course/pages/course-summary/course-summary.html +++ b/src/core/features/course/pages/course-summary/course-summary.html @@ -57,16 +57,14 @@
-

- - {{ 'core.course.startdate' | translate }}
- {{ course.startdate * 1000 | coreFormatDate:'strftimedaydatetime' }} -

-

- - {{ 'core.course.enddate' | translate }}
- {{ course.enddate * 1000 | coreFormatDate:'strftimedaydatetime' }} -

+ + + +
diff --git a/src/core/features/course/pages/course-summary/course-summary.module.ts b/src/core/features/course/pages/course-summary/course-summary.module.ts index daeb7f70c..c925524f7 100644 --- a/src/core/features/course/pages/course-summary/course-summary.module.ts +++ b/src/core/features/course/pages/course-summary/course-summary.module.ts @@ -17,6 +17,7 @@ import { RouterModule, Routes } from '@angular/router'; import { CoreSharedModule } from '@/core/shared.module'; import { CoreCourseSummaryPage } from './course-summary'; +import { CoreRemindersComponentsModule } from '@features/reminders/components/components.module'; const routes: Routes = [ { @@ -27,6 +28,7 @@ const routes: Routes = [ @NgModule({ imports: [ CoreSharedModule, + CoreRemindersComponentsModule, ], declarations: [ CoreCourseSummaryPage, @@ -39,6 +41,7 @@ export class CoreCoursePreviewPageComponentModule { } RouterModule.forChild(routes), CoreSharedModule, CoreCoursePreviewPageComponentModule, + CoreRemindersComponentsModule, ], exports: [RouterModule], }) diff --git a/src/core/features/course/services/course-helper.ts b/src/core/features/course/services/course-helper.ts index 58ae2f26d..65d242603 100644 --- a/src/core/features/course/services/course-helper.ts +++ b/src/core/features/course/services/course-helper.ts @@ -27,7 +27,6 @@ import { CoreCourseModuleCompletionTracking, CoreCourseModuleCompletionStatus, CoreCourseGetContentsWSModule, - CoreCourseGetContentsWSModuleDate, } from './course'; import { CoreConstants } from '@/core/constants'; import { CoreLogger } from '@singletons/logger'; @@ -1189,7 +1188,7 @@ export class CoreCourseHelperProvider { * This should be used in 3.6 sites or higher, where the course contents already include the completion. * * @param courseId The course to get the completion. - * @param mmodule The module. + * @param module The module. * @param siteId Site ID. If not defined, current site. * @return Promise resolved when done. */ @@ -2086,20 +2085,12 @@ export type CoreCourseSectionWithStatus = CoreCourseSection & { /** * Module with calculated data. */ -export type CoreCourseModuleData = Omit & { +export type CoreCourseModuleData = Omit & { course: number; // The course id. isStealth?: boolean; handlerData?: CoreCourseModuleHandlerData; completiondata?: CoreCourseModuleCompletionData; section: number; - dates?: CoreCourseModuleDate[]; -}; - -/** - * Module date with calculated data. - */ -export type CoreCourseModuleDate = CoreCourseGetContentsWSModuleDate & { - readableTime: string; }; /** diff --git a/src/core/features/course/services/course.ts b/src/core/features/course/services/course.ts index 16a680440..70b2ca22b 100644 --- a/src/core/features/course/services/course.ts +++ b/src/core/features/course/services/course.ts @@ -38,7 +38,7 @@ import { import { CoreDomUtils } from '@services/utils/dom'; import { CoreWSError } from '@classes/errors/wserror'; import { CorePushNotifications } from '@features/pushnotifications/services/pushnotifications'; -import { CoreCourseHelper, CoreCourseModuleData, CoreCourseModuleCompletionData, CoreCourseModuleDate } from './course-helper'; +import { CoreCourseHelper, CoreCourseModuleData, CoreCourseModuleCompletionData } from './course-helper'; import { CoreCourseFormatDelegate } from './format-delegate'; import { CoreCronDelegate } from '@services/cron'; import { CoreCourseLogCronHandler } from './handlers/log-cron'; @@ -53,7 +53,6 @@ import { CoreDatabaseTable } from '@classes/database/database-table'; import { CoreDatabaseCachingStrategy } from '@classes/database/database-table-proxy'; import { SQLiteDB } from '@classes/sqlitedb'; import { CorePlatform } from '@services/platform'; -import { CoreTime } from '@singletons/time'; import { asyncObservable, firstValueFrom } from '@/core/utils/rxjs'; import { map } from 'rxjs/operators'; @@ -678,39 +677,11 @@ export class CoreCourseProvider { }; } - let formattedDates: CoreCourseModuleDate[] | undefined; - - if (module.dates) { - formattedDates = module.dates.map(date => { - let readableTime = ''; - if (!date.relativeto) { - readableTime = CoreTimeUtils.userDate(date.timestamp * 1000, 'core.strftimedatetime', true); - } else { - readableTime = Translate.instant( - 'core.course.relativedatessubmissionduedate' + (date.timestamp > date.relativeto ? 'after' : 'before'), - { - $a: { - datediffstr: date.relativeto === date.timestamp ? - '0 ' + Translate.instant('core.secs') : - CoreTime.formatTime(date.relativeto - date.timestamp, 3), - }, - }, - ); - } - - return { - ...date, - readableTime, - }; - }); - } - return { ...module, course: courseId, section: sectionId, completiondata: completionData, - dates: formattedDates, }; } @@ -1775,7 +1746,10 @@ export type CoreCourseGetContentsWSModule = { completiondata?: CoreCourseModuleWSCompletionData; // Module completion data. contents?: CoreCourseModuleContentFile[]; downloadcontent?: number; // @since 4.0 The download content value. - dates?: CoreCourseGetContentsWSModuleDate[]; // @since 3.11. Activity dates. + dates?: { + label: string; + timestamp: number; + }[]; // @since 3.11. Activity dates. contentsinfo?: { // @since v3.7.6 Contents summary information. filescount: number; // Total number of files. filessize: number; // Total files size. @@ -1785,16 +1759,6 @@ export type CoreCourseGetContentsWSModule = { }; }; -/** - * Activity date. - */ -export type CoreCourseGetContentsWSModuleDate = { - label: string; - timestamp: number; - relativeto?: number; // @since 4.1. Relative date timestamp. - dataid?: string; // @since 4.1. ID to identify the text. -}; - /** * Data returned by core_course_get_contents WS. */ diff --git a/src/core/features/emulator/emulator.module.ts b/src/core/features/emulator/emulator.module.ts index 227648524..48ef1aa05 100644 --- a/src/core/features/emulator/emulator.module.ts +++ b/src/core/features/emulator/emulator.module.ts @@ -25,6 +25,7 @@ import { FileOpener } from '@ionic-native/file-opener/ngx'; import { FileTransfer } from '@ionic-native/file-transfer/ngx'; import { Geolocation } from '@ionic-native/geolocation/ngx'; import { InAppBrowser } from '@ionic-native/in-app-browser/ngx'; +import { LocalNotifications } from '@ionic-native/local-notifications/ngx'; import { MediaCapture } from '@ionic-native/media-capture/ngx'; import { Zip } from '@ionic-native/zip/ngx'; @@ -36,9 +37,11 @@ import { FileOpenerMock } from './services/file-opener'; import { FileTransferMock } from './services/file-transfer'; import { GeolocationMock } from './services/geolocation'; import { InAppBrowserMock } from './services/inappbrowser'; +import { LocalNotificationsMock } from './services/local-notifications'; import { MediaCaptureMock } from './services/media-capture'; import { ZipMock } from './services/zip'; import { CorePlatform } from '@services/platform'; +import { CoreLocalNotifications } from '@services/local-notifications'; /** * This module handles the emulation of Cordova plugins in browser and desktop. @@ -90,6 +93,12 @@ import { CorePlatform } from '@services/platform'; provide: Zip, useFactory: (): Zip => CorePlatform.is('cordova') ? new Zip() : new ZipMock(), }, + { + provide: LocalNotifications, + useFactory: (): LocalNotifications => CoreLocalNotifications.isPluginAvailable() + ? new LocalNotifications() + : new LocalNotificationsMock(), + }, { provide: APP_INITIALIZER, useFactory: () => () => { diff --git a/src/core/features/emulator/services/local-notifications.ts b/src/core/features/emulator/services/local-notifications.ts new file mode 100644 index 000000000..8fac97b80 --- /dev/null +++ b/src/core/features/emulator/services/local-notifications.ts @@ -0,0 +1,407 @@ +// (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 { CoreError } from '@classes/errors/error'; +import { ILocalNotification, ILocalNotificationAction, LocalNotifications } from '@ionic-native/local-notifications/ngx'; +import { Observable, Subject } from 'rxjs'; + +/** + * Mock LocalNotifications service. + */ +export class LocalNotificationsMock extends LocalNotifications { + + protected scheduledNotifications: ILocalNotification[] = []; + protected triggeredNotifications: ILocalNotification[] = []; + protected presentNotifications: Record = {}; + protected nextTimeout = 0; + protected hasGranted?: boolean; + protected observables = { + trigger: new Subject(), + click: new Subject(), + clear: new Subject(), + clearall: new Subject(), + cancel: new Subject(), + cancelall: new Subject(), + schedule: new Subject(), + update: new Subject(), + }; + + /** + * @inheritdoc + */ + schedule(options?: ILocalNotification | Array): void { + this.hasPermission().then(() => { + // Do not check permission here, it could be denied by Selenium. + if (!options) { + return; + } + + if (!Array.isArray(options)) { + options = [options]; + } + + this.scheduledNotifications = this.scheduledNotifications.concat(options); + this.scheduledNotifications.sort((a, b) => + (a.trigger?.at?.getTime() || 0) - (b.trigger?.at?.getTime() || 0)); + + options.forEach((notification) => { + this.observables.schedule.next(notification); + }); + + this.scheduleNotifications(); + + return; + }).catch(() => { + // Ignore errors. + }); + } + + /** + * Sets timeout for next nofitication. + */ + protected scheduleNotifications(): void { + window.clearTimeout(this.nextTimeout); + + const nextNotification = this.scheduledNotifications[0]; + if (!nextNotification) { + return; + } + + const notificationTime = nextNotification.trigger?.at?.getTime() || 0; + const timeout = notificationTime - Date.now(); + if (timeout <= 0) { + this.triggerNextNotification(); + + return; + } + + this.nextTimeout = window.setTimeout(() => { + this.triggerNextNotification(); + }, timeout); + } + + /** + * Shows the next notification. + */ + protected triggerNextNotification(): void { + const dateNow = Date.now(); + + const nextNotification = this.scheduledNotifications[0]; + if (!nextNotification) { + return; + } + + const notificationTime = nextNotification.trigger?.at?.getTime() || 0; + if (notificationTime === 0 || notificationTime <= dateNow) { + const body = Array.isArray(nextNotification.text) ? nextNotification.text.join() : nextNotification.text; + const notification = new Notification(nextNotification.title || '', { + body, + data: nextNotification.data, + icon: nextNotification.icon, + requireInteraction: true, + tag: nextNotification.data?.component, + }); + + this.triggeredNotifications.push(nextNotification); + + this.observables.trigger.next(nextNotification); + + notification.addEventListener('click', () => { + this.observables.click.next(nextNotification); + }); + + if (nextNotification.id) { + this.presentNotifications[nextNotification.id] = notification; + + notification.addEventListener('close', () => { + delete(this.presentNotifications[nextNotification.id ?? 0]); + }); + } + + this.scheduledNotifications.shift(); + this.triggerNextNotification(); + } else { + this.scheduleNotifications(); + } + } + + /** + * @inheritdoc + */ + update(options?: ILocalNotification): void { + if (!options?.id) { + return; + } + const index = this.scheduledNotifications.findIndex((notification) => notification.id === options.id); + if (index < 0) { + return; + } + + this.observables.update.next(options); + + this.scheduledNotifications[index] = options; + } + + /** + * @inheritdoc + */ + async clear(notificationId: number | Array): Promise { + if (!Array.isArray(notificationId)) { + notificationId = [notificationId]; + } + + notificationId.forEach((id) => { + if (!this.presentNotifications[id]) { + return; + } + + this.presentNotifications[id].close(); + + this.observables.clear.next(this.presentNotifications[id]); + delete this.presentNotifications[id]; + }); + } + + /** + * @inheritdoc + */ + async clearAll(): Promise { + for (const x in this.presentNotifications) { + this.presentNotifications[x].close(); + } + this.presentNotifications = {}; + + this.observables.clearall.next(); + + } + + /** + * @inheritdoc + */ + async cancel(notificationId: number | Array): Promise { + if (!Array.isArray(notificationId)) { + notificationId = [notificationId]; + } + + notificationId.forEach((id) => { + const index = this.scheduledNotifications.findIndex((notification) => notification.id === id); + this.observables.cancel.next(this.scheduledNotifications[index]); + + this.scheduledNotifications.splice(index, 1); + }); + + this.scheduleNotifications(); + } + + /** + * @inheritdoc + */ + async cancelAll(): Promise { + window.clearTimeout(this.nextTimeout); + this.scheduledNotifications = []; + + this.observables.cancelall.next(); + } + + /** + * @inheritdoc + */ + async isPresent(notificationId: number): Promise { + return !!this.presentNotifications[notificationId]; + } + + /** + * @inheritdoc + */ + async isScheduled(notificationId: number): Promise { + return this.scheduledNotifications.some((notification) => notification.id === notificationId); + } + + /** + * @inheritdoc + */ + async isTriggered(notificationId: number): Promise { + return this.triggeredNotifications.some((notification) => notification.id === notificationId); + + } + + /** + * @inheritdoc + */ + async getIds(): Promise> { + const ids = await this.getScheduledIds(); + const triggeredIds = await this.getTriggeredIds(); + + return Promise.resolve(ids.concat(triggeredIds)); + } + + /** + * @inheritdoc + */ + async getTriggeredIds(): Promise> { + const ids = this.triggeredNotifications + .map((notification) => notification.id || 0) + .filter((id) => id > 0); + + return ids; + } + + /** + * @inheritdoc + */ + async getScheduledIds(): Promise> { + const ids = this.scheduledNotifications + .map((notification) => notification.id || 0) + .filter((id) => id > 0); + + return ids; + } + + /** + * @inheritdoc + */ + async get(notificationId: number): Promise { + const notification = this.scheduledNotifications + .find((notification) => notification.id === notificationId); + + if (!notification) { + throw new Error('Invalid Notification Id.'); + } + + return notification; + } + + /** + * @inheritdoc + */ + async getAll(): Promise> { + return this.scheduledNotifications.concat(this.triggeredNotifications); + } + + /** + * @inheritdoc + */ + async getAllScheduled(): Promise> { + return this.scheduledNotifications; + } + + /** + * @inheritdoc + */ + async getAllTriggered(): Promise> { + return this.triggeredNotifications; + } + + /** + * @inheritdoc + */ + async registerPermission(): Promise { + // We need to ask the user for permission + const permission = await Notification.requestPermission(); + + this.hasGranted = permission === 'granted'; + + // If the user accepts, let's create a notification + return this.hasGranted; + } + + /** + * @inheritdoc + */ + async hasPermission(): Promise { + if (this.hasGranted !== undefined) { + return this.hasGranted; + } + + if (!('Notification' in window)) { + // Check if the browser supports notifications + throw new CoreError('This browser does not support desktop notification'); + } + + return this.registerPermission(); + } + + /** + * @inheritdoc + */ + async addActions(groupId: unknown, actions: Array): Promise> { + // Not implemented. + return actions; + } + + /** + * @inheritdoc + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async removeActions(groupId: unknown): Promise { + // Not implemented. + return; + } + + /** + * @inheritdoc + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async hasActions(groupId: unknown): Promise { + // Not implemented. + return false; + } + + /** + * @inheritdoc + */ + async getDefaults(): Promise { + // Not implemented. + return; + } + + /** + * @inheritdoc + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async setDefaults(defaults: unknown): Promise { + // Not implemented. + return; + } + + /** + * @inheritdoc + */ + on(eventName: string): Observable { + if (!this.observables[eventName]) { + this.observables[eventName] = new Subject(); + } + + return this.observables[eventName]; + } + + /** + * @inheritdoc + */ + fireEvent(eventName: string, args: unknown): void { + if (!this.observables[eventName]) { + return; + } + + this.observables[eventName].next(args); + } + + /** + * @inheritdoc + */ + async fireQueuedEvents(): Promise { + return this.triggerNextNotification(); + } + +} diff --git a/src/core/features/features.module.ts b/src/core/features/features.module.ts index e5c8ee34f..7b5d7e569 100644 --- a/src/core/features/features.module.ts +++ b/src/core/features/features.module.ts @@ -27,9 +27,11 @@ import { CoreGradesModule } from './grades/grades.module'; import { CoreH5PModule } from './h5p/h5p.module'; import { CoreLoginModule } from './login/login.module'; import { CoreMainMenuModule } from './mainmenu/mainmenu.module'; +import { CoreNativeModule } from '@features/native/native.module'; import { CorePushNotificationsModule } from './pushnotifications/pushnotifications.module'; import { CoreQuestionModule } from './question/question.module'; import { CoreRatingModule } from './rating/rating.module'; +import { CoreRemindersModule } from './reminders/reminders.module'; import { CoreSearchModule } from './search/search.module'; import { CoreSettingsModule } from './settings/settings.module'; import { CoreSharedFilesModule } from './sharedfiles/sharedfiles.module'; @@ -37,11 +39,10 @@ import { CoreSiteHomeModule } from './sitehome/sitehome.module'; import { CoreSitePluginsModule } from './siteplugins/siteplugins.module'; import { CoreStylesModule } from './styles/styles.module'; import { CoreTagModule } from './tag/tag.module'; -import { CoreUserToursModule } from './usertours/user-tours.module'; import { CoreUserModule } from './user/user.module'; +import { CoreUserToursModule } from './usertours/user-tours.module'; import { CoreViewerModule } from './viewer/viewer.module'; import { CoreXAPIModule } from './xapi/xapi.module'; -import { CoreNativeModule } from '@features/native/native.module'; @NgModule({ imports: [ @@ -61,15 +62,16 @@ import { CoreNativeModule } from '@features/native/native.module'; CorePushNotificationsModule, CoreQuestionModule, CoreRatingModule, + CoreRemindersModule, CoreSearchModule, CoreSettingsModule, CoreSharedFilesModule, CoreSiteHomeModule, CoreSitePluginsModule, - CoreTagModule, CoreStylesModule, - CoreUserToursModule, + CoreTagModule, CoreUserModule, + CoreUserToursModule, CoreViewerModule, CoreXAPIModule, diff --git a/src/core/features/pushnotifications/services/pushnotifications.ts b/src/core/features/pushnotifications/services/pushnotifications.ts index b7865abda..31407566f 100644 --- a/src/core/features/pushnotifications/services/pushnotifications.ts +++ b/src/core/features/pushnotifications/services/pushnotifications.ts @@ -473,11 +473,6 @@ export class CorePushNotificationsProvider { return this.notificationClicked(data); } - // If the app is in foreground when the notification is received, it's not shown. Let's show it ourselves. - if (!CoreLocalNotifications.isAvailable()) { - return this.notifyReceived(notification, data); - } - const localNotif: ILocalNotification = { id: Number(data.notId) || 1, data: data, diff --git a/src/core/features/reminders/components/components.module.ts b/src/core/features/reminders/components/components.module.ts new file mode 100644 index 000000000..c99c2b06a --- /dev/null +++ b/src/core/features/reminders/components/components.module.ts @@ -0,0 +1,39 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSharedModule } from '@/core/shared.module'; +import { NgModule } from '@angular/core'; +import { CoreRemindersDateComponent } from './date/date'; +import { CoreRemindersSetButtonComponent } from './set-button/set-button'; +import { CoreRemindersSetReminderCustomComponent } from './set-reminder-custom/set-reminder-custom'; +import { CoreRemindersSetReminderMenuComponent } from './set-reminder-menu/set-reminder-menu'; + +@NgModule({ + declarations: [ + CoreRemindersDateComponent, + CoreRemindersSetButtonComponent, + CoreRemindersSetReminderCustomComponent, + CoreRemindersSetReminderMenuComponent, + ], + imports: [ + CoreSharedModule, + ], + exports: [ + CoreRemindersDateComponent, + CoreRemindersSetButtonComponent, + CoreRemindersSetReminderCustomComponent, + CoreRemindersSetReminderMenuComponent, + ], +}) +export class CoreRemindersComponentsModule {} diff --git a/src/core/features/reminders/components/date/date.html b/src/core/features/reminders/components/date/date.html new file mode 100644 index 000000000..4f75b5422 --- /dev/null +++ b/src/core/features/reminders/components/date/date.html @@ -0,0 +1,8 @@ +
+ + {{ label }} {{ readableTime }} +
+ + + diff --git a/src/core/features/reminders/components/date/date.scss b/src/core/features/reminders/components/date/date.scss new file mode 100644 index 000000000..e898db6da --- /dev/null +++ b/src/core/features/reminders/components/date/date.scss @@ -0,0 +1,21 @@ +@import "~theme/globals"; + +:host { + display: flex; + flex-direction: row; +} + +div { + flex-grow: 1; + font-size: 14px; + margin: 0; + align-self: center; + + ion-icon { + @include margin-horizontal(0px, 4px); + } +} + +core-reminders-date + :host { + margin-top: 12px; +} diff --git a/src/core/features/reminders/components/date/date.ts b/src/core/features/reminders/components/date/date.ts new file mode 100644 index 000000000..137d88d4b --- /dev/null +++ b/src/core/features/reminders/components/date/date.ts @@ -0,0 +1,95 @@ +// (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 { CoreReminders } from '@features/reminders/services/reminders'; +import { Component, Input, OnInit } from '@angular/core'; +import { CoreTimeUtils } from '@services/utils/time'; +import { Translate } from '@singletons'; +import { CoreTime } from '@singletons/time'; + +/** + * Component that displays a date to remind. + */ +@Component({ + selector: 'core-reminders-date', + templateUrl: 'date.html', + styleUrls: ['date.scss'], +}) +export class CoreRemindersDateComponent implements OnInit { + + @Input() component?: string; + @Input() instanceId?: number; + @Input() type?: string; + @Input() label = ''; + @Input() time = 0; + @Input() relativeTo = 0; + @Input() title = ''; + @Input() url = ''; + + showReminderButton = false; + timebefore?: number; // Undefined means no reminder has been set. + readableTime = ''; + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + this.readableTime = this.getReadableTime(this.time, this.relativeTo); + + // If not set, button won't be shown. + if (this.component === undefined || this.instanceId === undefined || this.type === undefined) { + return; + } + + const remindersEnabled = CoreReminders.isEnabled(); + this.showReminderButton = remindersEnabled && this.time > CoreTimeUtils.timestamp(); + + if (!this.showReminderButton) { + return; + } + + const reminders = await CoreReminders.getReminders({ + instanceId: this.instanceId, + component: this.component, + type: this.type, + }); + + this.timebefore = reminders[0]?.timebefore; + } + + /** + * Returns the readable time. + * + * @param timestamp Timestamp. + * @param relativeTo Base timestamp if timestamp is relative to this one. + * @return Readable time string. + */ + protected getReadableTime(timestamp: number, relativeTo = 0): string { + if (!relativeTo) { + return CoreTimeUtils.userDate(timestamp * 1000, 'core.strftimedatetime', true); + } + + return Translate.instant( + 'core.course.relativedatessubmissionduedate' + (timestamp > relativeTo ? 'after' : 'before'), + { + $a: { + datediffstr: relativeTo === timestamp ? + '0 ' + Translate.instant('core.secs') : + CoreTime.formatTime(relativeTo - timestamp, 3), + }, + }, + ); + } + +} diff --git a/src/core/features/reminders/components/set-button/set-button.html b/src/core/features/reminders/components/set-button/set-button.html new file mode 100644 index 000000000..97b9f1b9f --- /dev/null +++ b/src/core/features/reminders/components/set-button/set-button.html @@ -0,0 +1,6 @@ + + + + diff --git a/src/core/features/reminders/components/set-button/set-button.ts b/src/core/features/reminders/components/set-button/set-button.ts new file mode 100644 index 000000000..010b0baf7 --- /dev/null +++ b/src/core/features/reminders/components/set-button/set-button.ts @@ -0,0 +1,134 @@ +// (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 { CoreReminderData, CoreReminders, CoreRemindersService } from '@features/reminders/services/reminders'; +import { Component, Input, OnInit } from '@angular/core'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreRemindersSetReminderMenuComponent } from '../set-reminder-menu/set-reminder-menu'; +import { Translate } from '@singletons'; +import { CoreTimeUtils } from '@services/utils/time'; + +/** + * Component that displays a button to set a reminder. + */ +@Component({ + selector: 'core-reminders-set-button', + templateUrl: 'set-button.html', +}) +export class CoreRemindersSetButtonComponent implements OnInit { + + @Input() component?: string; + @Input() instanceId?: number; + @Input() type?: string; + @Input() label = ''; + @Input() timebefore?: number; + @Input() time = -1; + @Input() title = ''; + @Input() url = ''; + + labelClean = ''; + + /** + * @inheritdoc + */ + ngOnInit(): void { + this.labelClean = this.label.replace(':', ''); + } + + /** + * Set reminder. + * + * @param ev Click event. + */ + async setReminder(ev: Event): Promise { + if (this.component === undefined || this.instanceId === undefined || this.type === undefined) { + return; + } + + ev.preventDefault(); + ev.stopPropagation(); + + if (this.timebefore === undefined) { + // Set it to the time of the event. + this.saveReminder(0); + + return; + } + + // Open popover. + const reminderTime = await CoreDomUtils.openPopover<{timeBefore: number}>({ + component: CoreRemindersSetReminderMenuComponent, + componentProps: { + initialValue: this.timebefore, + eventTime: this.time, + noReminderLabel: 'core.reminders.delete', + }, + event: ev, + }); + + if (reminderTime === undefined) { + // User canceled. + return; + } + + // Save before. + this.saveReminder(reminderTime.timeBefore); + } + + /** + * Save reminder. + * + * @param timebefore Time before the event to fire the notification. + * @return Promise resolved when done. + */ + protected async saveReminder(timebefore: number): Promise { + if (this.component === undefined || this.instanceId === undefined || this.type === undefined) { + return; + } + + if (timebefore === undefined || timebefore === CoreRemindersService.DISABLED) { + // Remove the reminder. + await CoreReminders.removeReminders({ + instanceId: this.instanceId, + component: this.component, + type: this.type, + }); + this.timebefore = undefined; + + CoreDomUtils.showToast('core.reminders.reminderunset', true); + + return; + } + + this.timebefore = timebefore; + + const reminder: CoreReminderData = { + component: this.component, + instanceId: this.instanceId, + timebefore: this.timebefore, + type: this.type, + title: this.label + ' ' + this.title, + url: this.url, + time: this.time, + }; + + // Save before. + await CoreReminders.addReminder(reminder); + + const time = this.time - timebefore; + const text = Translate.instant('core.reminders.reminderset', { $a: CoreTimeUtils.userDate(time * 1000) }); + CoreDomUtils.showToast(text); + } + +} diff --git a/src/core/features/reminders/components/set-reminder-custom/set-reminder-custom.html b/src/core/features/reminders/components/set-reminder-custom/set-reminder-custom.html new file mode 100644 index 000000000..023ea6791 --- /dev/null +++ b/src/core/features/reminders/components/set-reminder-custom/set-reminder-custom.html @@ -0,0 +1,38 @@ + + + +

{{ 'core.reminders.customreminder' | translate }}

+
+
+
+ + +
+ + + + + + {{ 'core.reminders.units' | translate }} + + + {{ option.label | translate }} + + +
+
+ + + + {{ 'core.cancel' | translate }} + + + + {{ 'core.reminders.setreminder' | translate }} + + + + +
diff --git a/src/core/features/reminders/components/set-reminder-custom/set-reminder-custom.ts b/src/core/features/reminders/components/set-reminder-custom/set-reminder-custom.ts new file mode 100644 index 000000000..617309627 --- /dev/null +++ b/src/core/features/reminders/components/set-reminder-custom/set-reminder-custom.ts @@ -0,0 +1,64 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input } from '@angular/core'; +import { CoreRemindersUnits } from '@features/reminders/services/reminders'; +import { PopoverController } from '@singletons'; + +/** + * This component is meant to set a custom reminder + */ +@Component({ + templateUrl: 'set-reminder-custom.html', +}) +export class CoreRemindersSetReminderCustomComponent { + + @Input() customValue = 10; + @Input() customUnits = CoreRemindersUnits.MINUTE; + + customUnitsOptions = [ + { + value: CoreRemindersUnits.MINUTE, + label: 'core.minutes', + }, + { + value: CoreRemindersUnits.HOUR, + label: 'core.hours', + }, + { + value: CoreRemindersUnits.DAY, + label: 'core.days', + }, + { + value: CoreRemindersUnits.WEEK, + label: 'core.weeks', + }, + ]; + + /** + * Set custom reminder. + */ + set(): void { + // Return it as an object because 0 means undefined if not. + PopoverController.dismiss({ value: this.customValue, unit: this.customUnits }); + } + + /** + * Close popup. + */ + cancel(): void { + PopoverController.dismiss(); + } + +} diff --git a/src/core/features/reminders/components/set-reminder-menu/set-reminder-menu.html b/src/core/features/reminders/components/set-reminder-menu/set-reminder-menu.html new file mode 100644 index 000000000..c7d1c24e8 --- /dev/null +++ b/src/core/features/reminders/components/set-reminder-menu/set-reminder-menu.html @@ -0,0 +1,38 @@ + + + +

{{ 'core.reminders.setareminder' | translate }}

+
+
+
+ + + + + + +

{{ option.label }}

+
+ +
+
+ + + + +

{{ 'core.reminders.custom' | translate }}

+

{{ customLabel }}

+
+ +
+ + + +

{{ noReminderLabel | translate }}

+
+ +
+ +
diff --git a/src/core/features/reminders/components/set-reminder-menu/set-reminder-menu.scss b/src/core/features/reminders/components/set-reminder-menu/set-reminder-menu.scss new file mode 100644 index 000000000..db4708ab3 --- /dev/null +++ b/src/core/features/reminders/components/set-reminder-menu/set-reminder-menu.scss @@ -0,0 +1,3 @@ +ion-item.border-top { + border-top: 1px solid var(--stroke); +} diff --git a/src/core/features/reminders/components/set-reminder-menu/set-reminder-menu.ts b/src/core/features/reminders/components/set-reminder-menu/set-reminder-menu.ts new file mode 100644 index 000000000..4fac9cdb3 --- /dev/null +++ b/src/core/features/reminders/components/set-reminder-menu/set-reminder-menu.ts @@ -0,0 +1,181 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnInit } from '@angular/core'; +import { + CoreReminders, + CoreRemindersService, + CoreRemindersUnits, + CoreReminderValueAndUnit, +} from '@features/reminders/services/reminders'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { PopoverController } from '@singletons'; +import { CoreRemindersSetReminderCustomComponent } from '../set-reminder-custom/set-reminder-custom'; + +/** + * This component is meant to display a popover with the reminder options. + */ +@Component({ + templateUrl: 'set-reminder-menu.html', + styleUrls: ['set-reminder-menu.scss'], +}) +export class CoreRemindersSetReminderMenuComponent implements OnInit { + + @Input() initialValue?: number; + @Input() eventTime?: number; + @Input() noReminderLabel = ''; + + currentValue = '0m'; + customLabel = ''; + + protected customValue = 10; + protected customUnits = CoreRemindersUnits.MINUTE; + + presetOptions = [ + { + radioValue: '0m', + value: 0, + unit: CoreRemindersUnits.MINUTE, + label: '', + enabled: true, + }, + { + radioValue: '1h', + value: 1, + unit: CoreRemindersUnits.HOUR, + label: '', + enabled: true, + }, + { + radioValue: '12h', + value: 12, + unit: CoreRemindersUnits.HOUR, + label: '', + enabled: true, + }, + { + radioValue: '1d', + value: 1, + unit: CoreRemindersUnits.DAY, + label: '', + enabled: true, + }, + ]; + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + this.presetOptions.forEach((option) => { + option.label = CoreReminders.getUnitValueLabel(option.value, option.unit); + option.enabled = this.isValidTime(option.unit, option.value); + }); + + const initialValue = CoreRemindersService.convertSecondsToValueAndUnit(this.initialValue); + if (initialValue.value === CoreRemindersService.DISABLED) { + this.currentValue = 'disabled'; + + return; + } + + // Search if it's one of the preset options. + const option = this.presetOptions.find(option => + option.value === initialValue?.value && option.unit === initialValue.unit); + + if (option) { + this.currentValue = option.radioValue; + } else { + // It's a custom value. + this.currentValue = 'custom'; + this.customValue = initialValue.value; + this.customUnits = initialValue.unit; + this.customLabel = CoreReminders.getUnitValueLabel(this.customValue, this.customUnits); + } + } + + /** + * Set the reminder. + * + * @param value Value to set. + */ + setReminder(value?: string): void { + const option = this.presetOptions.find(option => option.radioValue === value); + + if (!option) { + PopoverController.dismiss(); + + return; + } + + PopoverController.dismiss({ timeBefore: option.unit * option.value }); + } + + /** + * Disable the reminder. + */ + disableReminder(): void { + PopoverController.dismiss({ timeBefore: undefined }); + } + + /** + * Check the time is on the future. + * + * @param unit Time unit. + * @param value Time value. + * @return Wether is a valid time or not. + */ + protected isValidTime(unit: number, value: number): boolean { + if (!this.eventTime) { + return true; + } + + const timebefore = unit * value; + + return (this.eventTime - timebefore) * 1000 > Date.now(); + } + + /** + * Custom value input clicked. + * + * @param ev Click event. + */ + async setCustom(ev: Event): Promise { + const reminderTime = await CoreDomUtils.openPopover({ + component: CoreRemindersSetReminderCustomComponent, + componentProps: { + customValue: this.customValue, + customUnits: this.customUnits, + }, + waitForDismissCompleted: true, // To be able to close parent popup. + event: ev, + }); + + if (reminderTime === undefined) { + // User canceled. + return; + } + + this.currentValue = 'custom'; + this.customValue = reminderTime.value; + this.customUnits = reminderTime.unit; + this.customLabel = CoreReminders.getUnitValueLabel(this.customValue, this.customUnits); + + // Let the dimissed popover to be removed. + await CoreUtils.nextTick(); + + PopoverController.dismiss({ timeBefore: Math.abs(this.customValue) * this.customUnits }); + } + +} diff --git a/src/core/features/reminders/lang.json b/src/core/features/reminders/lang.json new file mode 100644 index 000000000..98351de60 --- /dev/null +++ b/src/core/features/reminders/lang.json @@ -0,0 +1,14 @@ +{ + "atthetime": "At the time of the event", + "custom": "Custom...", + "customreminder": "Custom reminder", + "delete": "Delete reminder", + "reminderset": "Reminder set for {{$a}}", + "reminderunset": "Reminder deleted", + "setareminder": "Set a reminder", + "setareminderfor": "Set a reminder for \"{{title}}\" ({{label}})", + "setreminder": "Set reminder", + "timebefore": "{{value}} {{units}} before", + "units": "Units", + "value": "Value" +} diff --git a/src/core/features/reminders/reminders.module.ts b/src/core/features/reminders/reminders.module.ts new file mode 100644 index 000000000..1155eac0e --- /dev/null +++ b/src/core/features/reminders/reminders.module.ts @@ -0,0 +1,44 @@ +// (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 { APP_INITIALIZER, NgModule, Type } from '@angular/core'; +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { CoreRemindersComponentsModule } from './components/components.module'; +import { REMINDERS_SITE_SCHEMA } from './services/database/reminders'; +import { CoreReminders, CoreRemindersService } from './services/reminders'; + +export const CORE_REMINDERS_SERVICES: Type[] = [ + CoreRemindersService, +]; + +@NgModule({ + imports: [ + CoreRemindersComponentsModule, + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [REMINDERS_SITE_SCHEMA], + multi: true, + }, + { + provide: APP_INITIALIZER, + multi: true, + useValue: async () => { + await CoreReminders.initialize(); + }, + }, + ], +}) +export class CoreRemindersModule {} diff --git a/src/core/features/reminders/services/database/reminders.ts b/src/core/features/reminders/services/database/reminders.ts new file mode 100644 index 000000000..3bb9d6abe --- /dev/null +++ b/src/core/features/reminders/services/database/reminders.ts @@ -0,0 +1,225 @@ +// (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 { AddonCalendarProvider } from '@addons/calendar/services/calendar'; +import { AddonCalendarEventDBRecord, EVENTS_TABLE } from '@addons/calendar/services/database/calendar'; +import { SQLiteDB } from '@classes/sqlitedb'; +import { CoreSiteSchema } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreReminderData, CoreRemindersService } from '../reminders'; + +/** + * Database variables for CoreRemindersService service. + */ +export const REMINDERS_TABLE = 'core_reminders'; +export const REMINDERS_SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreRemindersService', + version: 1, + canBeCleared: [], + tables: [ + { + name: REMINDERS_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'component', + type: 'TEXT', + notNull: true, + }, + { + name: 'instanceId', + type: 'INTEGER', + notNull: true, + }, + { + name: 'type', + type: 'TEXT', + notNull: true, + }, + { + name: 'time', + type: 'INTEGER', + notNull: true, + }, + { + name: 'timebefore', + type: 'INTEGER', + notNull: true, + }, + { + name: 'title', + type: 'TEXT', + notNull: true, + }, + { + name: 'url', + type: 'TEXT', + }, + + ], + uniqueKeys: [ + ['component', 'instanceId', 'timebefore'], + ], + }, + ], + install: async (db: SQLiteDB): Promise => { + await migrateFromCalendarRemindersV1(db); + await migrateFromCalendarRemindersV2(db); + }, +}; + +const migrateFromCalendarRemindersV1 = async (db: SQLiteDB): Promise => { + // Migrate reminders. New format @since 4.0. + const oldTable = 'addon_calendar_reminders'; + + const tableExists = await CoreUtils.promiseWorks(db.tableExists(oldTable)); + if (!tableExists) { + return; + } + + const records = await db.getAllRecords(oldTable); + const events: Record = {}; + const uniqueReminder: Record = {}; + + 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. Ignore the reminder. + return; + } + } + + const event = events[record.eventid]; + + let reminderTime = record.time; + + if (!reminderTime || reminderTime === -1) { + // Default reminder. + reminderTime = CoreRemindersService.DEFAULT_REMINDER_TIMEBEFORE; + } else if (reminderTime > event.timestart) { + // Reminder is after the event, ignore it. + return; + } else { + // Remove seconds from the old reminder, it could include seconds by mistake. + reminderTime = event.timestart - Math.floor(reminderTime / 60) * 60; + } + + if (typeof uniqueReminder[record.eventid] === undefined) { + uniqueReminder[record.eventid] = []; + } else { + if (uniqueReminder[record.eventid].includes(reminderTime)) { + // Reminder already exists. + return; + } + } + + await createReminder(db, event, reminderTime); + })); + + try { + await db.dropTable(oldTable); + } catch { + // Error deleting old table, ignore. + } +}; + +const migrateFromCalendarRemindersV2 = async (db: SQLiteDB): Promise => { + const oldTable = 'addon_calendar_reminders_2'; + + const tableExists = await CoreUtils.promiseWorks(db.tableExists(oldTable)); + if (!tableExists) { + return; + } + + const records = await db.getAllRecords(oldTable); + const events: Record = {}; + const uniqueReminder: Record = {}; + + 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. Ignore the reminder. + return; + } + } + const event = events[record.eventid]; + + const reminderTime = record.time || CoreRemindersService.DEFAULT_REMINDER_TIMEBEFORE; + + if (typeof uniqueReminder[record.eventid] === undefined) { + uniqueReminder[record.eventid] = []; + } else { + if (uniqueReminder[record.eventid].includes(reminderTime)) { + // Reminder already exists. + return; + } + } + + uniqueReminder[record.eventid].push(reminderTime); + + await createReminder(db, event, reminderTime); + })); + + try { + await db.dropTable(oldTable); + } catch { + // Error deleting old table, ignore. + } +}; + +const createReminder = async ( + db: SQLiteDB, + event: AddonCalendarEventDBRecord, + reminderTime: number, +): Promise => { + const reminder: CoreReminderData = { + component: AddonCalendarProvider.COMPONENT, + instanceId: event.id, + type: event.eventtype, + timebefore: reminderTime, + url: event.url, + title: event.name, + time: event.timestart, + }; + + await db.insertRecord(REMINDERS_TABLE, reminder); +}; + +export type CoreReminderDBRecord = { + id: number; // Reminder ID. + component: string; // Component where the reminder belongs. + instanceId: number; // Instance Id where the reminder belongs. + type: string; // Event idenfier type. + time: number; // Event time. + timebefore: number; // Seconds before the event to remind. + title: string; // Notification title. + url?: string; // URL where to redirect the user. +}; + +type AddonCalendarReminderDBRecord = { + id: number; + eventid: number; + time: number | null; // Number of seconds before the event, null for default time. + timecreated?: number | null; +}; diff --git a/src/core/features/reminders/services/reminders.ts b/src/core/features/reminders/services/reminders.ts new file mode 100644 index 000000000..a6388eff7 --- /dev/null +++ b/src/core/features/reminders/services/reminders.ts @@ -0,0 +1,468 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreLocalNotifications } from '@services/local-notifications'; +import { CoreSites } from '@services/sites'; +import { CoreTimeUtils } from '@services/utils/time'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreReminderDBRecord, REMINDERS_TABLE } from './database/reminders'; +import { ILocalNotification } from '@ionic-native/local-notifications'; +import { CorePlatform } from '@services/platform'; +import { CoreConstants } from '@/core/constants'; +import { CoreConfig } from '@services/config'; +import { CoreEvents } from '@singletons/events'; + +/** + * Units to set a reminder. + */ +export enum CoreRemindersUnits { + MINUTE = CoreConstants.SECONDS_MINUTE, + HOUR = CoreConstants.SECONDS_HOUR, + DAY = CoreConstants.SECONDS_DAY, + WEEK = CoreConstants.SECONDS_WEEK, +} + +const REMINDER_UNITS_LABELS = { + single: { + [CoreRemindersUnits.MINUTE]: 'core.minute', + [CoreRemindersUnits.HOUR]: 'core.hour', + [CoreRemindersUnits.DAY]: 'core.day', + [CoreRemindersUnits.WEEK]: 'core.week', + }, + multi: { + [CoreRemindersUnits.MINUTE]: 'core.minutes', + [CoreRemindersUnits.HOUR]: 'core.hours', + [CoreRemindersUnits.DAY]: 'core.days', + [CoreRemindersUnits.WEEK]: 'core.weeks', + }, +}; + +/** + * Service to handle reminders. + */ +@Injectable({ providedIn: 'root' }) +export class CoreRemindersService { + + static readonly DEFAULT_REMINDER_TIMEBEFORE = -1; + static readonly DISABLED = -1; + + static readonly DEFAULT_NOTIFICATION_TIME_SETTING = 'CoreRemindersDefaultNotification'; + static readonly DEFAULT_NOTIFICATION_TIME_CHANGED = 'CoreRemindersDefaultNotificationChangedEvent'; + + /** + * Initialize the service. + * + * @return Promise resolved when done. + */ + async initialize(): Promise { + if (!this.isEnabled()) { + return; + } + + this.scheduleAllNotifications(); + + CoreEvents.on(CoreRemindersService.DEFAULT_NOTIFICATION_TIME_CHANGED, async (data) => { + const site = await CoreSites.getSite(data.siteId); + const siteId = site.getId(); + + // Get all the events that have a default reminder. + const reminders = await this.getRemindersWithDefaultTime(siteId); + + // Reschedule all the default reminders. + reminders.forEach((reminder) => + this.scheduleNotification(reminder, siteId)); + }); + } + + /** + * Returns if Reminders are enabled. + * + * @return True if reminders are enabled and available, false otherwise. + */ + isEnabled(): boolean { + return true; + } + + /** + * Save reminder to Database. + * + * @param reminder Reminder to set. + * @param siteId Site ID. If not defined, current site. + * @return Resolved when done. Rejected on failure. + */ + async addReminder(reminder: CoreReminderData, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const reminderId = await site.getDb().insertRecord(REMINDERS_TABLE, reminder); + + const reminderRecord: CoreReminderDBRecord = Object.assign(reminder, { id: reminderId }); + + await this.scheduleNotification(reminderRecord, site.getId()); + } + + /** + * Update a reminder from local Db. + * + * @param reminder Fields to update. + * @param siteId ID of the site the reminder belongs to. If not defined, use current site. + * @return Promise resolved when the reminder data is updated. + */ + async updateReminder( + reminder: CoreReminderDBRecord, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + await site.getDb().updateRecords(REMINDERS_TABLE, reminder, { id: reminder.id }); + + // Reschedule. + await this.scheduleNotification(reminder, siteId); + } + + /** + * Update all reminders of a component and instance from local Db. + * + * @param newFields Fields to update. + * @param selector Reminder selector. + * @param siteId ID of the site the reminder belongs to. If not defined, use current site. + * @return Promise resolved when the reminder data is updated. + */ + async updateReminders( + newFields: Partial, + selector: CoreReminderSelector, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const reminders = await this.getReminders(selector, site.getId()); + + await Promise.all(reminders.map((reminder) => { + reminder = Object.assign(reminder, newFields); + + return this.updateReminder(reminder, site.getId()); + })); + } + + /** + * Get all reminders from local Db. + * + * @param siteId ID of the site the reminder belongs to. If not defined, use current site. + * @return Promise resolved when the reminder data is retrieved. + */ + async getAllReminders(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return site.getDb().getRecords(REMINDERS_TABLE, undefined, 'time ASC'); + } + + /** + * Get all reminders of a component and instance from local Db. + * + * @param selector Reminder selector. + * @param siteId ID of the site the reminder belongs to. If not defined, use current site. + * @return Promise resolved when the reminder data is retrieved. + */ + async getReminders(selector: CoreReminderSelector, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return site.getDb().getRecords(REMINDERS_TABLE, selector, 'time ASC'); + } + + /** + * Get all reminders of a component with default time. + * + * @param siteId ID of the site the reminder belongs to. If not defined, use current site. + * @return Promise resolved when the reminder data is retrieved. + */ + protected async getRemindersWithDefaultTime(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return site.getDb().getRecords( + REMINDERS_TABLE, + { timebefore: CoreRemindersService.DEFAULT_REMINDER_TIMEBEFORE }, + 'time ASC', + ); + } + + /** + * Remove a reminder and cancel the notification. + * + * @param id Reminder ID. + * @param siteId ID of the site the reminder belongs to. If not defined, use current site. + * @return Promise resolved when the notification is updated. + */ + async removeReminder(id: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const reminder = await site.getDb().getRecord(REMINDERS_TABLE, { id }); + + if (this.isEnabled()) { + this.cancelReminder(id, reminder.component, site.getId()); + } + + await site.getDb().deleteRecords(REMINDERS_TABLE, { id }); + } + + /** + * Remove all reminders of the same element. + * + * @param selector Reminder selector. + * @param siteId ID of the site the reminder belongs to. If not defined, use current site. + * @return Promise resolved when the notification is updated. + */ + async removeReminders(selector: CoreReminderSelector, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + siteId = site.getId(); + + if (this.isEnabled()) { + const reminders = await this.getReminders(selector, siteId); + + reminders.forEach((reminder) => { + this.cancelReminder(reminder.id, reminder.component, siteId); + }); + } + + await site.getDb().deleteRecords(REMINDERS_TABLE, selector); + } + + /** + * Cancel a notification for a reminder. + * + * @param reminderId Reminder Id to cancel. + * @param component Reminder component. + * @param siteId ID of the site the reminder belongs to. If not defined, use current site. + * @returns + */ + async cancelReminder(reminderId: number, component: string, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + return CoreLocalNotifications.cancel(reminderId, component, siteId); + } + + /** + * Schedules a notification. If local notification plugin is not enabled, resolve the promise. + * + * @param reminder Reminder to schedule. + * @param siteId Site ID the reminder belongs to. If not defined, use current site. + * @return Promise resolved when the notification is scheduled. + */ + async scheduleNotification( + reminder: CoreReminderDBRecord, + siteId?: string, + ): Promise { + + if (!this.isEnabled()) { + return; + } + + siteId = siteId || CoreSites.getCurrentSiteId(); + + const timebefore = reminder.timebefore === CoreRemindersService.DEFAULT_REMINDER_TIMEBEFORE + ? await this.getDefaultNotificationTime(siteId) + : reminder.timebefore; + + if (timebefore === CoreRemindersService.DISABLED) { + // Notification disabled. Cancel. + return this.cancelReminder(reminder.id, reminder.component, siteId); + } + + const notificationTime = (reminder.time - timebefore) * 1000; + + if (notificationTime <= Date.now()) { // @TODO Add a threshold. + // This reminder is over, don't schedule. Cancel if it was scheduled. + return this.cancelReminder(reminder.id, reminder.component, siteId); + } + + const notificationData: CoreRemindersPushNotificationData = { + reminderId: reminder.id, + instanceId: reminder.instanceId, + siteId: siteId, + }; + + const notification: ILocalNotification = { + id: reminder.id, + title: reminder.title, + text: CoreTimeUtils.userDate(reminder.time * 1000, 'core.strftimedaydatetime', true), + icon: 'file://assets/img/icons/calendar.png', + trigger: { + at: new Date(notificationTime), + }, + data: notificationData, + }; + + return CoreLocalNotifications.schedule(notification, reminder.component, siteId); + } + + /** + * Get the all saved reminders and schedule the notification. + * If local notification plugin is not enabled, resolve the promise. + * + * @return Promise resolved when all the notifications have been scheduled. + */ + async scheduleAllNotifications(): Promise { + await CorePlatform.ready(); + + if (CoreLocalNotifications.isPluginAvailable()) { + // Notifications are already scheduled. + return; + } + + const siteIds = await CoreSites.getSitesIds(); + + await Promise.all(siteIds.map((siteId: string) => async () => { + const reminders = await this.getAllReminders(siteId); + + reminders.forEach((reminder) => { + this.scheduleNotification(reminder, siteId); + }); + })); + } + + /** + * 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: CoreRemindersUnits, addDefaultLabel = false): string { + if (value === CoreRemindersService.DISABLED) { + return Translate.instant('core.settings.disabled'); + } + + if (value === 0) { + return Translate.instant('core.reminders.atthetime'); + } + + const unitsLabel = value === 1 ? + REMINDER_UNITS_LABELS.single[unit] : + REMINDER_UNITS_LABELS.multi[unit]; + + const label = Translate.instant('core.reminders.timebefore', { + units: Translate.instant(unitsLabel), + value: value, + }); + + if (addDefaultLabel) { + return Translate.instant('core.defaultvalue', { $a: label }); + } + + return label; + } + + /** + * 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): CoreReminderValueAndUnit { + if (seconds === undefined || seconds < 0) { + return { + value: CoreRemindersService.DISABLED, + unit: CoreRemindersUnits.MINUTE, + }; + } else if (seconds === 0) { + return { + value: 0, + unit: CoreRemindersUnits.MINUTE, + }; + } else if (seconds % CoreRemindersUnits.WEEK === 0) { + return { + value: seconds / CoreRemindersUnits.WEEK, + unit: CoreRemindersUnits.WEEK, + }; + } else if (seconds % CoreRemindersUnits.DAY === 0) { + return { + value: seconds / CoreRemindersUnits.DAY, + unit: CoreRemindersUnits.DAY, + }; + } else if (seconds % CoreRemindersUnits.HOUR === 0) { + return { + value: seconds / CoreRemindersUnits.HOUR, + unit: CoreRemindersUnits.HOUR, + }; + } else { + return { + value: seconds / CoreRemindersUnits.MINUTE, + unit: CoreRemindersUnits.MINUTE, + }; + } + } + + /** + * Get the configured default notification time. + * + * @param siteId ID of the site. If not defined, use current site. + * @return Promise resolved with the default time (in seconds). + */ + async getDefaultNotificationTime(siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + const key = CoreRemindersService.DEFAULT_NOTIFICATION_TIME_SETTING + '#' + siteId; + + return CoreConfig.get(key, CoreConstants.CONFIG.calendarreminderdefaultvalue || 3600); + } + + /** + * Set the default notification time. + * + * @param time New default time. + * @param siteId ID of the site. If not defined, use current site. + * @return Promise resolved when stored. + */ + async setDefaultNotificationTime(time: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + const key = CoreRemindersService.DEFAULT_NOTIFICATION_TIME_SETTING + '#' + siteId; + + await CoreConfig.set(key, time); + + CoreEvents.trigger(CoreRemindersService.DEFAULT_NOTIFICATION_TIME_CHANGED, { time }, siteId); + } + +} + +export const CoreReminders = makeSingleton(CoreRemindersService); + +export type CoreReminderData = Omit; + +/** + * Additional data sent in push notifications, with some calculated data. + */ +export type CoreRemindersPushNotificationData = { + reminderId: number; + instanceId: number; + siteId: string; +}; + +export type CoreReminderNotificationOptions = { + title: string; +}; + +/** + * Value and unit for reminders. + */ +export type CoreReminderValueAndUnit = { + value: number; + unit: CoreRemindersUnits; +}; + +export type CoreReminderSelector = { + instanceId: number; + component: string; + type?: string; +}; diff --git a/src/core/features/reminders/tests/behat/activity_reminders.feature b/src/core/features/reminders/tests/behat/activity_reminders.feature new file mode 100644 index 000000000..d9d7d657e --- /dev/null +++ b/src/core/features/reminders/tests/behat/activity_reminders.feature @@ -0,0 +1,100 @@ +@app @javascript @core_reminders +Feature: Set a new reminder on activity + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | student1 | Student | student | student1@example.com | + And the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "course enrolments" exist: + | user | course | role | + | student1 | C1 | student | + And the following "activities" exist: + | activity | course | idnumber | name | allowsubmissionsfromdate | duedate | + | assign | C1 | assign01 | Assignment 01 | ## yesterday ## | ## now +70 minutes ## | + | assign | C1 | assign02 | Assignment 02 | ## yesterday ## | ## 1 January 2050 ## | + + Scenario: Add, delete and update reminder on activity + Given I entered the assign activity "Assignment 01" on course "Course 1" as "student1" in the app + + Then I should not find "Set a reminder for \"Assignment 01\" (Opened)" in the app + And I should find "Set a reminder for \"Assignment 01\" (Due)" in the app + And "Set a reminder for \"Assignment 01\" (Due)" should not be selected in the app + + # Default set + When I press "Set a reminder for \"Assignment 01\" (Due)" in the app + Then I should find "Reminder set for " in the app + And "Set a reminder for \"Assignment 01\" (Due)" should be selected in the app + + # Set from list + When I press "Set a reminder for \"Assignment 01\" (Due)" in the app + Then I should find "Set a reminder" in the app + And "At the time of the event" should be selected in the app + And "1 hour before" should not be selected in the app + When I press "1 hour before" in the app + Then I should find "Reminder set for " in the app + And "Set a reminder for \"Assignment 01\" (Due)" should be selected in the app + + # Custom set + When I press "Set a reminder for \"Assignment 01\" (Due)" in the app + Then I should find "Set a reminder" in the app + And "At the time of the event" should not be selected in the app + And "1 hour before" should be selected in the app + When I press "Custom..." in the app + Then I should find "Custom reminder" in the app + When I set the following fields to these values in the app: + | Value | 4 | + | Units | minutes | + And I press "Set reminder" in the app + Then I should find "Reminder set for " in the app + And "Set a reminder for \"Assignment 01\" (Due)" should be selected in the app + + # Remove + When I press "Set a reminder for \"Assignment 01\" (Due)" in the app + Then "4 minutes before" should be selected in the app + When I press "Delete reminder" in the app + Then I should find "Reminder deleted" in the app + And "Set a reminder for \"Assignment 01\" (Due)" should not be selected in the app + + # Set and check reminder + When I press "Set a reminder for \"Assignment 01\" (Due)" in the app + Then I should find "Reminder set for " in the app + When I press "Set a reminder for \"Assignment 01\" (Due)" in the app + And I press "Custom..." in the app + Then I should find "Custom reminder" in the app + When I set the following fields to these values in the app: + | Value | 69 | + | Units | minutes | + And I press "Set reminder" in the app + Then I should find "Reminder set for " in the app + When I wait "50" seconds + Then a notification with title "Due: Assignment 01" is present in the app + And I close a notification with title "Due: Assignment 01" in the app + + # Set and check reminder is cancelled + When I press "Set a reminder for \"Assignment 01\" (Due)" in the app + And I press "Custom..." in the app + Then I should find "Custom reminder" in the app + When I set the following fields to these values in the app: + | Value | 68 | + | Units | minutes | + And I press "Set reminder" in the app + Then I should find "Reminder set for " in the app + When I press "Set a reminder for \"Assignment 01\" (Due)" in the app + Then I should find "Reminder set for " in the app + When I press "Delete reminder" in the app + Then I should find "Reminder deleted" in the app + When I wait "50" seconds + Then a notification with title "Due: Assignment 01" is not present in the app + +Scenario: Check toast is correct + Given I entered the assign activity "Assignment 02" on course "Course 1" as "student1" in the app + + When I press "Set a reminder for \"Assignment 02\" (Due)" in the app + Then I should find "Reminder set for " in the app + + When I press "Set a reminder for \"Assignment 02\" (Due)" in the app + And I press "1 day before" in the app + Then I should find "Reminder set for Friday, 31 December 2049, 12:00 AM" in the app diff --git a/src/core/features/reminders/tests/behat/course_reminders.feature b/src/core/features/reminders/tests/behat/course_reminders.feature new file mode 100644 index 000000000..78f665aaf --- /dev/null +++ b/src/core/features/reminders/tests/behat/course_reminders.feature @@ -0,0 +1,56 @@ +@app @javascript @core_reminders +Feature: Set a new reminder on course + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | student1 | Student | student | student1@example.com | + And the following "courses" exist: + | fullname | shortname | category | startdate | enddate | + | Course 1 | C1 | 0 | ## yesterday ## | ## now +24 hours ## | + And the following "course enrolments" exist: + | user | course | role | + | student1 | C1 | student | + + Scenario: Add, delete and update reminder on course + Given I entered the course "Course 1" as "student1" in the app + And I press "Course summary" in the app + + Then I should not find "Set a reminder for \"Course 1\" (Course start date)" in the app + And I should find "Set a reminder for \"Course 1\" (Course end date)" in the app + And "Set a reminder for \"Course 1\" (Course end date)" should not be selected in the app + + # Default set + When I press "Set a reminder for \"Course 1\" (Course end date)" in the app + Then I should find "Reminder set for " in the app + And "Set a reminder for \"Course 1\" (Course end date)" should be selected in the app + + # Set from list + When I press "Set a reminder for \"Course 1\" (Course end date)" in the app + Then I should find "Set a reminder" in the app + And "At the time of the event" should be selected in the app + And "12 hours before" should not be selected in the app + When I press "12 hours before" in the app + Then I should find "Reminder set for " in the app + And "Set a reminder for \"Course 1\" (Course end date)" should be selected in the app + + # Custom set + When I press "Set a reminder for \"Course 1\" (Course end date)" in the app + Then I should find "Set a reminder" in the app + And "At the time of the event" should not be selected in the app + And "12 hours before" should be selected in the app + When I press "Custom..." in the app + Then I should find "Custom reminder" in the app + When I set the following fields to these values in the app: + | Value | 2 | + | Units | hours | + And I press "Set reminder" in the app + Then I should find "Reminder set for " in the app + And "Set a reminder for \"Course 1\" (Course end date)" should be selected in the app + + # Remove + When I press "Set a reminder for \"Course 1\" (Course end date)" in the app + Then "2 hours before" should be selected in the app + When I press "Delete reminder" in the app + Then I should find "Reminder deleted" in the app + And "Set a reminder for \"Course 1\" (Course end date)" should not be selected in the app diff --git a/src/core/features/settings/pages/deviceinfo/deviceinfo.ts b/src/core/features/settings/pages/deviceinfo/deviceinfo.ts index b00cc0dae..1ce2ff010 100644 --- a/src/core/features/settings/pages/deviceinfo/deviceinfo.ts +++ b/src/core/features/settings/pages/deviceinfo/deviceinfo.ts @@ -94,7 +94,7 @@ export class CoreSettingsDeviceInfoPage implements OnDestroy { lastCommit: CoreConstants.BUILD.lastCommitHash || '', networkStatus: CoreNetwork.isOnline() ? 'online' : 'offline', wifiConnection: CoreNetwork.isWifi() ? 'yes' : 'no', - localNotifAvailable: CoreLocalNotifications.isAvailable() ? 'yes' : 'no', + localNotifAvailable: CoreLocalNotifications.isPluginAvailable() ? 'yes' : 'no', pushId: CorePushNotifications.getPushId(), deviceType: '', }; diff --git a/src/core/services/app.ts b/src/core/services/app.ts index 9a3632274..ed55f1e32 100644 --- a/src/core/services/app.ts +++ b/src/core/services/app.ts @@ -138,6 +138,9 @@ export class CoreAppProvider { if (schema.tables) { await this.getDB().createTablesFromSchema(schema.tables); } + if (schema.install && oldVersion === 0) { + await schema.install(this.getDB()); + } if (schema.migrate && oldVersion > 0) { await schema.migrate(this.getDB(), oldVersion); } @@ -662,7 +665,7 @@ export class CoreAppProvider { const entry = await this.schemaVersionsTable.getOneByPrimaryKey({ name: schema.name }); return entry.version; - } catch (error) { + } catch { // No installed version yet. return 0; } @@ -727,11 +730,21 @@ export type CoreAppSchema = { /** * Migrates the schema to the latest version. * - * Called when installing and upgrading the schema, after creating the defined tables. + * Called when upgrading the schema, after creating the defined tables. * * @param db The affected DB. * @param oldVersion Old version of the schema or 0 if not installed. * @return Promise resolved when done. */ migrate?(db: SQLiteDB, oldVersion: number): Promise; + + /** + * Make changes to install the schema. + * + * Called when installing the schema, after creating the defined tables. + * + * @param db Site database. + * @return Promise resolved when done. + */ + install?(db: SQLiteDB): Promise | void; }; diff --git a/src/core/services/local-notifications.ts b/src/core/services/local-notifications.ts index 17bb9a503..b1e7d84e0 100644 --- a/src/core/services/local-notifications.ts +++ b/src/core/services/local-notifications.ts @@ -123,7 +123,7 @@ export class CoreLocalNotificationsProvider { async initializeDatabase(): Promise { try { await CoreApp.createTablesFromSchema(APP_SCHEMA); - } catch (e) { + } catch { // Ignore errors. } @@ -319,14 +319,26 @@ export class CoreLocalNotificationsProvider { } /** - * Returns whether local notifications plugin is installed. + * Returns whether local notifications are available. * - * @return Whether local notifications plugin is installed. + * @return Whether local notifications are available. + * @deprecated since 4.1. It will always return true. */ isAvailable(): boolean { + return true; + } + + /** + * Returns whether local notifications plugin is available. + * + * @return Whether local notifications plugin is available. + */ + isPluginAvailable(): boolean { const win = window; // eslint-disable-line @typescript-eslint/no-explicit-any - return !!win.cordova?.plugins?.notification?.local; + const enabled = !!win.cordova?.plugins?.notification?.local; + + return enabled && CorePlatform.is('cordova'); } /** diff --git a/src/core/services/sites.ts b/src/core/services/sites.ts index d4fba5e80..dde4cf8f8 100644 --- a/src/core/services/sites.ts +++ b/src/core/services/sites.ts @@ -84,6 +84,7 @@ export class CoreSitesProvider { protected sessionRestored = false; protected currentSite?: CoreSite; protected sites: { [s: string]: CoreSite } = {}; + protected sitesMigrated: { [s: string]: boolean } = {}; protected siteSchemasMigration: { [siteId: string]: Promise } = {}; protected siteSchemas: { [name: string]: CoreRegisteredSiteSchema } = {}; protected pluginsSiteSchemas: { [name: string]: CoreRegisteredSiteSchema } = {}; @@ -128,7 +129,7 @@ export class CoreSitesProvider { async initializeDatabase(): Promise { try { await CoreApp.createTablesFromSchema(APP_SCHEMA); - } catch (e) { + } catch { // Ignore errors. } @@ -218,7 +219,9 @@ export class CoreSitesProvider { if (!CoreUrlUtils.isHttpURL(siteUrl)) { throw new CoreError(Translate.instant('core.login.invalidsite')); - } else if (!CoreNetwork.isOnline()) { + } + + if (!CoreNetwork.isOnline()) { throw new CoreNetworkError(); } @@ -303,14 +306,18 @@ export class CoreSitesProvider { errorDetails: Translate.instant('core.login.webservicesnotenabled'), critical: true, }); - } else if (!config.enablemobilewebservice) { + } + + if (!config.enablemobilewebservice) { throw this.createCannotConnectLoginError(config.httpswwwroot || config.wwwroot, { supportConfig: new CoreUserGuestSupportConfig(config), errorcode: 'mobileservicesnotenabled', errorDetails: Translate.instant('core.login.mobileservicesnotenabled'), critical: true, }); - } else if (config.maintenanceenabled) { + } + + if (config.maintenanceenabled) { let message = Translate.instant('core.sitemaintenance'); if (config.maintenancemessage) { message += config.maintenancemessage; @@ -497,39 +504,41 @@ export class CoreSitesProvider { ? Translate.instant('core.siteunavailablehelp', { site: this.currentSite?.siteUrl }) : Translate.instant('core.sitenotfoundhelp'), ); - } else { - if (data.token !== undefined) { - return { token: data.token, siteUrl, privateToken: data.privatetoken }; - } else { - if (data.error !== undefined) { - // We only allow one retry (to avoid loops). - if (!retry && data.errorcode == 'requirecorrectaccess') { - siteUrl = CoreUrlUtils.addOrRemoveWWW(siteUrl); + } - return this.getUserToken(siteUrl, username, password, service, true); - } else if (data.errorcode == 'missingparam') { - // It seems the server didn't receive all required params, it could be due to a redirect. - const redirect = await CoreUtils.checkRedirect(loginUrl); + if (data.token !== undefined) { + return { token: data.token, siteUrl, privateToken: data.privatetoken }; + } - if (redirect) { - throw this.createCannotConnectLoginError(siteUrl, { - supportConfig: await CoreUserGuestSupportConfig.forSite(siteUrl), - errorcode: 'sitehasredirect', - errorDetails: Translate.instant('core.login.sitehasredirect'), - }); - } - } + if (data.error === undefined) { + throw new CoreError(Translate.instant('core.login.invalidaccount')); + } - throw this.createCannotConnectLoginError(siteUrl, { - supportConfig: await CoreUserGuestSupportConfig.forSite(siteUrl), - errorcode: data.errorcode, - errorDetails: data.error, - }); - } + // We only allow one retry (to avoid loops). + if (!retry && data.errorcode == 'requirecorrectaccess') { + siteUrl = CoreUrlUtils.addOrRemoveWWW(siteUrl); - throw new CoreError(Translate.instant('core.login.invalidaccount')); + return this.getUserToken(siteUrl, username, password, service, true); + } + + if (data.errorcode == 'missingparam') { + // It seems the server didn't receive all required params, it could be due to a redirect. + const redirect = await CoreUtils.checkRedirect(loginUrl); + + if (redirect) { + throw this.createCannotConnectLoginError(siteUrl, { + supportConfig: await CoreUserGuestSupportConfig.forSite(siteUrl), + errorcode: 'sitehasredirect', + errorDetails: Translate.instant('core.login.sitehasredirect'), + }); } } + + throw this.createCannotConnectLoginError(siteUrl, { + supportConfig: await CoreUserGuestSupportConfig.forSite(siteUrl), + errorcode: data.errorcode, + errorDetails: data.error, + }); } /** @@ -549,19 +558,19 @@ export class CoreSitesProvider { login: boolean = true, oauthId?: number, ): Promise { - if (typeof login != 'boolean') { + if (typeof login !== 'boolean') { login = true; } // Create a "candidate" site to fetch the site info. - let candidateSite = CoreSitesFactory.makeSite(undefined, siteUrl, token, undefined, privateToken, undefined, undefined); + let candidateSite = CoreSitesFactory.makeSite(undefined, siteUrl, token, undefined, privateToken); let isNewSite = true; try { const info = await candidateSite.fetchSiteInfo(); const result = this.isValidMoodleVersion(info); - if (result != CoreSitesProvider.VALID_VERSION) { + if (result !== CoreSitesProvider.VALID_VERSION) { return this.treatInvalidAppVersion(result); } @@ -1080,19 +1089,28 @@ export class CoreSitesProvider { } throw new CoreError('No current site found.'); - } else if (this.currentSite && this.currentSite.getId() == siteId) { - return this.currentSite; - } else if (this.sites[siteId] !== undefined) { - return this.sites[siteId]; - } else { - // Retrieve and create the site. - try { - const data = await this.sitesTable.getOneByPrimaryKey({ id: siteId }); + } - return this.addSiteFromSiteListEntry(data); - } catch { - throw new CoreError('SiteId not found'); - } + if (this.currentSite && this.currentSite.getId() === siteId) { + return this.currentSite; + } + + if (this.sites[siteId] !== undefined) { + return this.sites[siteId]; + } + + // Retrieve and create the site. + let record: SiteDBEntry; + try { + record = await this.sitesTable.getOneByPrimaryKey({ id: siteId }); + } catch { + throw new CoreError('SiteId not found.'); + } + + try { + return await this.addSiteFromSiteListEntry(record); + } catch { + throw new CoreError('Site database installation or update failed.'); } } @@ -1123,10 +1141,6 @@ export class CoreSitesProvider { async getSiteByUrl(siteUrl: string): Promise { const data = await this.sitesTable.getOne({ siteUrl }); - if (this.sites[data.id] !== undefined) { - return this.sites[data.id]; - } - return this.addSiteFromSiteListEntry(data); } @@ -1148,16 +1162,20 @@ export class CoreSitesProvider { * @param entry Site list entry. * @return Promised resolved with the created site. */ - addSiteFromSiteListEntry(entry: SiteDBEntry): Promise { + async addSiteFromSiteListEntry(entry: SiteDBEntry): Promise { + if (this.sites[entry.id] !== undefined) { + return this.sites[entry.id]; + } + // Parse info and config. const site = this.makeSiteFromSiteListEntry(entry); - return this.migrateSiteSchemas(site).then(() => { - // Set site after migrating schemas, or a call to getSite could get the site while tables are being created. - this.sites[entry.id] = site; + await this.migrateSiteSchemas(site); - return site; - }); + // Set site after migrating schemas, or a call to getSite could get the site while tables are being created. + this.sites[entry.id] = site; + + return site; } /** @@ -1167,8 +1185,8 @@ export class CoreSitesProvider { * @return Site. */ makeSiteFromSiteListEntry(entry: SiteDBEntry): CoreSite { - const info = entry.info ? CoreTextUtils.parseJSON(entry.info) : undefined; - const config = entry.config ? CoreTextUtils.parseJSON(entry.config) : undefined; + const info = entry.info ? CoreTextUtils.parseJSON(entry.info) : undefined; + const config = entry.config ? CoreTextUtils.parseJSON(entry.config) : undefined; const site = CoreSitesFactory.makeSite( entry.id, @@ -1321,13 +1339,13 @@ export class CoreSitesProvider { async getSitesInstances(): Promise { const siteIds = await this.getSitesIds(); - return Promise.all(siteIds.map(async (siteId) => await this.getSite(siteId))); + return Promise.all(siteIds.map((siteId) => this.getSite(siteId))); } /** * Login the user in a site. * - * @param siteid ID of the site the user is accessing. + * @param siteId ID of the site the user is accessing. * @return Promise resolved when current site is stored. */ async login(siteId: string): Promise { @@ -1479,7 +1497,7 @@ export class CoreSitesProvider { /** * Updates a site's info. * - * @param siteid Site's ID. + * @param siteId Site's ID. * @return A promise resolved when the site is updated. */ async updateSiteInfo(siteId?: string): Promise { @@ -1500,7 +1518,7 @@ export class CoreSitesProvider { try { config = await this.getSiteConfig(site); - } catch (error) { + } catch { // Error getting config, keep the current one. } @@ -1561,14 +1579,14 @@ export class CoreSitesProvider { if (CoreUrlUtils.isAbsoluteURL(url)) { // It has some protocol. Return empty array. return []; - } else { - // No protocol, probably a relative URL. Return current site. - if (this.currentSite) { - return [this.currentSite.getId()]; - } else { - return []; - } } + + // No protocol, probably a relative URL. Return current site. + if (this.currentSite) { + return [this.currentSite.getId()]; + } + + return []; } try { @@ -1576,9 +1594,7 @@ export class CoreSitesProvider { const ids: string[] = []; await Promise.all(siteEntries.map(async (site) => { - if (!this.sites[site.id]) { - await this.addSiteFromSiteListEntry(site); - } + await this.addSiteFromSiteListEntry(site); if (this.sites[site.id].containsUrl(url)) { if (!username || this.sites[site.id].getInfo()?.username === username) { @@ -1694,24 +1710,30 @@ export class CoreSitesProvider { * @param site Site. * @return Promise resolved when done. */ - migrateSiteSchemas(site: CoreSite): Promise { + async migrateSiteSchemas(site: CoreSite): Promise { if (!site.id) { - return Promise.resolve(); + return; } const siteId = site.id; - if (this.siteSchemasMigration[site.id] !== undefined) { - return this.siteSchemasMigration[site.id]; + if (this.siteSchemasMigration[siteId] !== undefined) { + return this.siteSchemasMigration[siteId]; } - this.logger.debug(`Migrating all schemas of ${site.id}`); + if (this.sitesMigrated[siteId] !== undefined) { + throw new CoreError('Site already migrated.'); + } + + this.sitesMigrated[siteId] = true; + + this.logger.debug(`Migrating all schemas of ${siteId}`); // First create tables not registerd with name/version. const promise = site.getDb().createTableFromSchema(SCHEMA_VERSIONS_TABLE_SCHEMA) .then(() => this.applySiteSchemas(site, this.siteSchemas)); - this.siteSchemasMigration[site.id] = promise; + this.siteSchemasMigration[siteId] = promise; return promise.finally(() => { delete this.siteSchemasMigration[siteId]; @@ -1769,6 +1791,9 @@ export class CoreSitesProvider { if (schema.tables) { await db.createTablesFromSchema(schema.tables); } + if (schema.install && oldVersion == 0) { + await schema.install(db, site.id); + } if (schema.migrate && oldVersion > 0) { await schema.migrate(db, oldVersion, site.id); } @@ -2032,7 +2057,7 @@ export type CoreSiteSchema = { /** * Migrates the schema in a site to the latest version. * - * Called when installing and upgrading the schema, after creating the defined tables. + * Called when upgrading the schema, after creating the defined tables. * * @param db Site database. * @param oldVersion Old version of the schema or 0 if not installed. @@ -2040,6 +2065,17 @@ export type CoreSiteSchema = { * @return Promise resolved when done. */ migrate?(db: SQLiteDB, oldVersion: number, siteId: string): Promise | void; + + /** + * Make changes to install the schema in a site. + * + * Called when installing the schema, after creating the defined tables. + * + * @param db Site database. + * @param siteId Site Id to migrate. + * @return Promise resolved when done. + */ + install?(db: SQLiteDB, siteId: string): Promise | void; }; /** diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index faddf4024..581880eeb 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -1138,7 +1138,7 @@ export class CoreDomUtilsProvider { /** * Show an alert modal with a button to close it. * - * @param title Title to show. + * @param header Title to show. * @param message Message to show. * @param buttonText Text of the button. * @param autocloseTime Number of milliseconds to wait to close the modal. If not defined, modal won't be closed. @@ -1230,7 +1230,7 @@ export class CoreDomUtilsProvider { /** * Show an alert modal with a button to close it, translating the values supplied. * - * @param title Title to show. + * @param header Title to show. * @param message Message to show. * @param buttonText Text of the button. * @param autocloseTime Number of milliseconds to wait to close the modal. If not defined, modal won't be closed. @@ -1669,7 +1669,6 @@ export class CoreDomUtilsProvider { * @param needsTranslate Whether the 'text' needs to be translated. * @param duration Duration in ms of the dimissable toast. * @param cssClass Class to add to the toast. - * @param dismissOnPageChange Dismiss the Toast on page change. * @return Toast instance. */ async showToast( diff --git a/src/testing/services/behat-runtime.ts b/src/testing/services/behat-runtime.ts index cd491d22f..a9d012ad4 100644 --- a/src/testing/services/behat-runtime.ts +++ b/src/testing/services/behat-runtime.ts @@ -18,7 +18,7 @@ import { CoreCustomURLSchemes, CoreCustomURLSchemesProvider } from '@services/ur import { CoreLoginHelperProvider } from '@features/login/services/login-helper'; import { CoreConfig } from '@services/config'; import { EnvironmentConfig } from '@/types/config'; -import { makeSingleton, NgZone } from '@singletons'; +import { LocalNotifications, makeSingleton, NgZone } from '@singletons'; import { CoreNetwork, CoreNetworkService } from '@services/network'; import { CorePushNotifications, CorePushNotificationsProvider } from '@features/pushnotifications/services/pushnotifications'; import { CoreCronDelegate, CoreCronDelegateService } from '@services/cron'; @@ -491,6 +491,49 @@ export class TestingBehatRuntimeService { console.log('BEHAT: ' + nowFormatted, ...args); // eslint-disable-line no-console } + /** + * Check a notification is present. + * + * @param title Title of the notification + * @return YES or NO: depending on the result. + */ + async notificationIsPresentWithText(title: string): Promise { + const notifications = await LocalNotifications.getAllTriggered(); + + const notification = notifications.find((notification) => notification.title?.includes(title)); + + if (!notification) { + return 'NO'; + } + + if (!notification.id) { + // Cannot check but has been triggered. + return 'YES'; + } + + return (await LocalNotifications.isPresent(notification.id)) ? 'YES' : 'NO'; + } + + /** + * Close notification. + * + * @param title Title of the notification + * @return OK or ERROR + */ + async closeNotification(title: string): Promise { + const notifications = await LocalNotifications.getAllTriggered(); + + const notification = notifications.find((notification) => notification.title?.includes(title)); + + if (!notification || !notification.id) { + return `ERROR: Notification with title ${title} cannot be closed`; + } + + await LocalNotifications.clear(notification.id); + + return 'OK'; + } + } export const TestingBehatRuntime = makeSingleton(TestingBehatRuntimeService);