diff --git a/package-lock.json b/package-lock.json index 25be08cd3..9666d5811 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,11 +43,10 @@ "@moodlehq/cordova-plugin-advanced-http": "3.3.1-moodle.1", "@moodlehq/cordova-plugin-file-opener": "4.0.0-moodle.1", "@moodlehq/cordova-plugin-file-transfer": "2.0.0-moodle.2", - "@moodlehq/cordova-plugin-camera": "6.0.0-moodle.2", "@moodlehq/cordova-plugin-inappbrowser": "5.0.0-moodle.3", "@moodlehq/cordova-plugin-intent": "2.2.0-moodle.3", "@moodlehq/cordova-plugin-ionic-webview": "5.0.0-moodle.3", - "@moodlehq/cordova-plugin-local-notification": "0.9.0-moodle.11", + "@moodlehq/cordova-plugin-local-notification": "0.9.0-moodle.12", "@moodlehq/cordova-plugin-qrscanner": "3.0.1-moodle.5", "@moodlehq/cordova-plugin-statusbar": "4.0.0-moodle.3", "@moodlehq/cordova-plugin-zip": "3.1.0-moodle.1", @@ -7797,7 +7796,9 @@ } }, "node_modules/@moodlehq/cordova-plugin-local-notification": { - "version": "0.9.0-moodle.11", + "version": "0.9.0-moodle.12", + "resolved": "https://registry.npmjs.org/@moodlehq/cordova-plugin-local-notification/-/cordova-plugin-local-notification-0.9.0-moodle.12.tgz", + "integrity": "sha512-gt6BhqsltCnNmk/CRUIDxTha/c1/UGTsh2d15zoUVeGaKYqE6olmIyN/HCAn+ofy7CA6DNHOdm1v0uqC3YbPZg==", "engines": [ { "name": "cordova", @@ -7819,8 +7820,7 @@ "name": "apple-ios", "version": ">=10.0.0" } - ], - "license": "Apache 2.0" + ] }, "node_modules/@moodlehq/cordova-plugin-qrscanner": { "version": "3.0.1-moodle.5", diff --git a/package.json b/package.json index 6de18ecb8..d6931d9ba 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "@moodlehq/cordova-plugin-inappbrowser": "5.0.0-moodle.3", "@moodlehq/cordova-plugin-intent": "2.2.0-moodle.3", "@moodlehq/cordova-plugin-ionic-webview": "5.0.0-moodle.3", - "@moodlehq/cordova-plugin-local-notification": "0.9.0-moodle.11", + "@moodlehq/cordova-plugin-local-notification": "0.9.0-moodle.12", "@moodlehq/cordova-plugin-qrscanner": "3.0.1-moodle.5", "@moodlehq/cordova-plugin-statusbar": "4.0.0-moodle.3", "@moodlehq/cordova-plugin-zip": "3.1.0-moodle.1", diff --git a/scripts/langindex.json b/scripts/langindex.json index c825692d1..7c05d22da 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1752,6 +1752,8 @@ "core.errorsyncblocked": "local_moodlemobileapp", "core.errorurlschemeinvalidscheme": "local_moodlemobileapp", "core.errorurlschemeinvalidsite": "local_moodlemobileapp", + "core.exactalarmsturnedoff": "local_moodlemobileapp", + "core.exactalarmsturnedoffmessage": "local_moodlemobileapp", "core.expand": "moodle", "core.explanationdigitalminor": "moodle", "core.favourites": "moodle", @@ -2225,6 +2227,7 @@ "core.notenrolledprofile": "moodle", "core.notice": "moodle", "core.notingroup": "moodle", + "core.notnow": "local_moodlemobileapp", "core.notsent": "local_moodlemobileapp", "core.now": "moodle", "core.nummore": "local_moodlemobileapp", @@ -2499,6 +2502,10 @@ "core.today": "moodle", "core.toggledelete": "local_moodlemobileapp", "core.tryagain": "local_moodlemobileapp", + "core.turnon": "local_moodlemobileapp", + "core.turnonexactalarms": "local_moodlemobileapp", + "core.turnonnotifications": "local_moodlemobileapp", + "core.turnonnotificationsmessage": "local_moodlemobileapp", "core.twoparagraphs": "local_moodlemobileapp", "core.type": "repository", "core.uhoh": "local_moodlemobileapp", diff --git a/src/addons/calendar/pages/event/event.html b/src/addons/calendar/pages/event/event.html index 1af4abd75..1a7553b9a 100644 --- a/src/addons/calendar/pages/event/event.html +++ b/src/addons/calendar/pages/event/event.html @@ -120,6 +120,23 @@ + + + +
+ + {{ 'core.dontshowagain' | translate | coreNoPeriod }} + + {{ 'core.turnon' | translate }} +
+
+ diff --git a/src/addons/calendar/pages/event/event.ts b/src/addons/calendar/pages/event/event.ts index 27e64a067..c4d62ace6 100644 --- a/src/addons/calendar/pages/event/event.ts +++ b/src/addons/calendar/pages/event/event.ts @@ -40,6 +40,9 @@ import { AddonCalendarEventsSource } from '@addons/calendar/classes/events-sourc 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'; +import { CoreLocalNotifications } from '@services/local-notifications'; +import { CorePlatform } from '@services/platform'; +import { CoreConfig } from '@services/config'; /** * Page that displays a single calendar event. @@ -61,6 +64,7 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy { protected defaultTimeChangedObserver: CoreEventObserver; protected currentSiteId: string; protected updateCurrentTime?: number; + protected appResumeSubscription: Subscription; eventLoaded = false; event?: AddonCalendarEventToDisplay; @@ -78,6 +82,8 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy { hasOffline = false; isOnline = false; syncIcon = CoreConstants.ICON_LOADING; // Sync icon. + canScheduleExactAlarms = true; + scheduleExactWarningHidden = false; constructor( protected route: ActivatedRoute, @@ -138,6 +144,11 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy { this.updateCurrentTime = window.setInterval(() => { this.currentTime = CoreTimeUtils.timestamp(); }, 5000); + + this.checkExactAlarms(); + this.appResumeSubscription = CorePlatform.resume.subscribe(() => { + this.checkExactAlarms(); + }); } /** @@ -153,6 +164,14 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy { this.reminders = await AddonCalendarHelper.getEventReminders(this.eventId, this.event.timestart, this.currentSiteId); } + /** + * Check if the app can schedule exact alarms. + */ + protected async checkExactAlarms(): Promise { + this.scheduleExactWarningHidden = !!(await CoreConfig.get(CoreConstants.DONT_SHOW_EXACT_ALARMS_WARNING, 0)); + this.canScheduleExactAlarms = await CoreLocalNotifications.canScheduleExactAlarms(); + } + /** * @inheritdoc */ @@ -616,6 +635,21 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy { } } + /** + * Open alarm settings. + */ + openAlarmSettings(): void { + CoreLocalNotifications.openAlarmSettings(); + } + + /** + * Hide alarm warning. + */ + hideAlarmWarning(): void { + CoreConfig.set(CoreConstants.DONT_SHOW_EXACT_ALARMS_WARNING, 1); + this.scheduleExactWarningHidden = true; + } + /** * @inheritdoc */ @@ -626,6 +660,7 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy { this.onlineObserver.unsubscribe(); this.newEventObserver.off(); this.events?.destroy(); + this.appResumeSubscription.unsubscribe(); clearInterval(this.updateCurrentTime); } diff --git a/src/addons/notifications/pages/list/list.html b/src/addons/notifications/pages/list/list.html index 3ae30321c..3229c1e11 100644 --- a/src/addons/notifications/pages/list/list.html +++ b/src/addons/notifications/pages/list/list.html @@ -17,8 +17,23 @@ + + + +
+ + {{ 'core.dontshowagain' | translate | coreNoPeriod }} + + {{ 'core.turnon' | translate }} +
+
- diff --git a/src/addons/notifications/pages/list/list.scss b/src/addons/notifications/pages/list/list.scss index a5e92ddc2..518d05627 100644 --- a/src/addons/notifications/pages/list/list.scss +++ b/src/addons/notifications/pages/list/list.scss @@ -1,6 +1,6 @@ @use "theme/globals" as *; -ion-item { +ion-item.addon-notification-item { ion-label { margin-top: 8px; margin-bottom: 8px; diff --git a/src/addons/notifications/pages/list/list.ts b/src/addons/notifications/pages/list/list.ts index b496b4ea7..52ab8136b 100644 --- a/src/addons/notifications/pages/list/list.ts +++ b/src/addons/notifications/pages/list/list.ts @@ -31,6 +31,10 @@ import { CoreTimeUtils } from '@services/utils/time'; import { AddonNotificationsNotificationsSource } from '@addons/notifications/classes/notifications-source'; import { CoreListItemsManager } from '@classes/items-management/list-items-manager'; import { AddonLegacyNotificationsNotificationsSource } from '@addons/notifications/classes/legacy-notifications-source'; +import { CoreLocalNotifications } from '@services/local-notifications'; +import { CoreConfig } from '@services/config'; +import { CoreConstants } from '@/core/constants'; +import { CorePlatform } from '@services/platform'; /** * Page that displays the list of notifications. @@ -47,12 +51,15 @@ export class AddonNotificationsListPage implements AfterViewInit, OnDestroy { fetchMoreNotificationsFailed = false; canMarkAllNotificationsAsRead = false; loadingMarkAllNotificationsAsRead = false; + hasNotificationsPermission = true; + permissionWarningHidden = false; protected isCurrentView?: boolean; protected cronObserver?: CoreEventObserver; protected readObserver?: CoreEventObserver; protected pushObserver?: Subscription; protected pendingRefresh = false; + protected appResumeSubscription?: Subscription; constructor() { try { @@ -67,7 +74,14 @@ export class AddonNotificationsListPage implements AfterViewInit, OnDestroy { } catch(error) { CoreDomUtils.showErrorModal(error); CoreNavigator.back(); + + return; } + + this.checkPermission(); + this.appResumeSubscription = CorePlatform.resume.subscribe(() => { + this.checkPermission(); + }); } /** @@ -120,6 +134,14 @@ export class AddonNotificationsListPage implements AfterViewInit, OnDestroy { deepLinkManager.treatLink(); } + /** + * Check if the app has permission to display notifications. + */ + protected async checkPermission(): Promise { + this.permissionWarningHidden = !!(await CoreConfig.get(CoreConstants.DONT_SHOW_NOTIFICATIONS_PERMISSION_WARNING, 0)); + this.hasNotificationsPermission = await CoreLocalNotifications.hasNotificationsPermission(); + } + /** * Convenience function to get notifications. Gets unread notifications first. * @@ -211,6 +233,21 @@ export class AddonNotificationsListPage implements AfterViewInit, OnDestroy { refresher?.complete(); } + /** + * Open notification settings. + */ + openSettings(): void { + CoreLocalNotifications.openNotificationSettings(); + } + + /** + * Hide permission warning. + */ + hidePermissionWarning(): void { + CoreConfig.set(CoreConstants.DONT_SHOW_NOTIFICATIONS_PERMISSION_WARNING, 1); + this.permissionWarningHidden = true; + } + /** * User entered the page. */ @@ -241,6 +278,7 @@ export class AddonNotificationsListPage implements AfterViewInit, OnDestroy { this.readObserver?.off(); this.pushObserver?.unsubscribe(); this.notifications?.destroy(); + this.appResumeSubscription?.unsubscribe(); } } diff --git a/src/core/constants.ts b/src/core/constants.ts index a5d6ef786..e3a6ce330 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -155,6 +155,9 @@ export class CoreConstants { // Other constants. static readonly CALENDAR_DEFAULT_STARTING_WEEKDAY = 1; + static readonly DONT_SHOW_NOTIFICATIONS_PERMISSION_WARNING = 'CoreDontShowNotificationsPermissionWarning'; + static readonly DONT_SHOW_EXACT_ALARMS_WARNING = 'CoreDontShowScheduleExactWarning'; + static readonly EXACT_ALARMS_WARNING_DISPLAYED = 'CoreScheduleExactWarningModalDisplayed'; // Config & environment constants. static readonly CONFIG = { ...envJson.config } as unknown as EnvironmentConfig; // Data parsed from config.json files. diff --git a/src/core/lang.json b/src/core/lang.json index e6518b69d..5138ebfac 100644 --- a/src/core/lang.json +++ b/src/core/lang.json @@ -124,6 +124,8 @@ "errorsyncblocked": "This {{$a}} cannot be synchronised right now because of an ongoing process. Please try again later. If the problem persists, try restarting the app.", "errorurlschemeinvalidscheme": "This URL is meant to be used in another app: {{$a}}.", "errorurlschemeinvalidsite": "This site URL cannot be opened in this app.", + "exactalarmsturnedoff": "Real-time notifications are turned off", + "exactalarmsturnedoffmessage": "To make sure you don't miss any important alerts, turn on 'Alarms and reminders' in your device's settings.", "expand": "Expand", "explanationdigitalminor": "This information is required to determine if your age is over the digital age of consent. This is the age when an individual can consent to terms and conditions and their data being legally stored and processed.", "favourites": "Starred", @@ -225,6 +227,7 @@ "notenrolledprofile": "This profile is not available because this user is not enrolled in this course.", "notice": "Notice", "notingroup": "Sorry, but you need to be part of a group to see this page.", + "notnow": "Not now", "notsent": "Not sent", "now": "now", "nummore": "{{$a}} more", @@ -333,6 +336,10 @@ "today": "Today", "toggledelete": "Toggle delete buttons", "tryagain": "Try again", + "turnon": "Turn on", + "turnonexactalarms": "Turn on real-time alerts", + "turnonnotifications": "Turn on notifications", + "turnonnotificationsmessage": "Would you like to receive notifications about activities and assignments?", "twoparagraphs": "{{p1}}

{{p2}}", "type": "Type", "uhoh": "Uh oh!", diff --git a/src/core/pipes/no-period.ts b/src/core/pipes/no-period.ts new file mode 100644 index 000000000..e6c097371 --- /dev/null +++ b/src/core/pipes/no-period.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 { Pipe, PipeTransform } from '@angular/core'; + +/** + * Pipe to remove period at the end of a text if present. + */ +@Pipe({ + name: 'coreNoPeriod', +}) +export class CoreNoPeriodPipe implements PipeTransform { + + /** + * Takes a text and removes ending period. + * + * @param text The text to treat. + * @returns Treated text. + */ + transform(text: string): string { + if (!text) { + return ''; + } + + return text.trim().replace(/\.$/, ''); + } + +} diff --git a/src/core/pipes/pipes.module.ts b/src/core/pipes/pipes.module.ts index b8b197300..f523f1b0a 100644 --- a/src/core/pipes/pipes.module.ts +++ b/src/core/pipes/pipes.module.ts @@ -22,6 +22,7 @@ import { CoreFormatDatePipe } from './format-date'; import { CoreNoTagsPipe } from './no-tags'; import { CoreSecondsToHMSPipe } from './seconds-to-hms'; import { CoreTimeAgoPipe } from './time-ago'; +import { CoreNoPeriodPipe } from './no-period'; @NgModule({ declarations: [ @@ -30,6 +31,7 @@ import { CoreTimeAgoPipe } from './time-ago'; CoreDateDayOrTimePipe, CoreDurationPipe, CoreFormatDatePipe, + CoreNoPeriodPipe, CoreNoTagsPipe, CoreSecondsToHMSPipe, CoreTimeAgoPipe, @@ -41,6 +43,7 @@ import { CoreTimeAgoPipe } from './time-ago'; CoreDurationPipe, CoreFormatDatePipe, CoreNoTagsPipe, + CoreNoPeriodPipe, CoreSecondsToHMSPipe, CoreTimeAgoPipe, ], diff --git a/src/core/services/local-notifications.ts b/src/core/services/local-notifications.ts index f6ea2cdd5..95d4b852f 100644 --- a/src/core/services/local-notifications.ts +++ b/src/core/services/local-notifications.ts @@ -41,6 +41,7 @@ import { Push } from '@features/native/plugins'; import { AsyncInstance, asyncInstance } from '@/core/utils/async-instance'; import { CoreDatabaseTable } from '@classes/database/database-table'; import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy'; +import { CoreDomUtils } from './utils/dom'; /** * Service to handle local notifications. @@ -119,6 +120,41 @@ export class CoreLocalNotificationsProvider { this.cancelSiteNotifications(site.id); } }); + + CoreEvents.on(CoreEvents.LOGIN, async () => { + const [hasNotificationsPermission, canScheduleExact] = await Promise.all([ + this.hasNotificationsPermission(), + this.canScheduleExactAlarms(), + ]); + + if (!hasNotificationsPermission || canScheduleExact) { + return; + } + + const dontShowWarning = await CoreConfig.get(CoreConstants.EXACT_ALARMS_WARNING_DISPLAYED, 0); + if (dontShowWarning) { + return; + } + + CoreDomUtils.showAlertWithOptions({ + header: Translate.instant('core.turnonexactalarms'), + message: Translate.instant('core.exactalarmsturnedoffmessage'), + buttons: [ + { + text: Translate.instant('core.notnow'), + role: 'cancel', + }, + { + text: Translate.instant('core.turnon'), + handler: (): void => { + this.openAlarmSettings(); + }, + }, + ], + }); + + CoreConfig.set(CoreConstants.EXACT_ALARMS_WARNING_DISPLAYED, 1); + }); } /** @@ -163,6 +199,40 @@ export class CoreLocalNotificationsProvider { this.triggeredTable.setInstance(triggeredTable); } + /** + * Check whether the app has the permission to display notifications. + * + * @returns Whether has notifications permission. + */ + async hasNotificationsPermission(): Promise { + if (!CorePlatform.isMobile()) { + return true; + } + + return LocalNotifications.hasPermission(); + } + + /** + * Check whether the app can schedule exact alarms. + * + * @returns Whether can schedule exact alarms. + */ + async canScheduleExactAlarms(): Promise { + if (!CorePlatform.isAndroid()) { + return true; + } + + const plugin = this.getCordovaPlugin(); + if (!plugin || !plugin.canScheduleExactAlarms) { + // Cannot check, assume it's enabled. + return true; + } + + return new Promise(resolve => { + plugin.canScheduleExactAlarms(canSchedule => resolve(canSchedule)); + }); + } + /** * Cancel a local notification. * @@ -370,11 +440,18 @@ export class CoreLocalNotificationsProvider { * @returns Whether local notifications plugin is available. */ isPluginAvailable(): boolean { - const win = window; // eslint-disable-line @typescript-eslint/no-explicit-any + return !!this.getCordovaPlugin() && CorePlatform.is('cordova'); + } - const enabled = !!win.cordova?.plugins?.notification?.local; - - return enabled && CorePlatform.is('cordova'); + /** + * Get the Cordova plugin object. + * + * @returns Cordova plugin, undefined if not found. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + protected getCordovaPlugin(): any | undefined { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return ( window).cordova?.plugins?.notification?.local; } /** @@ -740,6 +817,28 @@ export class CoreLocalNotificationsProvider { await this.componentsTable.update({ id: newId }, { id: oldId }); } + /** + * Open notification settings. + */ + openNotificationSettings(): void { + if (!CorePlatform.isMobile()) { + return; + } + + this.getCordovaPlugin()?.openNotificationSettings(); + } + + /** + * Open alarm settings (Android only). + */ + openAlarmSettings(): void { + if (!CorePlatform.isAndroid()) { + return; + } + + this.getCordovaPlugin()?.openAlarmSettings(); + } + } export const CoreLocalNotifications = makeSingleton(CoreLocalNotificationsProvider); diff --git a/src/theme/theme.base.scss b/src/theme/theme.base.scss index c6eed8e06..0e57efaeb 100644 --- a/src/theme/theme.base.scss +++ b/src/theme/theme.base.scss @@ -931,6 +931,25 @@ ion-card { ion-card-title { font-size: 20px; } + + &.core-card-with-buttons .item ion-label { + margin-bottom: 0; + } + + .core-card-buttons { + display: flex; + flex-direction: row; + justify-content: flex-end; + @include margin(0, 8px, 8px, 8px); + + ion-button { + text-transform: none; + + &[fill="outline"] { + --background: transparent; + } + } + } } .core-course-module-handler:not(.addon-mod-label-handler) .item-heading .filter_mathjaxloader_equation div {