diff --git a/src/addons/addons.module.ts b/src/addons/addons.module.ts index 82761f055..a49cffd18 100644 --- a/src/addons/addons.module.ts +++ b/src/addons/addons.module.ts @@ -40,10 +40,12 @@ import { AddonPrivateFilesModule } from './privatefiles/privatefiles.module'; import { AddonFilterModule } from './filter/filter.module'; import { AddonUserProfileFieldModule } from './userprofilefield/userprofilefield.module'; import { AddonBadgesModule } from './badges/badges.module'; +import { AddonCalendarModule } from './calendar/calendar.module'; @NgModule({ imports: [ AddonBadgesModule, + AddonCalendarModule, AddonPrivateFilesModule, AddonFilterModule, AddonBlockActivityResultsModule, diff --git a/src/addons/badges/badges.module.ts b/src/addons/badges/badges.module.ts index 733fafa15..285282783 100644 --- a/src/addons/badges/badges.module.ts +++ b/src/addons/badges/badges.module.ts @@ -24,7 +24,7 @@ import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-ro import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate'; import { AddonBadgesPushClickHandler } from './services/handlers/push-click'; -const mainMenuHomeSiblingRoutes: Routes = [ +const mainMenuRoutes: Routes = [ { path: 'badges', loadChildren: () => import('./badges-lazy.module').then(m => m.AddonBadgesLazyModule), @@ -33,7 +33,7 @@ const mainMenuHomeSiblingRoutes: Routes = [ @NgModule({ imports: [ - CoreMainMenuTabRoutingModule.forChild(mainMenuHomeSiblingRoutes), + CoreMainMenuTabRoutingModule.forChild(mainMenuRoutes), ], providers: [ { diff --git a/src/addons/badges/pages/issued-badge/issued-badge.page.ts b/src/addons/badges/pages/issued-badge/issued-badge.page.ts index 70f90e8b2..928fff65a 100644 --- a/src/addons/badges/pages/issued-badge/issued-badge.page.ts +++ b/src/addons/badges/pages/issued-badge/issued-badge.page.ts @@ -50,7 +50,7 @@ export class AddonBadgesIssuedBadgePage implements OnInit { * View loaded. */ ngOnInit(): void { - this.courseId = this.route.snapshot.queryParams['courseId'] || this.courseId; // Use 0 for site badges. + this.courseId = parseInt(this.route.snapshot.queryParams['courseId'], 10) || this.courseId; // Use 0 for site badges. this.userId = this.route.snapshot.queryParams['userId'] || CoreSites.instance.getCurrentSite()?.getUserId(); this.badgeHash = this.route.snapshot.queryParams['badgeHash']; diff --git a/src/addons/badges/pages/user-badges/user-badges.page.ts b/src/addons/badges/pages/user-badges/user-badges.page.ts index 23b54a499..deea55390 100644 --- a/src/addons/badges/pages/user-badges/user-badges.page.ts +++ b/src/addons/badges/pages/user-badges/user-badges.page.ts @@ -51,7 +51,7 @@ export class AddonBadgesUserBadgesPage implements OnInit { */ ngOnInit(): void { - this.courseId = this.route.snapshot.queryParams['courseId'] || this.courseId; // Use 0 for site badges. + this.courseId = parseInt(this.route.snapshot.queryParams['courseId'], 10) || this.courseId; // Use 0 for site badges. this.userId = this.route.snapshot.queryParams['userId'] || CoreSites.instance.getCurrentSite()?.getUserId(); diff --git a/src/addons/block/calendarmonth/services/block-handler.ts b/src/addons/block/calendarmonth/services/block-handler.ts index ed08c724d..d73fc8895 100644 --- a/src/addons/block/calendarmonth/services/block-handler.ts +++ b/src/addons/block/calendarmonth/services/block-handler.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; import { CoreBlockHandlerData } from '@features/block/services/block-delegate'; import { CoreBlockOnlyTitleComponent } from '@features/block/components/only-title-block/only-title-block'; import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler'; -// import { AddonCalendar } from '@addon/calendar/services/calendar'; +import { AddonCalendar } from '@/addons/calendar/services/calendar'; import { CoreCourseBlock } from '@features/course/services/course'; import { Params } from '@angular/router'; import { makeSingleton } from '@singletons'; @@ -39,19 +39,13 @@ export class AddonBlockCalendarMonthHandlerService extends CoreBlockBaseHandler * @return Data or promise resolved with the data. */ getDisplayData(block: CoreCourseBlock, contextLevel: string, instanceId: number): CoreBlockHandlerData { - // @todo - const link = 'AddonCalendarListPage'; const linkParams: Params = contextLevel == 'course' ? { courseId: instanceId } : {}; - /* if (AddonCalendar.instance.canViewMonthInSite()) { - link = 'AddonCalendarIndexPage'; - }*/ - return { title: 'addon.block_calendarmonth.pluginname', class: 'addon-block-calendar-month', component: CoreBlockOnlyTitleComponent, - link: link, + link: AddonCalendar.instance.getMainCalendarPagePath(), linkParams: linkParams, }; } diff --git a/src/addons/block/calendarupcoming/services/block-handler.ts b/src/addons/block/calendarupcoming/services/block-handler.ts index 6a5d0bda6..2fa351eb0 100644 --- a/src/addons/block/calendarupcoming/services/block-handler.ts +++ b/src/addons/block/calendarupcoming/services/block-handler.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; import { CoreBlockHandlerData } from '@features/block/services/block-delegate'; import { CoreBlockOnlyTitleComponent } from '@features/block/components/only-title-block/only-title-block'; import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler'; -// import { AddonCalendar } from '@addon/calendar/services/calendar'; +import { AddonCalendar } from '@/addons/calendar/services/calendar'; import { CoreCourseBlock } from '@features/course/services/course'; import { Params } from '@angular/router'; import { makeSingleton } from '@singletons'; @@ -39,20 +39,13 @@ export class AddonBlockCalendarUpcomingHandlerService extends CoreBlockBaseHandl * @return Data or promise resolved with the data. */ getDisplayData(block: CoreCourseBlock, contextLevel: string, instanceId: number): CoreBlockHandlerData { - // @todo - const link = 'AddonCalendarListPage'; const linkParams: Params = contextLevel == 'course' ? { courseId: instanceId } : {}; - /* if (AddonCalendar.instance.canViewMonthInSite()) { - link = 'AddonCalendarIndexPage'; - linkParams.upcoming = true; - }*/ - return { title: 'addon.block_calendarupcoming.pluginname', class: 'addon-block-calendar-upcoming', component: CoreBlockOnlyTitleComponent, - link: link, + link: AddonCalendar.instance.getMainCalendarPagePath(), linkParams: linkParams, }; } diff --git a/src/addons/block/sitemainmenu/components/sitemainmenu/sitemainmenu.ts b/src/addons/block/sitemainmenu/components/sitemainmenu/sitemainmenu.ts index 4df2d9b89..5893d36cd 100644 --- a/src/addons/block/sitemainmenu/components/sitemainmenu/sitemainmenu.ts +++ b/src/addons/block/sitemainmenu/components/sitemainmenu/sitemainmenu.ts @@ -45,7 +45,7 @@ export class AddonBlockSiteMainMenuComponent extends CoreBlockBaseComponent impl * Component being initialized. */ async ngOnInit(): Promise { - this.siteHomeId = CoreSites.instance.getCurrentSite()?.getSiteHomeId() || 1; + this.siteHomeId = CoreSites.instance.getCurrentSiteHomeId(); super.ngOnInit(); } diff --git a/src/addons/calendar/calendar-common.scss b/src/addons/calendar/calendar-common.scss new file mode 100644 index 000000000..771f1987e --- /dev/null +++ b/src/addons/calendar/calendar-common.scss @@ -0,0 +1,28 @@ +:host { + + --addon-calendar-blank-day-background-color: var(--gray-lighter); + + .item.addon-calendar-event { + > ion-icon { + color: white; + border-radius: 50%; + padding: 6px; + } + + &.addon-calendar-eventtype-category > ion-icon { + background-color: var(--addon-calendar-event-category-color); + } + &.addon-calendar-eventtype-course > ion-icon { + background-color: var(--addon-calendar-event-course-color); + } + &.addon-calendar-eventtype-group > ion-icon { + background-color: var(--addon-calendar-event-group-color); + } + &.addon-calendar-eventtype-user > ion-icon { + background-color: var(--addon-calendar-event-user-color); + } + &.addon-calendar-eventtype-site > ion-icon { + background-color: var(--addon-calendar-event-site-color); + } + } +} diff --git a/src/addons/calendar/calendar-lazy.module.ts b/src/addons/calendar/calendar-lazy.module.ts new file mode 100644 index 000000000..396831edf --- /dev/null +++ b/src/addons/calendar/calendar-lazy.module.ts @@ -0,0 +1,68 @@ +// (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 { Injector, NgModule } from '@angular/core'; +import { RouterModule, ROUTES, Routes } from '@angular/router'; + +import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module'; + +function buildRoutes(injector: Injector): Routes { + return [ + { + path: 'index', + loadChildren: () => import('@/addons/calendar/pages/index/index.module').then(m => m.AddonCalendarIndexPageModule), + }, + { + path: 'list', + loadChildren: () => import('@/addons/calendar/pages/list/list.module').then(m => m.AddonCalendarListPageModule), + }, + { + path: 'settings', + loadChildren: () => + import('@/addons/calendar/pages/settings/settings.module').then(m => m.AddonCalendarSettingsPageModule), + }, + { + path: 'day', + loadChildren: () => + import('@/addons/calendar/pages/day/day.module').then(m => m.AddonCalendarDayPageModule), + }, + { + path: 'event', + loadChildren: () => + import('@/addons/calendar/pages/event/event.module').then(m => m.AddonCalendarEventPageModule), + }, + { + path: 'edit', + loadChildren: () => + import('@/addons/calendar/pages/edit-event/edit-event.module').then(m => m.AddonCalendarEditEventPageModule), + }, + ...buildTabMainRoutes(injector, { + redirectTo: 'index', + pathMatch: 'full', + }), + ]; +} + +@NgModule({ + exports: [RouterModule], + providers: [ + { + provide: ROUTES, + multi: true, + deps: [Injector], + useFactory: buildRoutes, + }, + ], +}) +export class AddonCalendarLazyModule { } diff --git a/src/addons/calendar/calendar.module.ts b/src/addons/calendar/calendar.module.ts new file mode 100644 index 000000000..ab5cff1f0 --- /dev/null +++ b/src/addons/calendar/calendar.module.ts @@ -0,0 +1,67 @@ +// (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 } from '@angular/core'; +import { Routes } from '@angular/router'; +import { CoreMainMenuRoutingModule } from '@features/mainmenu/mainmenu-routing.module'; + +import { CoreMainMenuDelegate } from '@features/mainmenu/services/mainmenu-delegate'; +import { CoreCronDelegate } from '@services/cron'; +import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate'; +import { AddonCalendarViewLinkHandler } from './services/handlers/view-link'; +import { AddonCalendarMainMenuHandler, AddonCalendarMainMenuHandlerService } from './services/handlers/mainmenu'; +import { AddonCalendarSyncCronHandler } from './services/handlers/sync-cron'; + +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { CALENDAR_SITE_SCHEMA } from './services/database/calendar'; +import { CALENDAR_OFFLINE_SITE_SCHEMA } from './services/database/calendar-offline'; +import { AddonCalendarComponentsModule } from './components/components.module'; +import { AddonCalendar } from './services/calendar'; + +const mainMenuChildrenRoutes: Routes = [ + { + path: AddonCalendarMainMenuHandlerService.PAGE_NAME, + loadChildren: () => import('./calendar-lazy.module').then(m => m.AddonCalendarLazyModule), + }, +]; + +@NgModule({ + imports: [ + CoreMainMenuRoutingModule.forChild({ children: mainMenuChildrenRoutes }), + AddonCalendarComponentsModule, + ], + exports: [CoreMainMenuRoutingModule], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [CALENDAR_SITE_SCHEMA, CALENDAR_OFFLINE_SITE_SCHEMA], + multi: true, + }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => async () => { + CoreContentLinksDelegate.instance.registerHandler(AddonCalendarViewLinkHandler.instance); + CoreMainMenuDelegate.instance.registerHandler(AddonCalendarMainMenuHandler.instance); + CoreCronDelegate.instance.register(AddonCalendarSyncCronHandler.instance); + + await AddonCalendar.instance.initialize(); + + AddonCalendar.instance.scheduleAllSitesEventsNotifications(); + }, + }, + ], +}) +export class AddonCalendarModule {} diff --git a/src/addons/calendar/components/calendar/addon-calendar-calendar.html b/src/addons/calendar/components/calendar/addon-calendar-calendar.html new file mode 100644 index 000000000..6b697a330 --- /dev/null +++ b/src/addons/calendar/components/calendar/addon-calendar-calendar.html @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + +

{{ periodName }}

+
+ + + + + +
+
+ + + + + + + {{ day.shortname | translate }} + {{ day.fullname | translate }} + + + + + + + + +

{{ day.mday }}

+ + +

+ + +
+ +

+ + + + {{ event.timestart * 1000 | coreFormatDate: timeFormat }} + + {{event.name}} +

+
+

+ {{ 'core.nummore' | translate:{$a: day.filteredEvents.length - 3} }} +

+
+
+ + +
+
+ +
diff --git a/src/addons/calendar/components/calendar/calendar.scss b/src/addons/calendar/components/calendar/calendar.scss new file mode 100644 index 000000000..1c3433c6c --- /dev/null +++ b/src/addons/calendar/components/calendar/calendar.scss @@ -0,0 +1,187 @@ +:host { + --addon-calendar-blank-day-background-color: var(--gray-lighter); + + .addon-calendar-navigation { + padding-top: 5px; + padding-left: 10px; + padding-right: 10px; + } + + .addon-calendar-months { + background-color: var(--contrast-background); + padding: 0; + } + + .addon-calendar-day { + border-bottom: 1px solid var(--addon-calendar-border-color); + border-right: 1px solid var(--addon-calendar-border-color); + overflow: hidden; + min-height: 60px; + cursor: pointer; + + &:first-child { + padding-left: 10px; + } + &:last-child { + border-right: 0; + padding-left: 8px; + } + + &.addon-calendar-event-past-day > .addon-calendar-dot-types, + &.addon-calendar-event-past-day > .addon-calendar-day-events { + opacity: 0.5; + } + + .addon-calendar-day-number { + margin: 0; + + span { + line-height: 24px; + font-weight: 500; + display: inline-block; + margin: 3px; + width: max-content; + width: 24px; + height: 24px; + text-align: center; + } + } + + @media (min-width: 768px) { + .addon-calendar-day-number { + text-align: start; + } + } + + &.today .addon-calendar-day-number span { + background-color: var(--addon-calendar-today-bgcolor); + color: var(--addon-calendar-today-color); + + border-radius: 50%; + } + &.dayblank { + cursor: auto; + background-color: var(--addon-calendar-blank-day-background-color); + } + + .addon-calendar-event { + margin-top: 0.6em; + margin-bottom: 0.6em; + overflow: hidden; + white-space: nowrap; + + &.addon-calendar-event-past { + opacity: 0.5; + } + + .addon-calendar-event-name { + font-weight: 500; + } + } + + .addon-calendar-day-more { + margin-top: 0.6em; + margin-bottom: 0.6em; + margin-right: 4px; + } + + .addon-calendar-dot-types { + margin: 0; + } + } + + .addon-calendar-period { + flex-grow: 3; + h3 { + margin-top: 10px; + font-size: 1.2rem; + } + } + + .addon-calendar-weekday { + border-bottom: 1px solid var(--addon-calendar-border-color); + } + + .addon-calendar-day-events { + text-align: left; + + ion-icon { + margin-right: 2px; + font-size: 1em; + } + } + + .addon-calendar-event, .addon-calendar-day-number, .addon-calendar-day-more { + cursor: pointer; + } + + .calendar_event_type { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + border: 1px solid white; + margin-right: 1px; + margin-left: 1px; + + &.calendar_event_category { + background-color: var(--addon-calendar-event-category-color); + } + &.calendar_event_course { + background-color: var(--addon-calendar-event-course-color); + } + &.calendar_event_group { + background-color: var(--addon-calendar-event-group-color); + } + &.calendar_event_user { + background-color: var(--addon-calendar-event-user-color); + } + &.calendar_event_site { + background-color: var(--addon-calendar-event-site-color); + } + } + + .core-module-icon { + margin-right: 1px; + margin-left: 1px; + width: 16px; + height: 16px; + display: inline-block; + vertical-align: bottom; + } +} + +:host-context([dir=rtl]) { + .addon-calendar-day-events { + text-align: right; + + ion-icon { + margin-right: unset; + margin-left: 2px; + } + } + + .addon-calendar-day { + border-left: 1px solid var(--addon-calendar-border-color); + border-right: unset; + + &:first-child { + padding-right: 10px; + padding-left: unset; + } + &:last-child { + border-left: 0; + border-right: unset; + padding-right: 8px; + padding-left: unset; + } + .addon-calendar-day-more { + margin-left: 4px; + margin-right: unset; + } + } +} + +:host-context(body.dark) { + --addon-calendar-blank-day-background-color: var(--black); +} diff --git a/src/addons/calendar/components/calendar/calendar.ts b/src/addons/calendar/components/calendar/calendar.ts new file mode 100644 index 000000000..624261ec3 --- /dev/null +++ b/src/addons/calendar/components/calendar/calendar.ts @@ -0,0 +1,529 @@ +// (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, + OnDestroy, + OnInit, + Input, + DoCheck, + Output, + EventEmitter, + KeyValueDiffers, + KeyValueDiffer, +} from '@angular/core'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { + AddonCalendar, + AddonCalendarProvider, + AddonCalendarWeek, + AddonCalendarWeekDaysTranslationKeys, + AddonCalendarEventToDisplay, + AddonCalendarUpdatedEventEvent, + AddonCalendarDayName, +} from '../../services/calendar'; +import { AddonCalendarFilter, AddonCalendarHelper } from '../../services/calendar-helper'; +import { AddonCalendarOffline } from '../../services/calendar-offline'; +import { CoreCategoryData, CoreCourses } from '@features/courses/services/courses'; +import { CoreApp } from '@services/app'; +import { CoreLocalNotifications } from '@services/local-notifications'; + +/** + * Component that displays a calendar. + */ +@Component({ + selector: 'addon-calendar-calendar', + templateUrl: 'addon-calendar-calendar.html', + styleUrls: ['calendar.scss'], +}) +export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestroy { + + @Input() initialYear?: number; // Initial year to load. + @Input() initialMonth?: number; // Initial month to load. + @Input() filter?: AddonCalendarFilter; // Filter to apply. + @Input() canNavigate?: string | boolean; // Whether to include arrows to change the month. Defaults to true. + @Input() displayNavButtons?: string | boolean; // Whether to display nav buttons created by this component. Defaults to true. + @Output() onEventClicked = new EventEmitter(); + @Output() onDayClicked = new EventEmitter<{day: number; month: number; year: number}>(); + + periodName?: string; + weekDays: AddonCalendarWeekDaysTranslationKeys[] = []; + weeks: AddonCalendarWeek[] = []; + loaded = false; + timeFormat?: string; + isCurrentMonth = false; + isPastMonth = false; + + protected year?: number; + protected month?: number; + protected categoriesRetrieved = false; + protected categories: { [id: number]: CoreCategoryData } = {}; + protected currentSiteId: string; + protected offlineEvents: { [monthId: string]: { [day: number]: AddonCalendarEventToDisplay[] } } = + {}; // Offline events classified in month & day. + + protected offlineEditedEventsIds: number[] = []; // IDs of events edited in offline. + protected deletedEvents: number[] = []; // Events deleted in offline. + protected currentTime?: number; + protected differ: KeyValueDiffer; // To detect changes in the data input. + // Observers. + protected undeleteEventObserver: CoreEventObserver; + protected obsDefaultTimeChange?: CoreEventObserver; + + constructor( + differs: KeyValueDiffers, + ) { + + this.currentSiteId = CoreSites.instance.getCurrentSiteId(); + + if (CoreLocalNotifications.instance.isAvailable()) { + // Re-schedule events if default time changes. + this.obsDefaultTimeChange = CoreEvents.on(AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_CHANGED, () => { + this.weeks.forEach((week) => { + week.days.forEach((day) => { + AddonCalendar.instance.scheduleEventsNotifications(day.eventsFormated!); + }); + }); + }, this.currentSiteId); + } + + // Listen for events "undeleted" (offline). + this.undeleteEventObserver = CoreEvents.on( + AddonCalendarProvider.UNDELETED_EVENT_EVENT, + (data: AddonCalendarUpdatedEventEvent) => { + if (!data || !data.eventId) { + return; + } + + // Mark it as undeleted, no need to refresh. + this.undeleteEvent(data.eventId); + + // Remove it from the list of deleted events if it's there. + const index = this.deletedEvents.indexOf(data.eventId); + if (index != -1) { + this.deletedEvents.splice(index, 1); + } + }, + this.currentSiteId, + ); + + this.differ = differs.find([]).create(); + } + + /** + * Component loaded. + */ + ngOnInit(): void { + const now = new Date(); + + this.year = this.initialYear ? this.initialYear : now.getFullYear(); + this.month = this.initialMonth ? this.initialMonth : now.getMonth() + 1; + + this.calculateIsCurrentMonth(); + + this.fetchData(); + } + + + /** + * Detect and act upon changes that Angular can’t or won’t detect on its own (objects and arrays). + */ + ngDoCheck(): void { + this.canNavigate = typeof this.canNavigate == 'undefined' ? true : CoreUtils.instance.isTrueOrOne(this.canNavigate); + this.displayNavButtons = typeof this.displayNavButtons == 'undefined' ? true : + CoreUtils.instance.isTrueOrOne(this.displayNavButtons); + + if (this.weeks) { + // Check if there's any change in the filter object. + const changes = this.differ.diff(this.filter!); + if (changes) { + this.filterEvents(); + } + } + } + + /** + * Fetch contacts. + * + * @return Promise resolved when done. + */ + async fetchData(): Promise { + const promises: Promise[] = []; + + promises.push(this.loadCategories()); + + // Get offline events. + promises.push(AddonCalendarOffline.instance.getAllEditedEvents().then((events) => { + // Classify them by month. + this.offlineEvents = AddonCalendarHelper.instance.classifyIntoMonths(events); + + // Get the IDs of events edited in offline. + const filtered = events.filter((event) => event.id! > 0); + this.offlineEditedEventsIds = filtered.map((event) => event.id!); + + return; + })); + + // Get events deleted in offline. + promises.push(AddonCalendarOffline.instance.getAllDeletedEventsIds().then((ids) => { + this.deletedEvents = ids; + + return; + })); + + // Get time format to use. + promises.push(AddonCalendar.instance.getCalendarTimeFormat().then((value) => { + this.timeFormat = value; + + return; + })); + + try { + await Promise.all(promises); + + await this.fetchEvents(); + + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); + } + + this.loaded = true; + } + + /** + * Fetch the events for current month. + * + * @return Promise resolved when done. + */ + async fetchEvents(): Promise { + // Don't pass courseId and categoryId, we'll filter them locally. + let result: { daynames: Partial[]; weeks: Partial[] }; + try { + result = await AddonCalendar.instance.getMonthlyEvents(this.year!, this.month!); + } catch (error) { + if (!CoreApp.instance.isOnline()) { + // Allow navigating to non-cached months in offline (behave as if using emergency cache). + result = await AddonCalendarHelper.instance.getOfflineMonthWeeks(this.year!, this.month!); + } else { + throw error; + } + } + + // Calculate the period name. We don't use the one in result because it's in server's language. + this.periodName = CoreTimeUtils.instance.userDate( + new Date(this.year!, this.month! - 1).getTime(), + 'core.strftimemonthyear', + ); + this.weekDays = AddonCalendar.instance.getWeekDays(result.daynames[0].dayno); + this.weeks = result.weeks as AddonCalendarWeek[]; + this.calculateIsCurrentMonth(); + + this.weeks.forEach((week) => { + week.days.forEach((day) => { + day.eventsFormated = day.eventsFormated || []; + day.filteredEvents = day.filteredEvents || []; + day.events.forEach((event) => { + /// Format online events. + day.eventsFormated!.push(AddonCalendarHelper.instance.formatEventData(event)); + }); + }); + }); + + if (this.isCurrentMonth) { + const currentDay = new Date().getDate(); + let isPast = true; + + this.weeks.forEach((week) => { + week.days.forEach((day) => { + day.istoday = day.mday == currentDay; + day.ispast = isPast && !day.istoday; + isPast = day.ispast; + + if (day.istoday) { + day.eventsFormated!.forEach((event) => { + event.ispast = this.isEventPast(event); + }); + } + }); + }); + } + // Merge the online events with offline data. + this.mergeEvents(); + // Filter events by course. + this.filterEvents(); + } + + /** + * Load categories to be able to filter events. + * + * @return Promise resolved when done. + */ + protected async loadCategories(): Promise { + if (this.categoriesRetrieved) { + // Already retrieved, stop. + return; + } + + try { + const cats = await CoreCourses.instance.getCategories(0, true); + this.categoriesRetrieved = true; + this.categories = {}; + + // Index categories by ID. + cats.forEach((category) => { + this.categories[category.id] = category; + }); + } catch { + // Ignore errors. + } + } + + /** + * Filter events based on the filter popover. + */ + filterEvents(): void { + this.weeks.forEach((week) => { + week.days.forEach((day) => { + day.filteredEvents = AddonCalendarHelper.instance.getFilteredEvents( + day.eventsFormated!, + this.filter!, + this.categories, + ); + + // Re-calculate some properties. + AddonCalendarHelper.instance.calculateDayData(day, day.filteredEvents); + }); + }); + } + + /** + * Refresh events. + * + * @param afterChange Whether the refresh is done after an event has changed or has been synced. + * @return Promise resolved when done. + */ + async refreshData(afterChange?: boolean): Promise { + const promises: Promise[] = []; + + // Don't invalidate monthly events after a change, it has already been handled. + if (!afterChange) { + promises.push(AddonCalendar.instance.invalidateMonthlyEvents(this.year!, this.month!)); + } + promises.push(CoreCourses.instance.invalidateCategories(0, true)); + promises.push(AddonCalendar.instance.invalidateTimeFormat()); + + this.categoriesRetrieved = false; // Get categories again. + + await Promise.all(promises); + + this.fetchData(); + } + + /** + * Load next month. + */ + async loadNext(): Promise { + this.increaseMonth(); + + this.loaded = false; + + try { + await this.fetchEvents(); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); + this.decreaseMonth(); + } + this.loaded = true; + } + + /** + * Load previous month. + */ + async loadPrevious(): Promise { + this.decreaseMonth(); + + this.loaded = false; + + try { + await this.fetchEvents(); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); + this.increaseMonth(); + } + this.loaded = true; + } + + /** + * An event was clicked. + * + * @param calendarEvent Calendar event.. + * @param event Mouse event. + */ + eventClicked(calendarEvent: AddonCalendarEventToDisplay, event: MouseEvent): void { + this.onEventClicked.emit(calendarEvent.id); + event.stopPropagation(); + } + + /** + * A day was clicked. + * + * @param day Day. + */ + dayClicked(day: number): void { + this.onDayClicked.emit({ day: day, month: this.month!, year: this.year! }); + } + + /** + * Check if user is viewing the current month. + */ + calculateIsCurrentMonth(): void { + const now = new Date(); + + this.currentTime = CoreTimeUtils.instance.timestamp(); + + this.isCurrentMonth = this.year == now.getFullYear() && this.month == now.getMonth() + 1; + this.isPastMonth = this.year! < now.getFullYear() || (this.year == now.getFullYear() && this.month! < now.getMonth() + 1); + } + + /** + * Go to current month. + */ + async goToCurrentMonth(): Promise { + const now = new Date(); + const initialMonth = this.month; + const initialYear = this.year; + + this.month = now.getMonth() + 1; + this.year = now.getFullYear(); + + this.loaded = false; + + try { + await this.fetchEvents(); + this.isCurrentMonth = true; + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); + this.year = initialYear; + this.month = initialMonth; + } + + this.loaded = true; + } + + /** + * Decrease the current month. + */ + protected decreaseMonth(): void { + if (this.month === 1) { + this.month = 12; + this.year!--; + } else { + this.month!--; + } + } + + /** + * Increase the current month. + */ + protected increaseMonth(): void { + if (this.month === 12) { + this.month = 1; + this.year!++; + } else { + this.month!++; + } + } + + /** + * Merge online events with the offline events of that period. + */ + protected mergeEvents(): void { + const monthOfflineEvents: { [day: number]: AddonCalendarEventToDisplay[] } = + this.offlineEvents[AddonCalendarHelper.instance.getMonthId(this.year!, this.month!)]; + + this.weeks.forEach((week) => { + week.days.forEach((day) => { + + // Schedule notifications for the events retrieved (only future events will be scheduled). + AddonCalendar.instance.scheduleEventsNotifications(day.eventsFormated!); + + if (monthOfflineEvents || this.deletedEvents.length) { + // There is offline data, merge it. + + if (this.deletedEvents.length) { + // Mark as deleted the events that were deleted in offline. + day.eventsFormated!.forEach((event) => { + event.deleted = this.deletedEvents.indexOf(event.id) != -1; + }); + } + + if (this.offlineEditedEventsIds.length) { + // Remove the online events that were modified in offline. + day.events = day.events.filter((event) => this.offlineEditedEventsIds.indexOf(event.id) == -1); + } + + if (monthOfflineEvents && monthOfflineEvents[day.mday]) { + // Add the offline events (either new or edited). + day.eventsFormated = + AddonCalendarHelper.instance.sortEvents(day.eventsFormated!.concat(monthOfflineEvents[day.mday])); + } + } + }); + }); + } + + /** + * Undelete a certain event. + * + * @param eventId Event ID. + */ + protected undeleteEvent(eventId: number): void { + if (!this.weeks) { + return; + } + + this.weeks.forEach((week) => { + week.days.forEach((day) => { + const event = day.eventsFormated!.find((event) => event.id == eventId); + + if (event) { + event.deleted = false; + } + }); + }); + } + + /** + * Returns if the event is in the past or not. + * + * @param event Event object. + * @return True if it's in the past. + */ + protected isEventPast(event: { timestart: number; timeduration: number}): boolean { + return (event.timestart + event.timeduration) < this.currentTime!; + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.undeleteEventObserver?.off(); + this.obsDefaultTimeChange?.off(); + } + +} diff --git a/src/addons/calendar/components/components.module.ts b/src/addons/calendar/components/components.module.ts new file mode 100644 index 000000000..7f0b02c5b --- /dev/null +++ b/src/addons/calendar/components/components.module.ts @@ -0,0 +1,55 @@ +// (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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { FormsModule } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; + +import { AddonCalendarCalendarComponent } from './calendar/calendar'; +import { AddonCalendarUpcomingEventsComponent } from './upcoming-events/upcoming-events'; +import { AddonCalendarFilterPopoverComponent } from './filter/filter'; + +@NgModule({ + declarations: [ + AddonCalendarCalendarComponent, + AddonCalendarUpcomingEventsComponent, + AddonCalendarFilterPopoverComponent, + ], + imports: [ + CommonModule, + IonicModule, + FormsModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + ], + providers: [ + ], + exports: [ + AddonCalendarCalendarComponent, + AddonCalendarUpcomingEventsComponent, + AddonCalendarFilterPopoverComponent, + ], + entryComponents: [ + AddonCalendarFilterPopoverComponent, + ], +}) +export class AddonCalendarComponentsModule {} diff --git a/src/addons/calendar/components/filter/addon-calendar-filter-popover.html b/src/addons/calendar/components/filter/addon-calendar-filter-popover.html new file mode 100644 index 000000000..c2d603559 --- /dev/null +++ b/src/addons/calendar/components/filter/addon-calendar-filter-popover.html @@ -0,0 +1,20 @@ + + + + + {{ 'addon.calendar.' + type + 'events' | translate}} + + + + + + + + + + + + + + + diff --git a/src/addons/calendar/components/filter/filter-popover.scss b/src/addons/calendar/components/filter/filter-popover.scss new file mode 100644 index 000000000..904ccbfab --- /dev/null +++ b/src/addons/calendar/components/filter/filter-popover.scss @@ -0,0 +1,21 @@ +:host { + ion-item { + ion-icon, ion-radio { + margin-right: 8px; + } + + > ion-icon { + padding: 4px; + font-size: 20px; + } + } +} + +:host-context([dir=rtl]) { + ion-item { + ion-icon, ion-radio { + margin-left: 8px; + margin-right: unset; + } + } +} diff --git a/src/addons/calendar/components/filter/filter.ts b/src/addons/calendar/components/filter/filter.ts new file mode 100644 index 000000000..9b69396aa --- /dev/null +++ b/src/addons/calendar/components/filter/filter.ts @@ -0,0 +1,86 @@ +// (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 { CoreEnrolledCourseData } from '@features/courses/services/courses'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreEvents } from '@singletons/events'; +import { AddonCalendarEventType, AddonCalendarProvider } from '../../services/calendar'; +import { AddonCalendarFilter, AddonCalendarEventIcons } from '../../services/calendar-helper'; + +/** + * Component to display the events filter that includes events types and a list of courses. + */ +@Component({ + selector: 'addon-calendar-filter-popover', + templateUrl: 'addon-calendar-filter-popover.html', + styleUrls: ['../../calendar-common.scss', 'filter-popover.scss'], +}) +export class AddonCalendarFilterPopoverComponent implements OnInit { + + + @Input() filter: AddonCalendarFilter = { + filtered: false, + courseId: -1, + categoryId: undefined, + course: true, + group: true, + site: true, + user: true, + category: true, + }; + + courseId = '-1'; + + @Input() courses: Partial[] = []; + typeIcons: AddonCalendarEventIcons[] = []; + types: string[] = []; + + constructor() { + CoreUtils.instance.enumKeys(AddonCalendarEventType).forEach((name) => { + const value = AddonCalendarEventType[name]; + this.typeIcons[value] = AddonCalendarEventIcons[name]; + this.types.push(value); + }); + + + } + + /** + * Init the component. + */ + ngOnInit(): void { + this.courseId = this.filter.courseId + ''; + } + + /** + * Function called when an item is clicked. + */ + onChange(): void { + const courseId = parseInt(this.courseId, 10); + if (courseId > 0) { + const course = this.courses.find((course) => courseId == course.id); + this.filter.courseId = course?.id || -1; + this.filter.categoryId = course?.categoryid; + } else { + this.filter.courseId = -1; + this.filter.categoryId = undefined; + } + + this.filter.filtered = this.filter.courseId > 0 || this.types.some((name) => !this.filter[name]); + + CoreEvents.trigger(AddonCalendarProvider.FILTER_CHANGED_EVENT, this.filter); + } + +} diff --git a/src/addons/calendar/components/upcoming-events/addon-calendar-upcoming-events.html b/src/addons/calendar/components/upcoming-events/addon-calendar-upcoming-events.html new file mode 100644 index 000000000..f86b22a2c --- /dev/null +++ b/src/addons/calendar/components/upcoming-events/addon-calendar-upcoming-events.html @@ -0,0 +1,29 @@ + + + + + + + + + + + +

+

+
+ + + {{ 'core.notsent' | translate }} + + + + {{ 'core.deletedoffline' | translate }} + +
+
+
+ +
diff --git a/src/addons/calendar/components/upcoming-events/upcoming-events.scss b/src/addons/calendar/components/upcoming-events/upcoming-events.scss new file mode 100644 index 000000000..0113d9512 --- /dev/null +++ b/src/addons/calendar/components/upcoming-events/upcoming-events.scss @@ -0,0 +1,5 @@ +:host { + .addon-calendar-event { + cursor: pointer; + } +} diff --git a/src/addons/calendar/components/upcoming-events/upcoming-events.ts b/src/addons/calendar/components/upcoming-events/upcoming-events.ts new file mode 100644 index 000000000..6c12077b1 --- /dev/null +++ b/src/addons/calendar/components/upcoming-events/upcoming-events.ts @@ -0,0 +1,324 @@ +// (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, OnDestroy, OnInit, Input, DoCheck, Output, EventEmitter, KeyValueDiffers, KeyValueDiffer } from '@angular/core'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { + AddonCalendarProvider, + AddonCalendarEventToDisplay, + AddonCalendar, + AddonCalendarUpdatedEventEvent, +} from '../../services/calendar'; +import { AddonCalendarHelper, AddonCalendarFilter } from '../../services/calendar-helper'; +import { AddonCalendarOffline } from '../../services/calendar-offline'; +import { CoreCategoryData, CoreCourses } from '@features/courses/services/courses'; +import { CoreConstants } from '@/core/constants'; +import { CoreLocalNotifications } from '@services/local-notifications'; + +/** + * Component that displays upcoming events. + */ +@Component({ + selector: 'addon-calendar-upcoming-events', + templateUrl: 'addon-calendar-upcoming-events.html', + styleUrls: ['../../calendar-common.scss', 'upcoming-events.scss'], +}) +export class AddonCalendarUpcomingEventsComponent implements OnInit, DoCheck, OnDestroy { + + @Input() filter?: AddonCalendarFilter; // Filter to apply. + @Output() onEventClicked = new EventEmitter(); + + filteredEvents: AddonCalendarEventToDisplay[] = []; + loaded = false; + + protected year?: number; + protected month?: number; + protected categoriesRetrieved = false; + protected categories: { [id: number]: CoreCategoryData } = {}; + protected currentSiteId: string; + protected events: AddonCalendarEventToDisplay[] = []; // Events (both online and offline). + protected onlineEvents: AddonCalendarEventToDisplay[] = []; + protected offlineEvents: AddonCalendarEventToDisplay[] = []; // Offline events. + protected deletedEvents: number[] = []; // Events deleted in offline. + protected lookAhead = 0; + protected timeFormat?: string; + protected differ: KeyValueDiffer; // To detect changes in the data input. + + // Observers. + protected undeleteEventObserver: CoreEventObserver; + protected obsDefaultTimeChange?: CoreEventObserver; + + constructor( + differs: KeyValueDiffers, + ) { + this.currentSiteId = CoreSites.instance.getCurrentSiteId(); + + if (CoreLocalNotifications.instance.isAvailable()) { // Re-schedule events if default time changes. + this.obsDefaultTimeChange = CoreEvents.on(AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_CHANGED, () => { + AddonCalendar.instance.scheduleEventsNotifications(this.onlineEvents); + }, this.currentSiteId); + } + + // Listen for events "undeleted" (offline). + this.undeleteEventObserver = CoreEvents.on( + AddonCalendarProvider.UNDELETED_EVENT_EVENT, + (data: AddonCalendarUpdatedEventEvent) => { + if (!data || !data.eventId) { + return; + } + + // Mark it as undeleted, no need to refresh. + this.undeleteEvent(data.eventId); + + // Remove it from the list of deleted events if it's there. + const index = this.deletedEvents.indexOf(data.eventId); + if (index != -1) { + this.deletedEvents.splice(index, 1); + } + }, + this.currentSiteId, + ); + + this.differ = differs.find([]).create(); + } + + /** + * Component loaded. + */ + ngOnInit(): void { + this.fetchData(); + } + + /** + * Detect and act upon changes that Angular can’t or won’t detect on its own (objects and arrays). + */ + ngDoCheck(): void { + // Check if there's any change in the filter object. + const changes = this.differ.diff(this.filter!); + if (changes) { + this.filterEvents(); + } + } + + /** + * Fetch data. + * + * @return Promise resolved when done. + */ + async fetchData(): Promise { + const promises: Promise[] = []; + + promises.push(this.loadCategories()); + + // Get offline events. + promises.push(AddonCalendarOffline.instance.getAllEditedEvents().then((offlineEvents) => { + // Format data. + const events: AddonCalendarEventToDisplay[] = offlineEvents.map((event) => + AddonCalendarHelper.instance.formatOfflineEventData(event)); + + this.offlineEvents = AddonCalendarHelper.instance.sortEvents(events); + + return; + })); + + // Get events deleted in offline. + promises.push(AddonCalendarOffline.instance.getAllDeletedEventsIds().then((ids) => { + this.deletedEvents = ids; + + return; + })); + + // Get user preferences. + promises.push(AddonCalendar.instance.getCalendarLookAhead().then((value) => { + this.lookAhead = value; + + return; + })); + + promises.push(AddonCalendar.instance.getCalendarTimeFormat().then((value) => { + this.timeFormat = value; + + return; + })); + + try { + await Promise.all(promises); + + this.fetchEvents(); + + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); + } + + this.loaded = true; + } + + /** + * Fetch upcoming events. + * + * @return Promise resolved when done. + */ + async fetchEvents(): Promise { + // Don't pass courseId and categoryId, we'll filter them locally. + const result = await AddonCalendar.instance.getUpcomingEvents(); + this.onlineEvents = result.events.map((event) => AddonCalendarHelper.instance.formatEventData(event)); + // Schedule notifications for the events retrieved. + AddonCalendar.instance.scheduleEventsNotifications(this.onlineEvents); + // Merge the online events with offline data. + this.events = this.mergeEvents(); + // Filter events by course. + this.filterEvents(); + + // Re-calculate the formatted time so it uses the device date. + const promises = this.events.map((event) => + AddonCalendar.instance.formatEventTime(event, this.timeFormat!).then((time) => { + event.formattedtime = time; + + return; + })); + + await Promise.all(promises); + } + + /** + * Load categories to be able to filter events. + * + * @return Promise resolved when done. + */ + protected async loadCategories(): Promise { + if (this.categoriesRetrieved) { + // Already retrieved, stop. + return; + } + + try { + const cats = await CoreCourses.instance.getCategories(0, true); + this.categoriesRetrieved = true; + this.categories = {}; + + // Index categories by ID. + cats.forEach((category) => { + this.categories[category.id] = category; + }); + } catch { + // Ignore errors. + } + } + + /** + * Filter events based on the filter popover. + */ + protected filterEvents(): void { + this.filteredEvents = AddonCalendarHelper.instance.getFilteredEvents(this.events, this.filter!, this.categories); + } + + /** + * Refresh events. + * + * @param afterChange Whether the refresh is done after an event has changed or has been synced. + * @return Promise resolved when done. + */ + async refreshData(afterChange?: boolean): Promise { + const promises: Promise[] = []; + + // Don't invalidate upcoming events after a change, it has already been handled. + if (!afterChange) { + promises.push(AddonCalendar.instance.invalidateAllUpcomingEvents()); + } + promises.push(CoreCourses.instance.invalidateCategories(0, true)); + promises.push(AddonCalendar.instance.invalidateLookAhead()); + promises.push(AddonCalendar.instance.invalidateTimeFormat()); + + this.categoriesRetrieved = false; // Get categories again. + + await Promise.all(promises); + + await this.fetchData(); + } + + /** + * An event was clicked. + * + * @param event Event. + */ + eventClicked(event: AddonCalendarEventToDisplay): void { + this.onEventClicked.emit(event.id); + } + + /** + * Merge online events with the offline events of that period. + * + * @return Merged events. + */ + protected mergeEvents(): AddonCalendarEventToDisplay[] { + if (!this.offlineEvents.length && !this.deletedEvents.length) { + // No offline events, nothing to merge. + return this.onlineEvents; + } + + const start = Date.now() / 1000; + const end = start + (CoreConstants.SECONDS_DAY * this.lookAhead); + let result: AddonCalendarEventToDisplay[] = this.onlineEvents; + + if (this.deletedEvents.length) { + // Mark as deleted the events that were deleted in offline. + result.forEach((event) => { + event.deleted = this.deletedEvents.indexOf(event.id) != -1; + }); + } + + if (this.offlineEvents.length) { + // Remove the online events that were modified in offline. + result = result.filter((event) => { + const offlineEvent = this.offlineEvents.find((ev) => ev.id == event.id); + + return !offlineEvent; + }); + } + + // Now get the offline events that belong to this period. + const periodOfflineEvents = + this.offlineEvents.filter((event) => + (event.timestart >= start || event.timestart + event.timeduration >= start) && event.timestart <= end); + + // Merge both arrays and sort them. + result = result.concat(periodOfflineEvents); + + return AddonCalendarHelper.instance.sortEvents(result); + } + + /** + * Undelete a certain event. + * + * @param eventId Event ID. + */ + protected undeleteEvent(eventId: number): void { + const event = this.onlineEvents.find((event) => event.id == eventId); + + if (event) { + event.deleted = false; + } + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.undeleteEventObserver?.off(); + this.obsDefaultTimeChange?.off(); + } + +} diff --git a/src/addons/calendar/lang.json b/src/addons/calendar/lang.json new file mode 100644 index 000000000..8b1748f6b --- /dev/null +++ b/src/addons/calendar/lang.json @@ -0,0 +1,76 @@ +{ + "allday": "All day", + "calendar": "Calendar", + "calendarevent": "Calendar event", + "calendarevents": "Calendar events", + "calendarreminders": "Calendar reminders", + "categoryevents": "Category events", + "confirmeventdelete": "Are you sure you want to delete the \"{{$a}}\" event?", + "confirmeventseriesdelete": "The \"{{$a.name}}\" event is part of a series. Do you want to delete just this event, or all {{$a.count}} events in the series?", + "courseevents": "Course events", + "currentmonth": "Current Month", + "daynext": "Next day", + "dayprev": "Previous day", + "defaultnotificationtime": "Default notification time", + "deleteallevents": "Delete all events", + "deleteevent": "Delete event", + "deleteoneevent": "Delete this event", + "durationminutes": "Duration in minutes", + "durationnone": "Without duration", + "durationuntil": "Until", + "editevent": "Editing event", + "errorloadevent": "Error loading event.", + "errorloadevents": "Error loading events.", + "eventcalendareventdeleted": "Calendar event deleted", + "eventduration": "Duration", + "eventendtime": "End time", + "eventkind": "Type of event", + "eventname": "Event title", + "eventstarttime": "Start time", + "eventtype": "Event type", + "fri": "Fri", + "friday": "Friday", + "gotoactivity": "Go to activity", + "groupevents": "Group events", + "invalidtimedurationminutes": "The duration in minutes you have entered is invalid. Please enter the duration in minutes greater than 0 or select no duration.", + "invalidtimedurationuntil": "The date and time you selected for duration until is before the start time of the event. Please correct this before proceeding.", + "mon": "Mon", + "monday": "Monday", + "monthlyview": "Monthly view", + "newevent": "New event", + "noevents": "There are no events", + "nopermissiontoupdatecalendar": "Sorry, but you do not have permission to update the calendar event.", + "reminders": "Reminders", + "repeatedevents": "Repeated events", + "repeateditall": "Also apply changes to the other {{$a}} events in this repeat series", + "repeateditthis": "Apply changes to this event only", + "repeatevent": "Repeat this event", + "repeatweeksl": "Repeat weekly, creating altogether", + "sat": "Sat", + "saturday": "Saturday", + "setnewreminder": "Set a new reminder", + "siteevents": "Site events", + "sun": "Sun", + "sunday": "Sunday", + "thu": "Thu", + "thursday": "Thursday", + "today": "Today", + "tomorrow": "Tomorrow", + "tue": "Tue", + "tuesday": "Tuesday", + "typecategory": "Category event", + "typeclose": "Close event", + "typecourse": "Course event", + "typedue": "Due event", + "typegradingdue": "Grading due event", + "typegroup": "Group event", + "typeopen": "Open event", + "typesite": "Site event", + "typeuser": "User event", + "upcomingevents": "Upcoming events", + "userevents": "User events", + "wed": "Wed", + "wednesday": "Wednesday", + "when": "When", + "yesterday": "Yesterday" +} \ No newline at end of file diff --git a/src/addons/calendar/pages/day/day.html b/src/addons/calendar/pages/day/day.html new file mode 100644 index 000000000..378fe8dcc --- /dev/null +++ b/src/addons/calendar/pages/day/day.html @@ -0,0 +1,92 @@ + + + + + + {{ 'addon.calendar.calendarevents' | translate }} + + + + + + + + + + + + + + + + + + + + + + + + + + + +

{{ periodName }}

+
+ + + + + +
+
+ + + + + + + {{ 'core.hasdatatosync' | translate:{$a: 'core.day' | translate} }} + + + + + + + + + + + + + +

+

+
+ + + {{ 'core.notsent' | translate }} + + + + {{ 'core.deletedoffline' | translate }} + +
+
+
+
+ + + + + + + +
diff --git a/src/addons/calendar/pages/day/day.module.ts b/src/addons/calendar/pages/day/day.module.ts new file mode 100644 index 000000000..20d149967 --- /dev/null +++ b/src/addons/calendar/pages/day/day.module.ts @@ -0,0 +1,51 @@ +// (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 { NgModule } from '@angular/core'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CommonModule } from '@angular/common'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; +import { AddonCalendarComponentsModule } from '../../components/components.module'; + +import { AddonCalendarDayPage } from './day.page'; + +const routes: Routes = [ + { + path: '', + component: AddonCalendarDayPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + AddonCalendarComponentsModule, + ], + declarations: [ + AddonCalendarDayPage, + ], + exports: [RouterModule], +}) +export class AddonCalendarDayPageModule {} diff --git a/src/addons/calendar/pages/day/day.page.ts b/src/addons/calendar/pages/day/day.page.ts new file mode 100644 index 000000000..16a6cab8d --- /dev/null +++ b/src/addons/calendar/pages/day/day.page.ts @@ -0,0 +1,728 @@ +// (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, OnInit, OnDestroy } from '@angular/core'; +import { PopoverController, IonRefresher } from '@ionic/angular'; +import { CoreApp } from '@services/app'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreLocalNotifications } from '@services/local-notifications'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTimeUtils } from '@services/utils/time'; +import { + AddonCalendarProvider, + AddonCalendar, + AddonCalendarEventToDisplay, + AddonCalendarCalendarDay, + AddonCalendarEventType, + AddonCalendarUpdatedEventEvent, +} from '../../services/calendar'; +import { AddonCalendarOffline } from '../../services/calendar-offline'; +import { AddonCalendarFilter, AddonCalendarHelper } from '../../services/calendar-helper'; +import { AddonCalendarSync, AddonCalendarSyncEvents, AddonCalendarSyncProvider } from '../../services/calendar-sync'; +import { CoreCategoryData, CoreCourses, CoreEnrolledCourseData } from '@features/courses/services/courses'; +import { CoreCoursesHelper } from '@features/courses/services/courses-helper'; +import { AddonCalendarFilterPopoverComponent } from '../../components/filter/filter'; +import moment from 'moment'; +import { Network, NgZone } from '@singletons'; +import { CoreNavigator } from '@services/navigator'; +import { ActivatedRoute, Params } from '@angular/router'; +import { Subscription } from 'rxjs'; +import { CoreUtils } from '@services/utils/utils'; + +/** + * Page that displays the calendar events for a certain day. + */ +@Component({ + selector: 'page-addon-calendar-day', + templateUrl: 'day.html', + styleUrls: ['../../calendar-common.scss', 'day.scss'], +}) +export class AddonCalendarDayPage implements OnInit, OnDestroy { + + protected currentSiteId: string; + protected year!: number; + protected month!: number; + protected day!: number; + protected categories: { [id: number]: CoreCategoryData } = {}; + protected events: AddonCalendarEventToDisplay[] = []; // Events (both online and offline). + protected onlineEvents: AddonCalendarEventToDisplay[] = []; + protected offlineEvents: { [monthId: string]: { [day: number]: AddonCalendarEventToDisplay[] } } = + {}; // Offline events classified in month & day. + + protected offlineEditedEventsIds: number[] = []; // IDs of events edited in offline. + protected deletedEvents: number[] = []; // Events deleted in offline. + protected timeFormat?: string; + protected currentTime!: number; + + // Observers. + protected newEventObserver: CoreEventObserver; + protected discardedObserver: CoreEventObserver; + protected editEventObserver: CoreEventObserver; + protected deleteEventObserver: CoreEventObserver; + protected undeleteEventObserver: CoreEventObserver; + protected syncObserver: CoreEventObserver; + protected manualSyncObserver: CoreEventObserver; + protected onlineObserver: Subscription; + protected obsDefaultTimeChange?: CoreEventObserver; + protected filterChangedObserver: CoreEventObserver; + + periodName?: string; + filteredEvents: AddonCalendarEventToDisplay [] = []; + canCreate = false; + courses: Partial[] = []; + loaded = false; + hasOffline = false; + isOnline = false; + syncIcon = 'spinner'; + isCurrentDay = false; + isPastDay = false; + currentMoment!: moment.Moment; + filter: AddonCalendarFilter = { + filtered: false, + courseId: -1, + categoryId: undefined, + course: true, + group: true, + site: true, + user: true, + category: true, + }; + + constructor( + protected route: ActivatedRoute, + private popoverCtrl: PopoverController, + ) { + this.currentSiteId = CoreSites.instance.getCurrentSiteId(); + + if (CoreLocalNotifications.instance.isAvailable()) { + // Re-schedule events if default time changes. + this.obsDefaultTimeChange = CoreEvents.on(AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_CHANGED, () => { + AddonCalendar.instance.scheduleEventsNotifications(this.onlineEvents); + }, this.currentSiteId); + } + + // Listen for events added. When an event is added, reload the data. + this.newEventObserver = CoreEvents.on( + AddonCalendarProvider.NEW_EVENT_EVENT, + (data: AddonCalendarUpdatedEventEvent) => { + if (data && data.eventId) { + this.loaded = false; + this.refreshData(true, true); + } + }, + this.currentSiteId, + ); + + // Listen for new event discarded event. When it does, reload the data. + this.discardedObserver = CoreEvents.on(AddonCalendarProvider.NEW_EVENT_DISCARDED_EVENT, () => { + this.loaded = false; + this.refreshData(true, true); + }, this.currentSiteId); + + // Listen for events edited. When an event is edited, reload the data. + this.editEventObserver = CoreEvents.on( + AddonCalendarProvider.EDIT_EVENT_EVENT, + (data: AddonCalendarUpdatedEventEvent) => { + if (data && data.eventId) { + this.loaded = false; + this.refreshData(true, true); + } + }, + this.currentSiteId, + ); + + // Refresh data if calendar events are synchronized automatically. + this.syncObserver = CoreEvents.on(AddonCalendarSyncProvider.AUTO_SYNCED, () => { + this.loaded = false; + this.refreshData(false, true); + }, this.currentSiteId); + + // Refresh data if calendar events are synchronized manually but not by this page. + this.manualSyncObserver = CoreEvents.on(AddonCalendarSyncProvider.MANUAL_SYNCED, (data: AddonCalendarSyncEvents) => { + if (data && (data.source != 'day' || data.year != this.year || data.month != this.month || data.day != this.day)) { + this.loaded = false; + this.refreshData(false, true); + } + }, this.currentSiteId); + + // Update the events when an event is deleted. + this.deleteEventObserver = CoreEvents.on( + AddonCalendarProvider.DELETED_EVENT_EVENT, + (data: AddonCalendarUpdatedEventEvent) => { + if (data && !data.sent) { + // Event was deleted in offline. Just mark it as deleted, no need to refresh. + this.hasOffline = this.markAsDeleted(data.eventId, true) || this.hasOffline; + this.deletedEvents.push(data.eventId); + } else { + this.loaded = false; + this.refreshData(false, true); + } + }, + this.currentSiteId, + ); + + // Listen for events "undeleted" (offline). + this.undeleteEventObserver = CoreEvents.on( + AddonCalendarProvider.UNDELETED_EVENT_EVENT, + (data: AddonCalendarUpdatedEventEvent) => { + if (!data || !data.eventId) { + return; + } + + // Mark it as undeleted, no need to refresh. + const found = this.markAsDeleted(data.eventId, false); + + // Remove it from the list of deleted events if it's there. + const index = this.deletedEvents.indexOf(data.eventId); + if (index != -1) { + this.deletedEvents.splice(index, 1); + } + + if (found) { + // The deleted event belongs to current list. Re-calculate "hasOffline". + this.hasOffline = false; + + if (this.events.length != this.onlineEvents.length) { + this.hasOffline = true; + } else { + const event = this.events.find((event) => event.deleted || event.offline); + + this.hasOffline = !!event; + } + } + }, + this.currentSiteId, + ); + + this.filterChangedObserver = CoreEvents.on( + AddonCalendarProvider.FILTER_CHANGED_EVENT, + async (data: AddonCalendarFilter) => { + this.filter = data; + + // Course viewed has changed, check if the user can create events for this course calendar. + this.canCreate = await AddonCalendarHelper.instance.canEditEvents(this.filter.courseId); + + this.filterEvents(); + }, + ); + + // Refresh online status when changes. + this.onlineObserver = Network.instance.onChange().subscribe(() => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + NgZone.instance.run(() => { + this.isOnline = CoreApp.instance.isOnline(); + }); + }); + } + + /** + * View loaded. + */ + ngOnInit(): void { + const types: string[] = []; + + CoreUtils.instance.enumKeys(AddonCalendarEventType).forEach((name) => { + const value = AddonCalendarEventType[name]; + const filter = this.route.snapshot.queryParams[name]; + this.filter[name] = typeof filter == 'undefined' ? true : filter; + types.push(value); + }); + this.filter.courseId = parseInt(this.route.snapshot.queryParams['courseId'], 10) || -1; + this.filter.categoryId = parseInt(this.route.snapshot.queryParams['categoryId'], 10) || undefined; + + this.filter.filtered = typeof this.filter.courseId != 'undefined' || types.some((name) => !this.filter[name]); + + const now = new Date(); + this.year = this.route.snapshot.queryParams['year'] || now.getFullYear(); + this.month = this.route.snapshot.queryParams['month'] || (now.getMonth() + 1); + this.day = this.route.snapshot.queryParams['day'] || now.getDate(); + + this.calculateCurrentMoment(); + this.calculateIsCurrentDay(); + + this.fetchData(true); + } + + /** + * Fetch all the data required for the view. + * + * @param sync Whether it should try to synchronize offline events. + * @param showErrors Whether to show sync errors to the user. + * @return Promise resolved when done. + */ + async fetchData(sync?: boolean): Promise { + + this.syncIcon = 'spinner'; + this.isOnline = CoreApp.instance.isOnline(); + + if (sync) { + await this.sync(); + } + + try { + const promises: Promise[] = []; + + // Load courses for the popover. + promises.push(CoreCoursesHelper.instance.getCoursesForPopover(this.filter.courseId).then((data) => { + this.courses = data.courses; + + return; + })); + + // Get categories. + promises.push(this.loadCategories()); + + // Get offline events. + promises.push(AddonCalendarOffline.instance.getAllEditedEvents().then((offlineEvents) => { + // Classify them by month & day. + this.offlineEvents = AddonCalendarHelper.instance.classifyIntoMonths(offlineEvents); + + // Get the IDs of events edited in offline. + this.offlineEditedEventsIds = offlineEvents.filter((event) => event.id! > 0).map((event) => event.id!); + + return; + })); + + // Get events deleted in offline. + promises.push(AddonCalendarOffline.instance.getAllDeletedEventsIds().then((ids) => { + this.deletedEvents = ids; + + return; + })); + + // Check if user can create events. + promises.push(AddonCalendarHelper.instance.canEditEvents(this.filter.courseId).then((canEdit) => { + this.canCreate = canEdit; + + return; + })); + + // Get user preferences. + promises.push(AddonCalendar.instance.getCalendarTimeFormat().then((value) => { + this.timeFormat = value; + + return; + })); + + await Promise.all(promises); + + await this.fetchEvents(); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); + } + + this.loaded = true; + this.syncIcon = 'fas-sync-alt'; + } + + /** + * Fetch the events for current day. + * + * @return Promise resolved when done. + */ + async fetchEvents(): Promise { + let result: AddonCalendarCalendarDay; + try { + // Don't pass courseId and categoryId, we'll filter them locally. + result = await AddonCalendar.instance.getDayEvents(this.year, this.month, this.day); + this.onlineEvents = result.events.map((event) => AddonCalendarHelper.instance.formatEventData(event)); + } catch (error) { + if (CoreApp.instance.isOnline()) { + throw error; + } + // Allow navigating to non-cached days in offline (behave as if using emergency cache). + this.onlineEvents = []; + } + + // Calculate the period name. We don't use the one in result because it's in server's language. + this.periodName = CoreTimeUtils.instance.userDate( + new Date(this.year, this.month - 1, this.day).getTime(), + 'core.strftimedaydate', + ); + + // Schedule notifications for the events retrieved (only future events will be scheduled). + AddonCalendar.instance.scheduleEventsNotifications(this.onlineEvents); + // Merge the online events with offline data. + this.events = this.mergeEvents(); + // Filter events by course. + this.filterEvents(); + this.calculateIsCurrentDay(); + // Re-calculate the formatted time so it uses the device date. + const dayTime = this.currentMoment.unix() * 1000; + + const promises = this.events.map((event) => { + event.ispast = this.isPastDay || (this.isCurrentDay && this.isEventPast(event)); + + return AddonCalendar.instance.formatEventTime(event, this.timeFormat!, true, dayTime).then((time) => { + event.formattedtime = time; + + return; + }); + }); + + await Promise.all(promises); + } + + /** + * Merge online events with the offline events of that period. + * + * @return Merged events. + */ + protected mergeEvents(): AddonCalendarEventToDisplay[] { + this.hasOffline = false; + + if (!Object.keys(this.offlineEvents).length && !this.deletedEvents.length) { + // No offline events, nothing to merge. + return this.onlineEvents; + } + + const monthOfflineEvents = this.offlineEvents[AddonCalendarHelper.instance.getMonthId(this.year, this.month)]; + const dayOfflineEvents = monthOfflineEvents && monthOfflineEvents[this.day]; + let result = this.onlineEvents; + + if (this.deletedEvents.length) { + // Mark as deleted the events that were deleted in offline. + result.forEach((event) => { + event.deleted = this.deletedEvents.indexOf(event.id) != -1; + + if (event.deleted) { + this.hasOffline = true; + } + }); + } + + if (this.offlineEditedEventsIds.length) { + // Remove the online events that were modified in offline. + result = result.filter((event) => this.offlineEditedEventsIds.indexOf(event.id) == -1); + + if (result.length != this.onlineEvents.length) { + this.hasOffline = true; + } + } + + if (dayOfflineEvents && dayOfflineEvents.length) { + // Add the offline events (either new or edited). + this.hasOffline = true; + result = AddonCalendarHelper.instance.sortEvents(result.concat(dayOfflineEvents)); + } + + return result; + } + + /** + * Filter events based on the filter popover. + */ + protected filterEvents(): void { + this.filteredEvents = AddonCalendarHelper.instance.getFilteredEvents(this.events, this.filter, this.categories); + } + + /** + * Refresh the data. + * + * @param refresher Refresher. + * @param done Function to call when done. + * @return Promise resolved when done. + */ + async doRefresh(refresher?: CustomEvent, done?: () => void): Promise { + if (!this.loaded) { + return; + } + + await this.refreshData(true).finally(() => { + refresher?.detail.complete(); + done && done(); + }); + } + + /** + * Refresh the data. + * + * @param sync Whether it should try to synchronize offline events. + * @param afterChange Whether the refresh is done after an event has changed or has been synced. + * @return Promise resolved when done. + */ + async refreshData(sync?: boolean, afterChange?: boolean): Promise { + this.syncIcon = 'spinner'; + + const promises: Promise[] = []; + + // Don't invalidate day events after a change, it has already been handled. + if (!afterChange) { + promises.push(AddonCalendar.instance.invalidateDayEvents(this.year, this.month, this.day)); + } + promises.push(AddonCalendar.instance.invalidateAllowedEventTypes()); + promises.push(CoreCourses.instance.invalidateCategories(0, true)); + promises.push(AddonCalendar.instance.invalidateTimeFormat()); + + await Promise.all(promises).finally(() => + this.fetchData(sync)); + } + + /** + * Load categories to be able to filter events. + * + * @return Promise resolved when done. + */ + protected async loadCategories(): Promise { + try { + const cats = await CoreCourses.instance.getCategories(0, true); + this.categories = {}; + + // Index categories by ID. + cats.forEach((category) => { + this.categories[category.id] = category; + }); + } catch { + // Ignore errors. + } + } + + /** + * Try to synchronize offline events. + * + * @param showErrors Whether to show sync errors to the user. + * @return Promise resolved when done. + */ + protected async sync(showErrors?: boolean): Promise { + try { + const result = await AddonCalendarSync.instance.syncEvents(); + + if (result.warnings && result.warnings.length) { + CoreDomUtils.instance.showErrorModal(result.warnings[0]); + } + + if (result.updated) { + // Trigger a manual sync event. + result.source = 'day'; + result.day = this.day; + result.month = this.month; + result.year = this.year; + + CoreEvents.trigger(AddonCalendarSyncProvider.MANUAL_SYNCED, result, this.currentSiteId); + } + } catch (error) { + if (showErrors) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.errorsync', true); + } + } + } + + /** + * Navigate to a particular event. + * + * @param eventId Event to load. + */ + gotoEvent(eventId: number): void { + if (eventId < 0) { + // It's an offline event, go to the edit page. + this.openEdit(eventId); + } else { + CoreNavigator.instance.navigateToSitePath('/calendar/event', { params: { id: eventId } }); + } + } + + /** + * Show the context menu. + * + * @param event Event. + */ + async openFilter(event: MouseEvent): Promise { + const popover = await this.popoverCtrl.create({ + component: AddonCalendarFilterPopoverComponent, + componentProps: { + courses: this.courses, + filter: this.filter, + }, + event, + }); + await popover.present(); + } + + /** + * Open page to create/edit an event. + * + * @param eventId Event ID to edit. + */ + openEdit(eventId?: number): void { + const params: Params = {}; + + if (eventId) { + params.eventId = eventId; + } else { + // It's a new event, set the time. + params.timestamp = moment().year(this.year).month(this.month - 1).date(this.day).unix() * 1000; + } + + if (this.filter.courseId) { + params.courseId = this.filter.courseId; + } + + CoreNavigator.instance.navigateToSitePath('/calendar/edit', { params }); + } + + /** + * Calculate current moment. + */ + calculateCurrentMoment(): void { + this.currentMoment = moment().year(this.year).month(this.month - 1).date(this.day); + } + + /** + * Check if user is viewing the current day. + */ + calculateIsCurrentDay(): void { + const now = new Date(); + + this.currentTime = CoreTimeUtils.instance.timestamp(); + + this.isCurrentDay = this.year == now.getFullYear() && this.month == now.getMonth() + 1 && this.day == now.getDate(); + this.isPastDay = this.year < now.getFullYear() || (this.year == now.getFullYear() && this.month < now.getMonth()) || + (this.year == now.getFullYear() && this.month == now.getMonth() + 1 && this.day < now.getDate()); + } + + /** + * Go to current day. + */ + async goToCurrentDay(): Promise { + const now = new Date(); + const initialDay = this.day; + const initialMonth = this.month; + const initialYear = this.year; + + this.day = now.getDate(); + this.month = now.getMonth() + 1; + this.year = now.getFullYear(); + this.calculateCurrentMoment(); + + this.loaded = false; + + try { + await this.fetchEvents(); + + this.isCurrentDay = true; + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); + + this.year = initialYear; + this.month = initialMonth; + this.day = initialDay; + this.calculateCurrentMoment(); + } + + this.loaded = true; + } + + /** + * Load next day. + */ + async loadNext(): Promise { + this.increaseDay(); + + this.loaded = false; + + try { + await this.fetchEvents(); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); + this.decreaseDay(); + } + this.loaded = true; + } + + /** + * Load previous day. + */ + async loadPrevious(): Promise { + this.decreaseDay(); + + this.loaded = false; + + try { + await this.fetchEvents(); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); + this.increaseDay(); + } + this.loaded = true; + } + + /** + * Decrease the current day. + */ + protected decreaseDay(): void { + this.currentMoment.subtract(1, 'day'); + + this.year = this.currentMoment.year(); + this.month = this.currentMoment.month() + 1; + this.day = this.currentMoment.date(); + } + + /** + * Increase the current day. + */ + protected increaseDay(): void { + this.currentMoment.add(1, 'day'); + + this.year = this.currentMoment.year(); + this.month = this.currentMoment.month() + 1; + this.day = this.currentMoment.date(); + } + + /** + * Find an event and mark it as deleted. + * + * @param eventId Event ID. + * @param deleted Whether to mark it as deleted or not. + * @return Whether the event was found. + */ + protected markAsDeleted(eventId: number, deleted: boolean): boolean { + const event = this.onlineEvents.find((event) => event.id == eventId); + + if (event) { + event.deleted = deleted; + + return true; + } + + return false; + } + + /** + * Returns if the event is in the past or not. + * + * @param event Event object. + * @return True if it's in the past. + */ + isEventPast(event: AddonCalendarEventToDisplay): boolean { + return (event.timestart + event.timeduration) < this.currentTime; + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.newEventObserver?.off(); + this.discardedObserver?.off(); + this.editEventObserver?.off(); + this.deleteEventObserver?.off(); + this.undeleteEventObserver?.off(); + this.syncObserver?.off(); + this.manualSyncObserver?.off(); + this.onlineObserver?.unsubscribe(); + this.filterChangedObserver?.off(); + this.obsDefaultTimeChange?.off(); + } + +} diff --git a/src/addons/calendar/pages/day/day.scss b/src/addons/calendar/pages/day/day.scss new file mode 100644 index 000000000..a2b9031b0 --- /dev/null +++ b/src/addons/calendar/pages/day/day.scss @@ -0,0 +1,9 @@ +:host { + .addon-calendar-period { + flex-grow: 3; + h3 { + margin-top: 10px; + font-size: 1.2rem; + } + } +} diff --git a/src/addons/calendar/pages/edit-event/edit-event.html b/src/addons/calendar/pages/edit-event/edit-event.html new file mode 100644 index 000000000..27ce1cfe5 --- /dev/null +++ b/src/addons/calendar/pages/edit-event/edit-event.html @@ -0,0 +1,232 @@ + + + + + + {{ title | translate }} + + + + + + + + +
+ + +

+ {{ 'addon.calendar.eventname' | translate }} +

+
+ + + +
+ + + + +

+ {{ 'core.date' | translate }} +

+
+ + + +
+ + + + +

+ {{ 'addon.calendar.eventkind' | translate }} +

+
+ + + {{ type.name | translate }} + + +
+ + + + +

+ {{ 'core.category' | translate }} +

+
+ + + {{ category.name }} + + +
+ + + + +

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

+
+ + {{ course.fullname }} + +
+ + + + + + +

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

+
+ + + {{ course.fullname }} + + +
+ + +

{{ 'core.coursenogroups' | translate }}

+
+ + + +

+ {{ 'core.group' | translate }} +

+
+ + {{ group.name }} + +
+ + + + +
+ + + + + + + {{ 'core.showmore' | translate }} + {{ 'core.showless' | translate }} + + + +
+ + + +

{{ 'core.description' | translate }}

+
+ +
+ + + +

{{ 'core.location' | translate }}

+ + +
+ + +
+ + + +

+ {{ 'addon.calendar.eventduration' | translate }} +

+
+
+ + + {{ 'addon.calendar.durationnone' | translate }} + + + + {{ 'addon.calendar.durationuntil' | translate }} + + + + + {{ 'addon.calendar.durationminutes' | translate }} + + +
+
+ + + + + +

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

+
+ +
+ +

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

+ +
+
+ + +
+ + + +

+ {{ 'addon.calendar.repeatedevents' | translate }} +

+
+
+ + {{ 'addon.calendar.repeateditall' | translate:{$a: otherEventsCount} }} + + + + {{ 'addon.calendar.repeateditthis' | translate }} + + +
+
+
+ + + + + + + {{ 'core.save' | translate }} + + + + {{ 'core.discard' | translate }} + + + + +
+
+
diff --git a/src/addons/calendar/pages/edit-event/edit-event.module.ts b/src/addons/calendar/pages/edit-event/edit-event.module.ts new file mode 100644 index 000000000..f9cf8c730 --- /dev/null +++ b/src/addons/calendar/pages/edit-event/edit-event.module.ts @@ -0,0 +1,52 @@ +// (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 { NgModule } from '@angular/core'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreEditorComponentsModule } from '@features/editor/components/components.module'; + +import { AddonCalendarEditEventPage } from './edit-event.page'; + +const routes: Routes = [ + { + path: '', + component: AddonCalendarEditEventPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + FormsModule, + ReactiveFormsModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CoreEditorComponentsModule, + ], + declarations: [ + AddonCalendarEditEventPage, + ], + exports: [RouterModule], +}) +export class AddonCalendarEditEventPageModule {} diff --git a/src/addons/calendar/pages/edit-event/edit-event.page.ts b/src/addons/calendar/pages/edit-event/edit-event.page.ts new file mode 100644 index 000000000..2b97b36fc --- /dev/null +++ b/src/addons/calendar/pages/edit-event/edit-event.page.ts @@ -0,0 +1,636 @@ +// (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, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; +import { FormControl, FormGroup, FormBuilder, Validators } from '@angular/forms'; +import { IonRefresher, NavController } from '@ionic/angular'; +import { CoreEvents } from '@singletons/events'; +import { CoreGroup, CoreGroups } from '@services/groups'; +import { CoreSites } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreCategoryData, CoreCourses, CoreCourseSearchedData, CoreEnrolledCourseData } from '@features/courses/services/courses'; +// @todo import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreEditorRichTextEditorComponent } from '@features/editor/components/rich-text-editor/rich-text-editor.ts'; +import { + AddonCalendarProvider, + AddonCalendarGetCalendarAccessInformationWSResponse, + AddonCalendarEvent, + AddonCalendarEventType, + AddonCalendar, + AddonCalendarSubmitCreateUpdateFormDataWSParams, + AddonCalendarUpdatedEventEvent, +} from '../../services/calendar'; +import { AddonCalendarOffline } from '../../services/calendar-offline'; +import { AddonCalendarEventTypeOption, AddonCalendarHelper } from '../../services/calendar-helper'; +import { AddonCalendarSync, AddonCalendarSyncProvider } from '../../services/calendar-sync'; +import { CoreSite } from '@classes/site'; +import { Translate } from '@singletons'; +import { CoreFilterHelper } from '@features/filter/services/filter-helper'; +import { ActivatedRoute } from '@angular/router'; +import { AddonCalendarOfflineEventDBRecord } from '../../services/database/calendar-offline'; +import { CoreError } from '@classes/errors/error'; + +/** + * Page that displays a form to create/edit an event. + */ +@Component({ + selector: 'page-addon-calendar-edit-event', + templateUrl: 'edit-event.html', + styleUrls: ['edit-event.scss'], +}) +export class AddonCalendarEditEventPage implements OnInit, OnDestroy { + + @ViewChild(CoreEditorRichTextEditorComponent) descriptionEditor!: CoreEditorRichTextEditorComponent; + @ViewChild('editEventForm') formElement!: ElementRef; + + title = 'addon.calendar.newevent'; + dateFormat: string; + component = AddonCalendarProvider.COMPONENT; + loaded = false; + hasOffline = false; + eventTypes: AddonCalendarEventTypeOption[] = []; + categories: CoreCategoryData[] = []; + courses: CoreCourseSearchedData[] | CoreEnrolledCourseData[] = []; + groups: CoreGroup[] = []; + loadingGroups = false; + courseGroupSet = false; + advanced = false; + errors: Record; + error = false; + eventRepeatId?: number; + otherEventsCount = 0; + eventId?: number; + + // Form variables. + form: FormGroup; + typeControl: FormControl; + groupControl: FormControl; + descriptionControl: FormControl; + + protected courseId!: number; + protected originalData?: AddonCalendarOfflineEventDBRecord; + protected currentSite: CoreSite; + protected types: { [name: string]: boolean } = {}; // Object with the supported types. + protected showAll = false; + protected isDestroyed = false; + protected gotEventData = false; + + constructor( + protected navCtrl: NavController, + protected route: ActivatedRoute, + protected fb: FormBuilder, + ) { + + this.currentSite = CoreSites.instance.getCurrentSite()!; + this.errors = { + required: Translate.instance.instant('core.required'), + }; + + // Calculate format to use. ion-datetime doesn't support escaping characters ([]), so we remove them. + this.dateFormat = CoreTimeUtils.instance.convertPHPToMoment(Translate.instance.instant('core.strftimedatetimeshort')) + .replace(/[[\]]/g, ''); + + this.form = new FormGroup({}); + + // Initialize form variables. + this.typeControl = this.fb.control('', Validators.required); + this.groupControl = this.fb.control(''); + this.descriptionControl = this.fb.control(''); + this.form.addControl('name', this.fb.control('', Validators.required)); + this.form.addControl('eventtype', this.typeControl); + this.form.addControl('categoryid', this.fb.control('')); + this.form.addControl('groupcourseid', this.fb.control('')); + this.form.addControl('groupid', this.groupControl); + this.form.addControl('description', this.descriptionControl); + this.form.addControl('location', this.fb.control('')); + this.form.addControl('duration', this.fb.control(0)); + this.form.addControl('timedurationminutes', this.fb.control('')); + this.form.addControl('repeat', this.fb.control(false)); + this.form.addControl('repeats', this.fb.control('1')); + this.form.addControl('repeateditall', this.fb.control(1)); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.eventId = this.route.snapshot.queryParams['eventId']; + this.courseId = parseInt(this.route.snapshot.queryParams['courseId'], 10) || 0; + this.title = this.eventId ? 'addon.calendar.editevent' : 'addon.calendar.newevent'; + + const timestamp = parseInt(this.route.snapshot.queryParams['timestamp'], 10); + const currentDate = CoreTimeUtils.instance.toDatetimeFormat(timestamp); + this.form.addControl('timestart', this.fb.control(currentDate, Validators.required)); + this.form.addControl('timedurationuntil', this.fb.control(currentDate)); + this.form.addControl('courseid', this.fb.control(this.courseId)); + + this.fetchData().finally(() => { + this.originalData = CoreUtils.instance.clone(this.form.value); + this.loaded = true; + }); + } + + /** + * Fetch the data needed to render the form. + * + * @param refresh Whether it's refreshing data. + * @return Promise resolved when done. + */ + protected async fetchData(): Promise { + let accessInfo: AddonCalendarGetCalendarAccessInformationWSResponse; + + this.error = false; + + // Get access info. + try { + accessInfo = await AddonCalendar.instance.getAccessInformation(this.courseId); + this.types = await AddonCalendar.instance.getAllowedEventTypes(this.courseId); + + const promises: Promise[] = []; + const eventTypes = AddonCalendarHelper.instance.getEventTypeOptions(this.types); + + if (!eventTypes.length) { + throw new CoreError(Translate.instance.instant('addon.calendar.nopermissiontoupdatecalendar')); + } + + if (this.eventId && !this.gotEventData) { + // Editing an event, get the event data. Wait for sync first. + promises.push(AddonCalendarSync.instance.waitForSync(AddonCalendarSyncProvider.SYNC_ID).then(async () => { + // Do not block if the scope is already destroyed. + if (!this.isDestroyed && this.eventId) { + CoreSync.instance.blockOperation(AddonCalendarProvider.COMPONENT, this.eventId); + } + + let eventForm: AddonCalendarEvent | AddonCalendarOfflineEventDBRecord | undefined; + + // Get the event offline data if there's any. + try { + eventForm = await AddonCalendarOffline.instance.getEvent(this.eventId!); + + this.hasOffline = true; + } catch { + // No offline data. + this.hasOffline = false; + } + + if (this.eventId! > 0) { + // It's an online event. get its data from server. + const event = await AddonCalendar.instance.getEventById(this.eventId!); + + if (!eventForm) { + eventForm = event; // Use offline data first. + } + + this.eventRepeatId = event?.repeatid; + if (this.eventRepeatId) { + + this.otherEventsCount = event.eventcount ? event.eventcount - 1 : 0; + } + } + + this.gotEventData = true; + + if (eventForm) { + // Load the data in the form. + return this.loadEventData(eventForm, this.hasOffline); + } + + return; + })); + } + + if (this.types.category) { + // Get the categories. + promises.push(this.fetchCategories()); + } + + this.showAll = CoreUtils.instance.isTrueOrOne(this.currentSite.getStoredConfig('calendar_adminseesall')) && + accessInfo.canmanageentries; + + if (this.types.course || this.types.groups) { + promises.push(this.fetchCourses()); + } + await Promise.all(promises); + + if (!this.typeControl.value) { + // Initialize event type value. If course is allowed, select it first. + if (this.types.course) { + this.typeControl.setValue(AddonCalendarEventType.COURSE); + } else { + this.typeControl.setValue(eventTypes[0].value); + } + } + + this.eventTypes = eventTypes; + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting data.'); + this.error = true; + } + } + + protected async fetchCategories(): Promise { + this.categories = await CoreCourses.instance.getCategories(0, true); + } + + protected async fetchCourses(): Promise { + // Get the courses. + let courses = await (this.showAll ? CoreCourses.instance.getCoursesByField() : CoreCourses.instance.getUserCourses()); + + if (courses.length < 0) { + this.courses = []; + + return; + } + + const courseFillterFullname = (course: CoreCourseSearchedData | CoreEnrolledCourseData): Promise => + CoreFilterHelper.instance.getFiltersAndFormatText(course.fullname, 'course', course.id) + .then((result) => { + course.fullname = result.text; + + return; + }).catch(() => { + // Ignore errors. + }); + + + if (this.showAll) { + // Remove site home from the list of courses. + const siteHomeId = CoreSites.instance.getCurrentSiteHomeId(); + + if ('contacts' in courses[0]) { + courses = (courses as CoreCourseSearchedData[]).filter((course) => course.id != siteHomeId); + } else { + courses = (courses as CoreEnrolledCourseData[]).filter((course) => course.id != siteHomeId); + } + } + + // Format the name of the courses. + if ('contacts' in courses[0]) { + await Promise.all((courses as CoreCourseSearchedData[]).map(courseFillterFullname)); + } else { + await Promise.all((courses as CoreEnrolledCourseData[]).map(courseFillterFullname)); + } + + + // Sort courses by name. + this.courses = courses.sort((a, b) => { + const compareA = a.fullname.toLowerCase(); + const compareB = b.fullname.toLowerCase(); + + return compareA.localeCompare(compareB); + }); + + } + + /** + * Load an event data into the form. + * + * @param event Event data. + * @param isOffline Whether the data is from offline or not. + * @return Promise resolved when done. + */ + protected async loadEventData( + event: AddonCalendarEvent | AddonCalendarOfflineEventDBRecord, + isOffline: boolean, + ): Promise { + if (!event) { + return; + } + + const offlineEvent = (event as AddonCalendarOfflineEventDBRecord); + const onlineEvent = (event as AddonCalendarEvent); + + const courseId = isOffline ? offlineEvent.courseid : onlineEvent.course?.id; + + this.form.controls.name.setValue(event.name); + this.form.controls.timestart.setValue(CoreTimeUtils.instance.toDatetimeFormat(event.timestart * 1000)); + this.form.controls.eventtype.setValue(event.eventtype); + this.form.controls.categoryid.setValue(event.categoryid || ''); + this.form.controls.courseid.setValue(courseId || ''); + this.form.controls.groupcourseid.setValue(courseId || ''); + this.form.controls.groupid.setValue(event.groupid || ''); + this.form.controls.description.setValue(event.description); + this.form.controls.location.setValue(event.location); + + if (isOffline) { + // It's an offline event, use the data as it is. + this.form.controls.duration.setValue(offlineEvent.duration); + this.form.controls.timedurationuntil.setValue( + CoreTimeUtils.instance.toDatetimeFormat(((offlineEvent.timedurationuntil || 0) * 1000) || Date.now()), + ); + this.form.controls.timedurationminutes.setValue(offlineEvent.timedurationminutes || ''); + this.form.controls.repeat.setValue(!!offlineEvent.repeat); + this.form.controls.repeats.setValue(offlineEvent.repeats || '1'); + this.form.controls.repeateditall.setValue(offlineEvent.repeateditall || 1); + } else { + // Online event, we'll have to calculate the data. + + if (onlineEvent.timeduration > 0) { + this.form.controls.duration.setValue(1); + this.form.controls.timedurationuntil.setValue(CoreTimeUtils.instance.toDatetimeFormat( + (onlineEvent.timestart + onlineEvent.timeduration) * 1000, + )); + } else { + // No duration. + this.form.controls.duration.setValue(0); + this.form.controls.timedurationuntil.setValue(CoreTimeUtils.instance.toDatetimeFormat()); + } + + this.form.controls.timedurationminutes.setValue(''); + this.form.controls.repeat.setValue(!!onlineEvent.repeatid); + this.form.controls.repeats.setValue(onlineEvent.eventcount || '1'); + this.form.controls.repeateditall.setValue(1); + } + + if (event.eventtype == AddonCalendarEventType.GROUP && courseId) { + await this.loadGroups(courseId); + } + } + + /** + * Pull to refresh. + * + * @param refresher Refresher. + */ + refreshData(refresher?: CustomEvent): void { + const promises = [ + AddonCalendar.instance.invalidateAccessInformation(this.courseId), + AddonCalendar.instance.invalidateAllowedEventTypes(this.courseId), + ]; + + if (this.types) { + if (this.types.category) { + promises.push(CoreCourses.instance.invalidateCategories(0, true)); + } + if (this.types.course || this.types.groups) { + if (this.showAll) { + promises.push(CoreCourses.instance.invalidateCoursesByField()); + } else { + promises.push(CoreCourses.instance.invalidateUserCourses()); + } + } + } + + Promise.all(promises).finally(() => { + this.fetchData().finally(() => { + refresher?.detail.complete(); + }); + }); + } + + /** + * A course was selected, get its groups. + * + * @param courseId Course ID. + */ + async groupCourseSelected(courseId: number): Promise { + if (!courseId) { + return; + } + + const modal = await CoreDomUtils.instance.showModalLoading(); + + try { + await this.loadGroups(courseId); + + this.groupControl.setValue(''); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting data.'); + } + + modal.dismiss(); + } + + /** + * Load groups of a certain course. + * + * @param courseId Course ID. + * @return Promise resolved when done. + */ + protected async loadGroups(courseId: number): Promise { + this.loadingGroups = true; + + try { + this.groups = await CoreGroups.instance.getUserGroupsInCourse(courseId); + this.courseGroupSet = true; + } finally { + this.loadingGroups = false; + } + } + + /** + * Show or hide advanced form fields. + */ + toggleAdvanced(): void { + this.advanced = !this.advanced; + } + + selectDuration(duration: string): void { + this.form.controls.duration.setValue(duration); + } + + /** + * Create the event. + */ + async submit(): Promise { + // Validate data. + const formData = this.form.value; + const timeStartDate = CoreTimeUtils.instance.convertToTimestamp(formData.timestart); + const timeUntilDate = CoreTimeUtils.instance.convertToTimestamp(formData.timedurationuntil); + const timeDurationMinutes = parseInt(formData.timedurationminutes || '', 10); + let error: string | undefined; + + if (formData.eventtype == AddonCalendarEventType.COURSE && !formData.courseid) { + error = 'core.selectacourse'; + } else if (formData.eventtype == AddonCalendarEventType.GROUP && !formData.groupcourseid) { + error = 'core.selectacourse'; + } else if (formData.eventtype == AddonCalendarEventType.GROUP && !formData.groupid) { + error = 'core.selectagroup'; + } else if (formData.eventtype == AddonCalendarEventType.CATEGORY && !formData.categoryid) { + error = 'core.selectacategory'; + } else if (formData.duration == 1 && timeStartDate > timeUntilDate) { + error = 'addon.calendar.invalidtimedurationuntil'; + } else if (formData.duration == 2 && (isNaN(timeDurationMinutes) || timeDurationMinutes < 1)) { + error = 'addon.calendar.invalidtimedurationminutes'; + } + + if (error) { + // Show error and stop. + CoreDomUtils.instance.showErrorModal(Translate.instance.instant(error)); + + return; + } + + // Format the data to send. + const data: AddonCalendarSubmitCreateUpdateFormDataWSParams = { + name: formData.name, + eventtype: formData.eventtype, + timestart: timeStartDate, + description: { + text: formData.description || '', + format: 1, + }, + location: formData.location, + duration: formData.duration, + repeat: formData.repeat, + }; + + if (formData.eventtype == AddonCalendarEventType.COURSE) { + data.courseid = formData.courseid; + } else if (formData.eventtype == AddonCalendarEventType.GROUP) { + data.groupcourseid = formData.groupcourseid; + data.groupid = formData.groupid; + } else if (formData.eventtype == AddonCalendarEventType.CATEGORY) { + data.categoryid = formData.categoryid; + } + + if (formData.duration == 1) { + data.timedurationuntil = timeUntilDate; + } else if (formData.duration == 2) { + data.timedurationminutes = formData.timedurationminutes; + } + + if (formData.repeat) { + data.repeats = Number(formData.repeats); + } + + if (this.eventRepeatId) { + data.repeatid = this.eventRepeatId; + data.repeateditall = formData.repeateditall; + } + + // Send the data. + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + let event: AddonCalendarEvent | AddonCalendarOfflineEventDBRecord; + + try { + const result = await AddonCalendar.instance.submitEvent(this.eventId, data); + event = result.event; + + CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, result.sent, this.currentSite.getId()); + + 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); + + try { + await AddonCalendarHelper.instance.refreshAfterChangeEvent(result.event, numberOfRepetitions); + } catch { + // Ignore errors. + } + } + + this.returnToList(event); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error sending data.'); + } + + modal.dismiss(); + } + + /** + * Convenience function to update or return to event list depending on device. + * + * @param event Event. + */ + protected returnToList(event?: AddonCalendarEvent | AddonCalendarOfflineEventDBRecord): void { + // Unblock the sync because the view will be destroyed and the sync process could be triggered before ngOnDestroy. + this.unblockSync(); + + if (this.eventId && this.eventId > 0) { + // Editing an event. + CoreEvents.trigger( + AddonCalendarProvider.EDIT_EVENT_EVENT, + { eventId: this.eventId }, + this.currentSite.getId(), + ); + } else { + if (event) { + CoreEvents.trigger( + AddonCalendarProvider.NEW_EVENT_EVENT, + { eventId: event.id! }, + this.currentSite.getId(), + ); + } else { + CoreEvents.trigger(AddonCalendarProvider.NEW_EVENT_DISCARDED_EVENT, {}, this.currentSite.getId()); + } + } + + /* if (this.svComponent && this.svComponent.isOn()) { + // Empty form. + this.hasOffline = false; + this.form.reset(this.originalData); + this.originalData = CoreUtils.instance.clone(this.form.value); + } else {*/ + this.originalData = undefined; // Avoid asking for confirmation. + this.navCtrl.pop(); + } + + /** + * Discard an offline saved discussion. + */ + async discard(): Promise { + try { + await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.areyousure')); + try { + await AddonCalendarOffline.instance.deleteEvent(this.eventId!); + + CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, this.currentSite.getId()); + + this.returnToList(); + } catch { + // Shouldn't happen. + CoreDomUtils.instance.showErrorModal('Error discarding event.'); + } + } catch { + // Ignore errors + } + } + + /** + * Check if we can leave the page or not. + * + * @return Resolved if we can leave it, rejected if not. + */ + async ionViewCanLeave(): Promise { + if (AddonCalendarHelper.instance.hasEventDataChanged(this.form.value, this.originalData)) { + // Show confirmation if some data has been modified. + await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.confirmcanceledit')); + } + + CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, this.currentSite.getId()); + } + + /** + * Unblock sync. + */ + protected unblockSync(): void { + if (this.eventId) { + CoreSync.instance.unblockOperation(AddonCalendarProvider.COMPONENT, this.eventId); + } + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.unblockSync(); + this.isDestroyed = true; + } + +} diff --git a/src/addons/calendar/pages/edit-event/edit-event.scss b/src/addons/calendar/pages/edit-event/edit-event.scss new file mode 100644 index 000000000..6efc949b1 --- /dev/null +++ b/src/addons/calendar/pages/edit-event/edit-event.scss @@ -0,0 +1,11 @@ +:host { + .addon-calendar-eventtype-container.item-select-disabled { + ion-label, ion-select { + opacity: 1; + } + + ion-select::part(icon) { + display: none; + } + } +} diff --git a/src/addons/calendar/pages/event/event.html b/src/addons/calendar/pages/event/event.html new file mode 100644 index 000000000..4a6a3802a --- /dev/null +++ b/src/addons/calendar/pages/event/event.html @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ 'core.hasdatatosync' | translate:{$a: 'addon.calendar.calendarevent' | translate} }} + + + + + + + + + + +

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

+

+ +

+
+ + {{ 'core.deletedoffline' | translate }} + +
+ + +

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

+

+
+ + {{ 'core.deletedoffline' | translate }} + +
+ + +

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

+

{{ 'addon.calendar.type' + event.formattedType | translate }}

+
+
+ + +

{{ 'core.course' | translate}}

+

+ + +

+
+
+ + +

{{ 'core.group' | translate}}

+

{{ groupName }}

+
+
+ + +

{{ 'core.category' | translate}}

+

+
+
+ + +

{{ 'core.description' | translate}}

+

+ +

+
+
+ + +

{{ 'core.location' | translate}}

+

+ + + +

+
+
+ + + + {{ 'addon.calendar.gotoactivity' | translate }} + + + +
+
+ + + + +

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

+
+
+ + + +

+ {{ 'core.defaultvalue' | translate :{$a: ((event.timestart - defaultTime) * 1000) | coreFormatDate } }} +

+

{{ reminder.time * 1000 | coreFormatDate }}

+
+ + + +
+
+ + + + + + {{ 'addon.calendar.setnewreminder' | translate }} + + + + + +
+
+
diff --git a/src/addons/calendar/pages/event/event.module.ts b/src/addons/calendar/pages/event/event.module.ts new file mode 100644 index 000000000..6fbc37425 --- /dev/null +++ b/src/addons/calendar/pages/event/event.module.ts @@ -0,0 +1,53 @@ +// (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 { NgModule } from '@angular/core'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; +import { AddonCalendarComponentsModule } from '../../components/components.module'; + +import { AddonCalendarEventPage } from './event.page'; + +const routes: Routes = [ + { + path: '', + component: AddonCalendarEventPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + FormsModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + AddonCalendarComponentsModule, + ], + declarations: [ + AddonCalendarEventPage, + ], + exports: [RouterModule], +}) +export class AddonCalendarEventPageModule {} diff --git a/src/addons/calendar/pages/event/event.page.ts b/src/addons/calendar/pages/event/event.page.ts new file mode 100644 index 000000000..ecba77a19 --- /dev/null +++ b/src/addons/calendar/pages/event/event.page.ts @@ -0,0 +1,582 @@ +// (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, OnDestroy, OnInit } from '@angular/core'; +import { IonRefresher } from '@ionic/angular'; +import { AlertOptions } from '@ionic/core'; +import { + AddonCalendar, + AddonCalendarEvent, + AddonCalendarEventBase, + AddonCalendarEventToDisplay, + AddonCalendarGetEventsEvent, + AddonCalendarProvider, + AddonCalendarUpdatedEventEvent, +} from '../../services/calendar'; +import { AddonCalendarHelper } from '../../services/calendar-helper'; +import { AddonCalendarOffline } from '../../services/calendar-offline'; +import { AddonCalendarSync, AddonCalendarSyncEvents, AddonCalendarSyncProvider } from '../../services/calendar-sync'; +import { CoreCourses } from '@features/courses/services/courses'; +import { CoreApp } from '@services/app'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreDomUtils } 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'; +// @todo import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { Network, NgZone, Translate } from '@singletons'; +import { Subscription } from 'rxjs'; +import { CoreNavigator } from '@services/navigator'; +import { CoreUtils } from '@services/utils/utils'; +import { AddonCalendarReminderDBRecord } from '../../services/database/calendar'; +import { ActivatedRoute } from '@angular/router'; + +/** + * Page that displays a single calendar event. + */ +@Component({ + selector: 'page-addon-calendar-event', + templateUrl: 'event.html', + styleUrls: ['event.scss'], +}) +export class AddonCalendarEventPage implements OnInit, OnDestroy { + + protected eventId!: number; + protected siteHomeId: number; + protected editEventObserver: CoreEventObserver; + protected syncObserver: CoreEventObserver; + protected manualSyncObserver: CoreEventObserver; + protected onlineObserver: Subscription; + protected currentSiteId: string; + + eventLoaded = false; + notificationFormat?: string; + notificationMin?: string; + notificationMax?: string; + notificationTimeText?: string; + event?: AddonCalendarEventToDisplay; + courseId?: number; + courseName = ''; + groupName?: string; + courseUrl = ''; + notificationsEnabled = false; + moduleUrl = ''; + categoryPath = ''; + currentTime?: number; + defaultTime = 0; + reminders: AddonCalendarReminderDBRecord[] = []; + canEdit = false; + canDelete = false; + hasOffline = false; + isOnline = false; + syncIcon = 'spinner'; // Sync icon. + isSplitViewOn = false; + + constructor( + protected route: ActivatedRoute, + // @Optional() private svComponent: CoreSplitViewComponent, + ) { + + this.notificationsEnabled = CoreLocalNotifications.instance.isAvailable(); + this.siteHomeId = CoreSites.instance.getCurrentSiteHomeId(); + this.currentSiteId = CoreSites.instance.getCurrentSiteId(); + // this.isSplitViewOn = this.svComponent && this.svComponent.isOn(); + + // Check if site supports editing and deleting. No need to check allowed types, event.canedit already does it. + this.canEdit = AddonCalendar.instance.canEditEventsInSite(); + this.canDelete = AddonCalendar.instance.canDeleteEventsInSite(); + + this.asyncConstructor(); + + // Listen for event edited. If current event is edited, reload the data. + this.editEventObserver = CoreEvents.on(AddonCalendarProvider.EDIT_EVENT_EVENT, (data: AddonCalendarUpdatedEventEvent) => { + if (data && data.eventId == this.eventId) { + this.eventLoaded = false; + this.refreshEvent(true, false); + } + }, this.currentSiteId); + + // Refresh data if this calendar event is synchronized automatically. + this.syncObserver = CoreEvents.on( + AddonCalendarSyncProvider.AUTO_SYNCED, + this.checkSyncResult.bind(this, false), + this.currentSiteId, + ); + + // Refresh data if calendar events are synchronized manually but not by this page. + this.manualSyncObserver = CoreEvents.on( + AddonCalendarSyncProvider.MANUAL_SYNCED, + this.checkSyncResult.bind(this, true), + this.currentSiteId, + ); + + // Refresh online status when changes. + this.onlineObserver = Network.instance.onChange().subscribe(() => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + NgZone.instance.run(() => { + this.isOnline = CoreApp.instance.isOnline(); + }); + }); + } + + protected async asyncConstructor(): Promise { + if (this.notificationsEnabled) { + this.reminders = await AddonCalendar.instance.getEventReminders(this.eventId); + this.defaultTime = await AddonCalendar.instance.getDefaultNotificationTime() * 60; + + // Calculate format to use. + this.notificationFormat = + CoreTimeUtils.instance.fixFormatForDatetime(CoreTimeUtils.instance.convertPHPToMoment( + Translate.instance.instant('core.strftimedatetime'), + )); + } + } + + /** + * View loaded. + */ + ngOnInit(): void { + this.eventId = this.route.snapshot.queryParams['id']; + + this.syncIcon = 'spinner'; + + this.fetchEvent(); + } + + /** + * Fetches the event and updates the view. + * + * @param sync Whether it should try to synchronize offline events. + * @param showErrors Whether to show sync errors to the user. + * @return Promise resolved when done. + */ + async fetchEvent(sync = false, showErrors = false): Promise { + const currentSite = CoreSites.instance.getCurrentSite(); + const canGetById = AddonCalendar.instance.isGetEventByIdAvailableInSite(); + let deleted = false; + + this.isOnline = CoreApp.instance.isOnline(); + + if (sync) { + // Try to synchronize offline events. + try { + const result = await AddonCalendarSync.instance.syncEvents(); + if (result.warnings && result.warnings.length) { + CoreDomUtils.instance.showErrorModal(result.warnings[0]); + } + + if (result.deleted && result.deleted.indexOf(this.eventId) != -1) { + // This event was deleted during the sync. + deleted = true; + } + + if (result.updated) { + // Trigger a manual sync event. + result.source = 'event'; + + CoreEvents.trigger( + AddonCalendarSyncProvider.MANUAL_SYNCED, + result, + this.currentSiteId, + ); + } + } catch (error) { + if (showErrors) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.errorsync', true); + } + } + } + + if (deleted) { + return; + } + + try { + let event: AddonCalendarEvent | AddonCalendarEventBase | AddonCalendarGetEventsEvent; + // Get the event data. + if (canGetById) { + event = await AddonCalendar.instance.getEventById(this.eventId); + } else { + event = await AddonCalendar.instance.getEvent(this.eventId); + } + this.event = AddonCalendarHelper.instance.formatEventData(event); + + try { + const offlineEvent = AddonCalendarHelper.instance.formatOfflineEventData( + await AddonCalendarOffline.instance.getEvent(this.eventId), + ); + + // There is offline data, apply it. + this.hasOffline = true; + + this.event = Object.assign(this.event, offlineEvent); + } catch { + // No offline data. + this.hasOffline = false; + } + + this.currentTime = CoreTimeUtils.instance.timestamp(); + this.notificationMin = CoreTimeUtils.instance.userDate(this.currentTime * 1000, 'YYYY-MM-DDTHH:mm', false); + this.notificationMax = CoreTimeUtils.instance.userDate( + (this.event!.timestart + this.event!.timeduration) * 1000, + 'YYYY-MM-DDTHH:mm', + false, + ); + + // Reset some of the calculated data. + this.categoryPath = ''; + this.courseName = ''; + this.courseUrl = ''; + this.moduleUrl = ''; + + if (this.event!.moduleIcon) { + // It's a module event, translate the module name to the current language. + const name = CoreCourse.instance.translateModuleName(this.event!.modulename || ''); + if (name.indexOf('core.mod_') === -1) { + this.event!.modulename = name; + } + + // Get the module URL. + if (canGetById) { + this.moduleUrl = this.event!.url || ''; + } + } + + const promises: Promise[] = []; + + const courseId = this.event.courseid; + if (courseId != this.siteHomeId) { + // If the event belongs to a course, get the course name and the URL to view it. + if (canGetById && this.event.course) { + this.courseId = this.event.course.id; + this.courseName = this.event.course.fullname; + this.courseUrl = this.event.course.viewurl; + } else if (!canGetById && this.event.courseid ) { + // Retrieve the course. + promises.push(CoreCourses.instance.getUserCourse(this.event.courseid, true).then((course) => { + this.courseId = course.id; + this.courseName = course.fullname; + this.courseUrl = currentSite ? CoreTextUtils.instance.concatenatePaths( + currentSite.siteUrl, + '/course/view.php?id=' + this.courseId, + ) : ''; + + return; + }).catch(() => { + // Error getting course, just don't show the course name. + })); + } + } + + // If it's a group event, get the name of the group. + if (courseId && this.event.groupid) { + promises.push(CoreGroups.instance.getUserGroupsInCourse(courseId).then((groups) => { + const group = groups.find((group) => group.id == this.event!.groupid); + + this.groupName = group ? group.name : ''; + + return; + }).catch(() => { + // Error getting groups, just don't show the group name. + this.groupName = ''; + })); + } + + if (canGetById && this.event.iscategoryevent && this.event.category) { + this.categoryPath = this.event.category.nestedname; + } + + if (this.event.location) { + // Build a link to open the address in maps. + this.event.location = CoreTextUtils.instance.decodeHTML(this.event.location); + this.event.encodedLocation = CoreTextUtils.instance.buildAddressURL(this.event.location); + } + + // Check if event was deleted in offine. + promises.push(AddonCalendarOffline.instance.isEventDeleted(this.eventId).then((deleted) => { + this.event!.deleted = deleted; + + return; + })); + + // Re-calculate the formatted time so it uses the device date. + promises.push(AddonCalendar.instance.getCalendarTimeFormat().then(async (timeFormat) => { + this.event!.formattedtime = await AddonCalendar.instance.formatEventTime(this.event!, timeFormat); + + return; + })); + + await Promise.all(promises); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.calendar.errorloadevent', true); + } + + this.eventLoaded = true; + this.syncIcon = 'fas-sync-alt'; + } + + /** + * Add a reminder for this event. + */ + async addNotificationTime(): Promise { + if (this.notificationTimeText && this.event && this.event.id) { + let notificationTime = CoreTimeUtils.instance.convertToTimestamp(this.notificationTimeText); + + const currentTime = CoreTimeUtils.instance.timestamp(); + const minute = Math.floor(currentTime / 60) * 60; + + // Check if the notification time is in the same minute as we are, so the notification is triggered. + if (notificationTime >= minute && notificationTime < minute + 60) { + notificationTime = currentTime + 1; + } + + await AddonCalendar.instance.addEventReminder(this.event, notificationTime); + this.reminders = await AddonCalendar.instance.getEventReminders(this.eventId); + this.notificationTimeText = undefined; + } + } + + /** + * Cancel the selected notification. + * + * @param id Reminder ID. + * @param e Click event. + */ + async cancelNotification(id: number, e: Event): Promise { + e.preventDefault(); + e.stopPropagation(); + + try { + await CoreDomUtils.instance.showDeleteConfirm(); + + const modal = await CoreDomUtils.instance.showModalLoading('core.deleting', true); + + try { + await AddonCalendar.instance.deleteEventReminder(id); + this.reminders = await AddonCalendar.instance.getEventReminders(this.eventId); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error deleting reminder'); + } finally { + modal.dismiss(); + } + } catch { + // Ignore errors. + } + } + + /** + * Refresh the data. + * + * @param refresher Refresher. + * @param done Function to call when done. + * @param showErrors Whether to show sync errors to the user. + * @return Promise resolved when done. + */ + async doRefresh(refresher?: CustomEvent, done?: () => void, showErrors= false): Promise { + if (!this.eventLoaded) { + return; + } + + await this.refreshEvent(true, showErrors).finally(() => { + refresher?.detail.complete(); + done && done(); + }); + } + + /** + * Refresh the event. + * + * @param sync Whether it should try to synchronize offline events. + * @param showErrors Whether to show sync errors to the user. + * @return Promise resolved when done. + */ + async refreshEvent(sync = false, showErrors = false): Promise { + this.syncIcon = 'spinner'; + + const promises: Promise[] = []; + + promises.push(AddonCalendar.instance.invalidateEvent(this.eventId)); + promises.push(AddonCalendar.instance.invalidateTimeFormat()); + + await CoreUtils.instance.allPromisesIgnoringErrors(promises); + + await this.fetchEvent(sync, showErrors); + } + + /** + * Open the page to edit the event. + */ + openEdit(): void { + // Decide which navCtrl to use. If this page is inside a split view, use the split view's master nav. + // @todo const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; + CoreNavigator.instance.navigateToSitePath('/calendar/edit', { params: { eventId: this.eventId } }); + } + + /** + * Delete the event. + */ + async deleteEvent(): Promise { + if (!this.event) { + return; + } + + const title = Translate.instance.instant('addon.calendar.deleteevent'); + const options: AlertOptions = {}; + let message: string; + + if (this.event.eventcount > 1) { + // It's a repeated event. + message = Translate.instance.instant( + 'addon.calendar.confirmeventseriesdelete', + { $a: { name: this.event.name, count: this.event.eventcount } }, + ); + + options.inputs = [ + { + type: 'radio', + name: 'deleteall', + checked: true, + value: false, + label: Translate.instance.instant('addon.calendar.deleteoneevent'), + }, + { + type: 'radio', + name: 'deleteall', + checked: false, + value: true, + label: Translate.instance.instant('addon.calendar.deleteallevents'), + }, + ]; + } else { + // Not repeated, display a simple confirm. + message = Translate.instance.instant('addon.calendar.confirmeventdelete', { $a: this.event.name }); + } + + let deleteAll = false; + try { + deleteAll = await CoreDomUtils.instance.showConfirm(message, title, undefined, undefined, options); + } catch { + + // User canceled. + return; + } + + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + try { + const sent = await AddonCalendar.instance.deleteEvent(this.event.id, this.event.name, deleteAll); + + if (sent) { + // Event deleted, invalidate right days & months. + try { + await AddonCalendarHelper.instance.refreshAfterChangeEvent(this.event, deleteAll ? this.event.eventcount : 1); + } catch { + // Ignore errors. + } + } + + // Trigger an event. + CoreEvents.trigger(AddonCalendarProvider.DELETED_EVENT_EVENT, { + eventId: this.eventId, + sent: sent, + }, CoreSites.instance.getCurrentSiteId()); + + if (sent) { + CoreDomUtils.instance.showToast('addon.calendar.eventcalendareventdeleted', true, 3000); + + // Event deleted, close the view. + /* if (!this.svComponent || !this.svComponent.isOn()) { + this.navCtrl.pop(); + }*/ + } else { + // Event deleted in offline, just mark it as deleted. + this.event.deleted = true; + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error deleting event.'); + } + + modal.dismiss(); + } + + /** + * Undo delete the event. + */ + async undoDelete(): Promise { + if (!this.event) { + return; + } + + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + try { + + await AddonCalendarOffline.instance.unmarkDeleted(this.event.id); + + // Trigger an event. + CoreEvents.trigger(AddonCalendarProvider.UNDELETED_EVENT_EVENT, { + eventId: this.eventId, + }, CoreSites.instance.getCurrentSiteId()); + + this.event.deleted = false; + + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error undeleting event.'); + } + + modal.dismiss(); + } + + /** + * Check the result of an automatic sync or a manual sync not done by this page. + * + * @param isManual Whether it's a manual sync. + * @param data Sync result. + */ + protected checkSyncResult(isManual: boolean, data: AddonCalendarSyncEvents): void { + if (!data) { + return; + } + + if (data.deleted && data.deleted.indexOf(this.eventId) != -1) { + CoreDomUtils.instance.showToast('addon.calendar.eventcalendareventdeleted', true, 3000); + + // Event was deleted, close the view. + /* if (!this.svComponent || !this.svComponent.isOn()) { + this.navCtrl.pop(); + }*/ + } else if (data.events && (!isManual || data.source != 'event')) { + const event = data.events.find((ev) => ev.id == this.eventId); + + if (event) { + this.eventLoaded = false; + this.refreshEvent(); + } + } + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.editEventObserver?.off(); + this.syncObserver?.off(); + this.manualSyncObserver?.off(); + this.onlineObserver?.unsubscribe(); + } + +} diff --git a/src/addons/calendar/pages/event/event.scss b/src/addons/calendar/pages/event/event.scss new file mode 100644 index 000000000..02d96ff3c --- /dev/null +++ b/src/addons/calendar/pages/event/event.scss @@ -0,0 +1,9 @@ +:host { + ion-card ion-note { + font-size: 1.6rem; + } + ion-title ion-icon, ion-title img { + margin-left: 10px; + margin-right: 10px; + } +} diff --git a/src/addons/calendar/pages/index/index.html b/src/addons/calendar/pages/index/index.html new file mode 100644 index 000000000..a81f512ee --- /dev/null +++ b/src/addons/calendar/pages/index/index.html @@ -0,0 +1,55 @@ + + + + + + {{ (showCalendar ? 'addon.calendar.calendarevents' : 'addon.calendar.upcomingevents') | translate }} + + + + + + + + + + + + + + + + + + + + + + + + {{ 'core.hasdatatosync' | translate:{$a: 'addon.calendar.calendar' | translate} }} + + + + + + + + + + + + + + + + diff --git a/src/addons/calendar/pages/index/index.module.ts b/src/addons/calendar/pages/index/index.module.ts new file mode 100644 index 000000000..86a385d70 --- /dev/null +++ b/src/addons/calendar/pages/index/index.module.ts @@ -0,0 +1,51 @@ +// (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 { NgModule } from '@angular/core'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CommonModule } from '@angular/common'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; +import { AddonCalendarComponentsModule } from '../../components/components.module'; + +import { AddonCalendarIndexPage } from './index.page'; + +const routes: Routes = [ + { + path: '', + component: AddonCalendarIndexPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + AddonCalendarComponentsModule, + ], + declarations: [ + AddonCalendarIndexPage, + ], + exports: [RouterModule], +}) +export class AddonCalendarIndexPageModule {} diff --git a/src/addons/calendar/pages/index/index.page.ts b/src/addons/calendar/pages/index/index.page.ts new file mode 100644 index 000000000..d0bdb8a19 --- /dev/null +++ b/src/addons/calendar/pages/index/index.page.ts @@ -0,0 +1,406 @@ +// (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, OnInit, OnDestroy, ViewChild } from '@angular/core'; +import { PopoverController, IonRefresher } from '@ionic/angular'; +import { CoreApp } from '@services/app'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreCoursesHelper } from '@features/courses/services/courses-helper'; +import { AddonCalendar, AddonCalendarProvider, AddonCalendarUpdatedEventEvent } from '../../services/calendar'; +import { AddonCalendarOffline } from '../../services/calendar-offline'; +import { AddonCalendarSync, AddonCalendarSyncEvents, AddonCalendarSyncProvider } from '../../services/calendar-sync'; +import { AddonCalendarFilter, AddonCalendarHelper } from '../../services/calendar-helper'; +import { Network, NgZone } from '@singletons'; +import { Subscription } from 'rxjs'; +import { CoreEnrolledCourseData } from '@features/courses/services/courses'; +import { ActivatedRoute, Params } from '@angular/router'; +import { AddonCalendarCalendarComponent } from '../../components/calendar/calendar'; +import { AddonCalendarUpcomingEventsComponent } from '../../components/upcoming-events/upcoming-events'; +import { AddonCalendarFilterPopoverComponent } from '../../components/filter/filter'; +import { CoreNavigator } from '@services/navigator'; +import { CoreLocalNotifications } from '@services/local-notifications'; + + +/** + * Page that displays the calendar events. + */ +@Component({ + selector: 'page-addon-calendar-index', + templateUrl: 'index.html', +}) +export class AddonCalendarIndexPage implements OnInit, OnDestroy { + + @ViewChild(AddonCalendarCalendarComponent) calendarComponent?: AddonCalendarCalendarComponent; + @ViewChild(AddonCalendarUpcomingEventsComponent) upcomingEventsComponent?: AddonCalendarUpcomingEventsComponent; + + protected eventId?: number; + protected currentSiteId: string; + + // Observers. + protected newEventObserver?: CoreEventObserver; + protected discardedObserver?: CoreEventObserver; + protected editEventObserver?: CoreEventObserver; + protected deleteEventObserver?: CoreEventObserver; + protected undeleteEventObserver?: CoreEventObserver; + protected syncObserver?: CoreEventObserver; + protected manualSyncObserver?: CoreEventObserver; + protected onlineObserver?: Subscription; + protected filterChangedObserver?: CoreEventObserver; + + year?: number; + month?: number; + canCreate = false; + courses: Partial[] = []; + notificationsEnabled = false; + loaded = false; + hasOffline = false; + isOnline = false; + syncIcon = 'spinner'; + showCalendar = true; + loadUpcoming = false; + filter: AddonCalendarFilter = { + filtered: false, + courseId: -1, + categoryId: undefined, + course: true, + group: true, + site: true, + user: true, + category: true, + }; + + constructor( + protected popoverCtrl: PopoverController, + protected route: ActivatedRoute, + ) { + this.currentSiteId = CoreSites.instance.getCurrentSiteId(); + + // Listen for events added. When an event is added, reload the data. + this.newEventObserver = CoreEvents.on( + AddonCalendarProvider.NEW_EVENT_EVENT, + (data: AddonCalendarUpdatedEventEvent) => { + if (data && data.eventId) { + this.loaded = false; + this.refreshData(true, false); + } + }, + this.currentSiteId, + ); + + // Listen for new event discarded event. When it does, reload the data. + this.discardedObserver = CoreEvents.on(AddonCalendarProvider.NEW_EVENT_DISCARDED_EVENT, () => { + this.loaded = false; + this.refreshData(true, false); + }, this.currentSiteId); + + // Listen for events edited. When an event is edited, reload the data. + this.editEventObserver = CoreEvents.on( + AddonCalendarProvider.EDIT_EVENT_EVENT, + (data: AddonCalendarUpdatedEventEvent) => { + if (data && data.eventId) { + this.loaded = false; + this.refreshData(true, false); + } + }, + this.currentSiteId, + ); + + // Refresh data if calendar events are synchronized automatically. + this.syncObserver = CoreEvents.on(AddonCalendarSyncProvider.AUTO_SYNCED, () => { + this.loaded = false; + this.refreshData(false, false); + }, this.currentSiteId); + + // Refresh data if calendar events are synchronized manually but not by this page. + this.manualSyncObserver = CoreEvents.on(AddonCalendarSyncProvider.MANUAL_SYNCED, (data: AddonCalendarSyncEvents) => { + if (data && data.source != 'index') { + this.loaded = false; + this.refreshData(false, false); + } + }, this.currentSiteId); + + // Update the events when an event is deleted. + this.deleteEventObserver = CoreEvents.on(AddonCalendarProvider.DELETED_EVENT_EVENT, () => { + this.loaded = false; + this.refreshData(false, false); + }, this.currentSiteId); + + // Update the "hasOffline" property if an event deleted in offline is restored. + this.undeleteEventObserver = CoreEvents.on(AddonCalendarProvider.UNDELETED_EVENT_EVENT, async () => { + this.hasOffline = await AddonCalendarOffline.instance.hasOfflineData(); + }, this.currentSiteId); + + this.filterChangedObserver = CoreEvents.on( + AddonCalendarProvider.FILTER_CHANGED_EVENT, + async (filterData: AddonCalendarFilter) => { + this.filter = filterData; + + // Course viewed has changed, check if the user can create events for this course calendar. + this.canCreate = await AddonCalendarHelper.instance.canEditEvents(this.filter['courseId']); + }, + ); + + // Refresh online status when changes. + this.onlineObserver = Network.instance.onChange().subscribe(() => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + NgZone.instance.run(() => { + this.isOnline = CoreApp.instance.isOnline(); + }); + }); + } + + /** + * View loaded. + */ + ngOnInit(): void { + this.notificationsEnabled = CoreLocalNotifications.instance.isAvailable(); + + this.route.queryParams.subscribe(params => { + this.eventId = parseInt(params['eventId'], 10) || undefined; + this.filter.courseId = parseInt(params['courseId'], 10) || -1; + this.year = parseInt(params['year'], 10) || undefined; + this.month = parseInt(params['month'], 10) || undefined; + this.loadUpcoming = !!params['upcoming']; + this.showCalendar = !this.loadUpcoming; + this.filter.filtered = this.filter.courseId > 0; + + if (this.eventId) { + // There is an event to load, open the event in a new state. + this.gotoEvent(this.eventId); + } + + this.fetchData(true, false); + }); + } + + /** + * Fetch all the data required for the view. + * + * @param sync Whether it should try to synchronize offline events. + * @param showErrors Whether to show sync errors to the user. + * @return Promise resolved when done. + */ + async fetchData(sync?: boolean, showErrors?: boolean): Promise { + + this.syncIcon = 'spinner'; + this.isOnline = CoreApp.instance.isOnline(); + + if (sync) { + // Try to synchronize offline events. + try { + const result = await AddonCalendarSync.instance.syncEvents(); + if (result.warnings && result.warnings.length) { + CoreDomUtils.instance.showErrorModal(result.warnings[0]); + } + + if (result.updated) { + // Trigger a manual sync event. + result.source = 'index'; + + CoreEvents.trigger( + AddonCalendarSyncProvider.MANUAL_SYNCED, + result, + this.currentSiteId, + ); + } + } catch (error) { + if (showErrors) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.errorsync', true); + } + } + } + + try { + const promises: Promise[] = []; + + this.hasOffline = false; + + // Load courses for the popover. + promises.push(CoreCoursesHelper.instance.getCoursesForPopover(this.filter.courseId).then((data) => { + this.courses = data.courses; + + return; + })); + + // Check if user can create events. + promises.push(AddonCalendarHelper.instance.canEditEvents(this.filter.courseId).then((canEdit) => { + this.canCreate = canEdit; + + return; + })); + + // Check if there is offline data. + promises.push(AddonCalendarOffline.instance.hasOfflineData().then((hasOffline) => { + this.hasOffline = hasOffline; + + return; + })); + + await Promise.all(promises); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); + } + + this.loaded = true; + this.syncIcon = 'fas-sync-alt'; + } + + /** + * Refresh the data. + * + * @param refresher Refresher. + * @param done Function to call when done. + * @param showErrors Whether to show sync errors to the user. + * @return Promise resolved when done. + */ + async doRefresh(refresher?: CustomEvent, done?: () => void, showErrors?: boolean): Promise { + if (!this.loaded) { + return; + } + + await this.refreshData(true, showErrors).finally(() => { + refresher?.detail.complete(); + done && done(); + }); + } + + /** + * Refresh the data. + * + * @param sync Whether it should try to synchronize offline events. + * @param showErrors Whether to show sync errors to the user. + * @param afterChange Whether the refresh is done after an event has changed or has been synced. + * @return Promise resolved when done. + */ + async refreshData(sync = false, showErrors = false): Promise { + this.syncIcon = 'spinner'; + + const promises: Promise[] = []; + + promises.push(AddonCalendar.instance.invalidateAllowedEventTypes()); + + // Refresh the sub-component. + if (this.showCalendar && this.calendarComponent) { + promises.push(this.calendarComponent.refreshData()); + } else if (!this.showCalendar && this.upcomingEventsComponent) { + promises.push(this.upcomingEventsComponent.refreshData()); + } + + await Promise.all(promises).finally(() => this.fetchData(sync, showErrors)); + } + + /** + * Navigate to a particular event. + * + * @param eventId Event to load. + */ + gotoEvent(eventId: number): void { + if (eventId < 0) { + // It's an offline event, go to the edit page. + this.openEdit(eventId); + } else { + CoreNavigator.instance.navigateToSitePath('/calendar/event', { params: { id: eventId } }); + } + } + + /** + * View a certain day. + * + * @param data Data with the year, month and day. + */ + gotoDay(data: {day: number; month: number; year: number}): void { + const params: Params = { + day: data.day, + month: data.month, + year: data.year, + }; + + Object.keys(this.filter).forEach((key) => { + params[key] = this.filter[key]; + }); + + CoreNavigator.instance.navigateToSitePath('/calendar/day', { params }); + } + + /** + * Show the context menu. + * + * @param event Event. + */ + async openFilter(event: MouseEvent): Promise { + const popover = await this.popoverCtrl.create({ + component: AddonCalendarFilterPopoverComponent, + componentProps: { + courses: this.courses, + filter: this.filter, + }, + event, + }); + await popover.present(); + } + + /** + * Open page to create/edit an event. + * + * @param eventId Event ID to edit. + */ + openEdit(eventId?: number): void { + const params: Params = {}; + + if (eventId) { + params.eventId = eventId; + } + if (this.filter.courseId) { + params.courseId = this.filter.courseId; + } + + CoreNavigator.instance.navigateToSitePath('/calendar/edit', { params }); + } + + /** + * Open calendar events settings. + */ + openSettings(): void { + CoreNavigator.instance.navigateToSitePath('/calendar/settings'); + } + + /** + * Toogle display: monthly view or upcoming events. + */ + toggleDisplay(): void { + this.showCalendar = !this.showCalendar; + + if (!this.showCalendar) { + this.loadUpcoming = true; + } + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.newEventObserver?.off(); + this.discardedObserver?.off(); + this.editEventObserver?.off(); + this.deleteEventObserver?.off(); + this.undeleteEventObserver?.off(); + this.syncObserver?.off(); + this.manualSyncObserver?.off(); + this.filterChangedObserver?.off(); + this.onlineObserver?.unsubscribe(); + } + +} diff --git a/src/addons/calendar/pages/list/list.html b/src/addons/calendar/pages/list/list.html new file mode 100644 index 000000000..1c11b3950 --- /dev/null +++ b/src/addons/calendar/pages/list/list.html @@ -0,0 +1,90 @@ + + + + + + {{ 'addon.calendar.calendarevents' | translate }} + + + + + + + + + + + + + + + + + + + + + + + {{ 'core.hasdatatosync' | translate:{$a: 'addon.calendar.calendar' | translate} }} + + + + + + + + + + {{ event.timestart * 1000 | coreFormatDate: "strftimedayshort" }} + + + + + + +

+ + +

+

+ {{ event.timestart * 1000 | coreFormatDate: "strftimetime" }} + + - {{ (event.timestart + event.timeduration) * 1000 | coreFormatDate: "strftimetime" }} + + + - {{ (event.timestart + event.timeduration) * 1000 | coreFormatDate: "strftimedatetimeshort" }} + +

+
+ + + {{ 'core.notsent' | translate }} + + + + {{ 'core.deletedoffline' | translate }} + +
+
+
+ + + +
+ + + + + + + +
+ diff --git a/src/addons/calendar/pages/list/list.module.ts b/src/addons/calendar/pages/list/list.module.ts new file mode 100644 index 000000000..ebbdeb4c8 --- /dev/null +++ b/src/addons/calendar/pages/list/list.module.ts @@ -0,0 +1,49 @@ +// (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 { NgModule } from '@angular/core'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CommonModule } from '@angular/common'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; + +import { AddonCalendarListPage } from './list.page'; + +const routes: Routes = [ + { + path: '', + component: AddonCalendarListPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + ], + declarations: [ + AddonCalendarListPage, + ], + exports: [RouterModule], +}) +export class AddonCalendarListPageModule {} diff --git a/src/addons/calendar/pages/list/list.page.ts b/src/addons/calendar/pages/list/list.page.ts new file mode 100644 index 000000000..5b99ee493 --- /dev/null +++ b/src/addons/calendar/pages/list/list.page.ts @@ -0,0 +1,704 @@ +// (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, ViewChild, OnDestroy, OnInit } from '@angular/core'; +import { PopoverController, IonContent, IonRefresher } from '@ionic/angular'; +import { + AddonCalendarProvider, + AddonCalendar, + AddonCalendarEventToDisplay, + AddonCalendarUpdatedEventEvent, +} from '../../services/calendar'; +import { AddonCalendarOffline } from '../../services/calendar-offline'; +import { AddonCalendarFilter, AddonCalendarHelper } from '../../services/calendar-helper'; +import { AddonCalendarSync, AddonCalendarSyncEvents, AddonCalendarSyncProvider } from '../../services/calendar-sync'; +import { CoreCategoryData, CoreCourses, CoreEnrolledCourseData } from '@features/courses/services/courses'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreSites } from '@services/sites'; +import { CoreLocalNotifications } from '@services/local-notifications'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreApp } from '@services/app'; +// @todo import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import moment from 'moment'; +import { CoreConstants } from '@/core/constants'; +import { AddonCalendarFilterPopoverComponent } from '../../components/filter/filter'; +import { ActivatedRoute, Params } from '@angular/router'; +import { Subscription } from 'rxjs'; +import { Network, NgZone } from '@singletons'; +import { CoreCoursesHelper } from '@features/courses/services/courses-helper'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreNavigator } from '@services/navigator'; + +/** + * Page that displays the list of calendar events. + */ +@Component({ + selector: 'page-addon-calendar-list', + templateUrl: 'list.html', + styleUrls: ['../../calendar-common.scss', 'list.scss'], +}) +export class AddonCalendarListPage implements OnInit, OnDestroy { + + @ViewChild(IonContent) content?: IonContent; + // @ViewChild(CoreSplitViewComponent) splitviewCtrl: CoreSplitViewComponent; + + protected initialTime = 0; + protected daysLoaded = 0; + protected emptyEventsTimes = 0; // Variable to identify consecutive calls returning 0 events. + protected categoriesRetrieved = false; + protected getCategories = false; + protected categories: { [id: number]: CoreCategoryData } = {}; + protected siteHomeId: number; + protected currentSiteId: string; + protected onlineEvents: AddonCalendarEventToDisplay[] = []; + protected offlineEvents: AddonCalendarEventToDisplay[] = []; + protected deletedEvents: number [] = []; + + // Observers. + protected obsDefaultTimeChange?: CoreEventObserver; + protected newEventObserver: CoreEventObserver; + protected discardedObserver: CoreEventObserver; + protected editEventObserver: CoreEventObserver; + protected deleteEventObserver: CoreEventObserver; + protected undeleteEventObserver: CoreEventObserver; + protected syncObserver: CoreEventObserver; + protected manualSyncObserver: CoreEventObserver; + protected filterChangedObserver: CoreEventObserver; + protected onlineObserver: Subscription; + + eventId?: number; // Selected EventId on list + courses: Partial[] = []; + eventsLoaded = false; + events: AddonCalendarEventToDisplay[] = []; // Events (both online and offline). + notificationsEnabled = false; + filteredEvents: AddonCalendarEventToDisplay[] = []; + canLoadMore = false; + loadMoreError = false; + canCreate = false; + hasOffline = false; + isOnline = false; + syncIcon = 'spinner'; + filter: AddonCalendarFilter = { + filtered: false, + courseId: -1, + categoryId: undefined, + course: true, + group: true, + site: true, + user: true, + category: true, + }; + + constructor( + protected route: ActivatedRoute, + private popoverCtrl: PopoverController, + ) { + + this.siteHomeId = CoreSites.instance.getCurrentSiteHomeId(); + this.notificationsEnabled = CoreLocalNotifications.instance.isAvailable(); + this.currentSiteId = CoreSites.instance.getCurrentSiteId(); + + if (this.notificationsEnabled) { + // Re-schedule events if default time changes. + this.obsDefaultTimeChange = CoreEvents.on(AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_CHANGED, () => { + AddonCalendar.instance.scheduleEventsNotifications(this.onlineEvents); + }, this.currentSiteId); + } + + // Listen for events added. When an event is added, reload the data. + this.newEventObserver = CoreEvents.on(AddonCalendarProvider.NEW_EVENT_EVENT, (data: AddonCalendarUpdatedEventEvent) => { + if (data && data.eventId) { + /* if (this.splitviewCtrl.isOn()) { + // Discussion added, clear details page. + this.splitviewCtrl.emptyDetails(); + }*/ + + this.eventsLoaded = false; + this.refreshEvents(true, false).finally(() => { + + // In tablet mode try to open the event (only if it's an online event). + /* if (this.splitviewCtrl.isOn() && data.event.id > 0) { + this.gotoEvent(data.event.id); + }*/ + }); + } + }, this.currentSiteId); + + // Listen for new event discarded event. When it does, reload the data. + this.discardedObserver = CoreEvents.on(AddonCalendarProvider.NEW_EVENT_DISCARDED_EVENT, () => { + /* if (this.splitviewCtrl.isOn()) { + // Discussion added, clear details page. + this.splitviewCtrl.emptyDetails(); + }*/ + + this.eventsLoaded = false; + this.refreshEvents(true, false); + }, this.currentSiteId); + + // Listen for events edited. When an event is edited, reload the data. + this.editEventObserver = CoreEvents.on(AddonCalendarProvider.EDIT_EVENT_EVENT, (data: AddonCalendarUpdatedEventEvent) => { + if (data && data.eventId) { + this.eventsLoaded = false; + this.refreshEvents(true, false); + } + }, this.currentSiteId); + + // Refresh data if calendar events are synchronized automatically. + this.syncObserver = CoreEvents.on(AddonCalendarSyncProvider.AUTO_SYNCED, () => { + this.eventsLoaded = false; + this.refreshEvents(); + + /* if (this.splitviewCtrl.isOn() && this.eventId && data && data.deleted && data.deleted.indexOf(this.eventId) != -1) { + // Current selected event was deleted. Clear details. + this.splitviewCtrl.emptyDetails(); + } */ + }, this.currentSiteId); + + // Refresh data if calendar events are synchronized manually but not by this page. + this.manualSyncObserver = CoreEvents.on(AddonCalendarSyncProvider.MANUAL_SYNCED, (data: AddonCalendarSyncEvents) => { + if (data && data.source != 'list') { + this.eventsLoaded = false; + this.refreshEvents(); + } + + /* if (this.splitviewCtrl.isOn() && this.eventId && data && data.deleted && data.deleted.indexOf(this.eventId) != -1) { + // Current selected event was deleted. Clear details. + this.splitviewCtrl.emptyDetails(); + }*/ + }, this.currentSiteId); + + // Update the list when an event is deleted. + this.deleteEventObserver = CoreEvents.on( + AddonCalendarProvider.DELETED_EVENT_EVENT, + (data: AddonCalendarUpdatedEventEvent) => { + if (data && !data.sent) { + // Event was deleted in offline. Just mark it as deleted, no need to refresh. + this.markAsDeleted(data.eventId, true); + this.deletedEvents.push(data.eventId); + this.hasOffline = true; + } else { + // Event deleted, clear the details if needed and refresh the view. + /* if (this.splitviewCtrl.isOn()) { + this.splitviewCtrl.emptyDetails(); + } */ + + this.eventsLoaded = false; + this.refreshEvents(); + } + }, + this.currentSiteId, + ); + + // Listen for events "undeleted" (offline). + this.undeleteEventObserver = CoreEvents.on( + AddonCalendarProvider.UNDELETED_EVENT_EVENT, + (data: AddonCalendarUpdatedEventEvent) => { + if (!data || !data.eventId) { + return; + } + + // Mark it as undeleted, no need to refresh. + this.markAsDeleted(data.eventId, false); + + // Remove it from the list of deleted events if it's there. + const index = this.deletedEvents.indexOf(data.eventId); + if (index != -1) { + this.deletedEvents.splice(index, 1); + } + + this.hasOffline = !!this.offlineEvents.length || !!this.deletedEvents.length; + }, + this.currentSiteId, + ); + + this.filterChangedObserver = + CoreEvents.on(AddonCalendarProvider.FILTER_CHANGED_EVENT, async (data: AddonCalendarFilter) => { + this.filter = data; + + // Course viewed has changed, check if the user can create events for this course calendar. + this.canCreate = await AddonCalendarHelper.instance.canEditEvents(this.filter.courseId); + + this.filterEvents(); + + this.content?.scrollToTop(); + }); + + // Refresh online status when changes. + this.onlineObserver = Network.instance.onChange().subscribe(() => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + NgZone.instance.run(() => { + this.isOnline = CoreApp.instance.isOnline(); + }); + }); + } + + /** + * View loaded. + */ + async ngOnInit(): Promise { + this.eventId = this.route.snapshot.queryParams['eventId'] || undefined; + this.filter.courseId = this.route.snapshot.queryParams['courseId']; + + if (this.eventId) { + // There is an event to load, open the event in a new state. + this.gotoEvent(this.eventId); + } + + this.syncIcon = 'spinner'; + + await this.fetchData(false, true, false); + + /* if (!this.eventId && this.splitviewCtrl.isOn() && this.events.length > 0) { + // Take first online event and load it. If no online event, load the first offline. + if (this.onlineEvents[0]) { + this.gotoEvent(this.onlineEvents[0].id); + } else { + this.gotoEvent(this.offlineEvents[0].id); + } + }*/ + } + + /** + * Fetch all the data required for the view. + * + * @param refresh Empty events array first. + * @param sync Whether it should try to synchronize offline events. + * @param showErrors Whether to show sync errors to the user. + * @return Promise resolved when done. + */ + async fetchData(refresh = false, sync = false, showErrors = false): Promise { + this.initialTime = CoreTimeUtils.instance.timestamp(); + this.daysLoaded = 0; + this.emptyEventsTimes = 0; + this.isOnline = CoreApp.instance.isOnline(); + + if (sync) { + // Try to synchronize offline events. + try { + const result = await AddonCalendarSync.instance.syncEvents(); + if (result.warnings && result.warnings.length) { + CoreDomUtils.instance.showErrorModal(result.warnings[0]); + } + + if (result.updated) { + // Trigger a manual sync event. + result.source = 'list'; + + CoreEvents.trigger( + AddonCalendarSyncProvider.MANUAL_SYNCED, + result, + this.currentSiteId, + ); + } + } catch (error) { + if (showErrors) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.errorsync', true); + } + } + } + + try { + const promises: Promise[] = []; + + this.hasOffline = false; + + promises.push(AddonCalendarHelper.instance.canEditEvents(this.filter.courseId).then((canEdit) => { + this.canCreate = canEdit; + + return; + })); + + // Load courses for the popover. + promises.push(CoreCoursesHelper.instance.getCoursesForPopover(this.filter.courseId).then((result) => { + this.courses = result.courses; + + return this.fetchEvents(refresh); + })); + + // Get offline events. + promises.push(AddonCalendarOffline.instance.getAllEditedEvents().then((offlineEvents) => { + this.hasOffline = this.hasOffline || !!offlineEvents.length; + + // Format data and sort by timestart. + const events: AddonCalendarEventToDisplay[] = offlineEvents.map((event) => + AddonCalendarHelper.instance.formatOfflineEventData(event)); + + this.offlineEvents = AddonCalendarHelper.instance.sortEvents(events); + + return; + })); + + // Get events deleted in offline. + promises.push(AddonCalendarOffline.instance.getAllDeletedEventsIds().then((ids) => { + this.hasOffline = this.hasOffline || !!ids.length; + this.deletedEvents = ids; + + return; + })); + + await Promise.all(promises); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); + } + + this.eventsLoaded = true; + this.syncIcon = 'fas-sync-alt'; + } + + /** + * Fetches the events and updates the view. + * + * @param refresh Empty events array first. + * @return Promise resolved when done. + */ + async fetchEvents(refresh = false): Promise { + this.loadMoreError = false; + + try { + const onlineEventsTemp = + await AddonCalendar.instance.getEventsList(this.initialTime, this.daysLoaded, AddonCalendarProvider.DAYS_INTERVAL); + + if (onlineEventsTemp.length === 0) { + this.emptyEventsTimes++; + if (this.emptyEventsTimes > 5) { // Stop execution if we retrieve empty list 6 consecutive times. + this.canLoadMore = false; + if (refresh) { + this.onlineEvents = []; + this.filteredEvents = []; + this.events = this.offlineEvents; + } + } else { + // No events returned, load next events. + this.daysLoaded += AddonCalendarProvider.DAYS_INTERVAL; + + return this.fetchEvents(); + } + } else { + const onlineEvents = onlineEventsTemp.map((event) => AddonCalendarHelper.instance.formatEventData(event)); + + // Get the merged events of this period. + const events = this.mergeEvents(onlineEvents); + + this.getCategories = this.shouldLoadCategories(onlineEvents); + + if (refresh) { + this.onlineEvents = onlineEvents; + this.events = events; + } else { + // Filter events with same ID. Repeated events are returned once per WS call, show them only once. + this.onlineEvents = CoreUtils.instance.mergeArraysWithoutDuplicates(this.onlineEvents, onlineEvents, 'id'); + this.events = CoreUtils.instance.mergeArraysWithoutDuplicates(this.events, events, 'id'); + } + this.filterEvents(); + + // Calculate which evemts need to display the date. + this.filteredEvents.forEach((event, index) => { + event.showDate = this.showDate(event, this.filteredEvents[index - 1]); + event.endsSameDay = this.endsSameDay(event); + }); + this.canLoadMore = true; + + // Schedule notifications for the events retrieved (might have new events). + AddonCalendar.instance.scheduleEventsNotifications(this.onlineEvents); + + this.daysLoaded += AddonCalendarProvider.DAYS_INTERVAL; + } + + // Resize the content so infinite loading is able to calculate if it should load more items or not. + // @todo: Infinite loading is not working if content is not high enough. + // this.content.resize(); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); + this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading. + } + + // Success retrieving events. Get categories if needed. + if (this.getCategories) { + this.getCategories = false; + + return this.loadCategories(); + } + } + + /** + * Function to load more events. + * + * @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading. + * @return Resolved when done. + */ + loadMoreEvents(infiniteComplete?: () => void ): void { + this.fetchEvents().finally(() => { + infiniteComplete && infiniteComplete(); + }); + } + + protected filterEvents(): void { + this.filteredEvents = AddonCalendarHelper.instance.getFilteredEvents(this.events, this.filter, this.categories); + } + + /** + * Returns if the current state should load categories or not. + * + * @param events Events to parse. + * @return True if categories should be loaded. + */ + protected shouldLoadCategories(events: AddonCalendarEventToDisplay[]): boolean { + if (this.categoriesRetrieved || this.getCategories) { + // Use previous value + return this.getCategories; + } + + // Categories not loaded yet. We should get them if there's any category event. + const found = events.some((event) => typeof event.categoryid != 'undefined' && event.categoryid > 0); + + return found || this.getCategories; + } + + /** + * Load categories to be able to filter events. + * + * @return Promise resolved when done. + */ + protected async loadCategories(): Promise { + try { + const cats = await CoreCourses.instance.getCategories(0, true); + this.categoriesRetrieved = true; + this.categories = {}; + // Index categories by ID. + cats.forEach((category) => { + this.categories[category.id] = category; + }); + } catch { + // Ignore errors. + } + } + + /** + * Merge a period of online events with the offline events of that period. + * + * @param onlineEvents Online events. + * @return Merged events. + */ + protected mergeEvents(onlineEvents: AddonCalendarEventToDisplay[]): AddonCalendarEventToDisplay[] { + if (!this.offlineEvents.length && !this.deletedEvents.length) { + // No offline events, nothing to merge. + return onlineEvents; + } + + const start = this.initialTime + (CoreConstants.SECONDS_DAY * this.daysLoaded); + const end = start + (CoreConstants.SECONDS_DAY * AddonCalendarProvider.DAYS_INTERVAL) - 1; + let result = onlineEvents; + + if (this.deletedEvents.length) { + // Mark as deleted the events that were deleted in offline. + result.forEach((event) => { + event.deleted = this.deletedEvents.indexOf(event.id) != -1; + }); + } + + if (this.offlineEvents.length) { + // Remove the online events that were modified in offline. + result = result.filter((event) => { + const offlineEvent = this.offlineEvents.find((ev) => ev.id == event.id); + + return !offlineEvent; + }); + } + + // Now get the offline events that belong to this period. + const periodOfflineEvents = this.offlineEvents.filter((event) => { + if (this.daysLoaded == 0 && event.timestart < start) { + // Display offline events that are previous to current time to allow editing them. + return true; + } + + return (event.timestart >= start || event.timestart + event.timeduration >= start) && event.timestart <= end; + }); + + // Merge both arrays and sort them. + result = result.concat(periodOfflineEvents); + + return AddonCalendarHelper.instance.sortEvents(result); + } + + /** + * Refresh the data. + * + * @param refresher Refresher. + * @param done Function to call when done. + * @param showErrors Whether to show sync errors to the user. + * @return Promise resolved when done. + */ + async doRefresh(refresher?: CustomEvent, done?: () => void, showErrors?: boolean): Promise { + if (!this.eventsLoaded) { + return; + } + + await this.refreshEvents(true, showErrors).finally(() => { + refresher?.detail.complete(); + done && done(); + }); + } + + /** + * Refresh the events. + * + * @param sync Whether it should try to synchronize offline events. + * @param showErrors Whether to show sync errors to the user. + * @return Promise resolved when done. + */ + async refreshEvents(sync?: boolean, showErrors?: boolean): Promise { + this.syncIcon = 'spinner'; + + const promises: Promise[] = []; + + promises.push(AddonCalendar.instance.invalidateEventsList()); + promises.push(AddonCalendar.instance.invalidateAllowedEventTypes()); + + if (this.categoriesRetrieved) { + promises.push(CoreCourses.instance.invalidateCategories(0, true)); + this.categoriesRetrieved = false; + } + + await Promise.all(promises).finally(() => this.fetchData(true, sync, showErrors)); + } + + /** + * Check date should be shown on event list for the current event. + * If date has changed from previous to current event it should be shown. + * + * @param event Current event where to show the date. + * @param prevEvent Previous event where to compare the date with. + * @return If date has changed and should be shown. + */ + protected showDate(event: AddonCalendarEventToDisplay, prevEvent?: AddonCalendarEventToDisplay): boolean { + if (!prevEvent) { + // First event, show it. + return true; + } + + // Check if day has changed. + return !moment(event.timestart * 1000).isSame(prevEvent.timestart * 1000, 'day'); + } + + /** + * Check if event ends the same date or not. + * + * @param event Event info. + * @return If date has changed and should be shown. + */ + protected endsSameDay(event: AddonCalendarEventToDisplay): boolean { + if (!event.timeduration) { + // No duration. + return true; + } + + // Check if day has changed. + return moment(event.timestart * 1000).isSame((event.timestart + event.timeduration) * 1000, 'day'); + } + + /** + * Show the context menu. + * + * @param event Event. + */ + async openFilter(event: MouseEvent): Promise { + const popover = await this.popoverCtrl.create({ + component: AddonCalendarFilterPopoverComponent, + componentProps: { + courses: this.courses, + filter: this.filter, + }, + event, + }); + await popover.present(); + } + + /** + * Open page to create/edit an event. + * + * @param eventId Event ID to edit. + */ + openEdit(eventId?: number): void { + this.eventId = undefined; + + const params: Params = {}; + + if (eventId) { + params.eventId = eventId; + } + if (this.filter.courseId) { + params.courseId = this.filter.courseId; + } + + CoreNavigator.instance.navigateToSitePath('/calendar/edit', { params }); // @todo , this.splitviewCtrl); + } + + /** + * Open calendar events settings. + */ + openSettings(): void { + CoreNavigator.instance.navigateToSitePath('/calendar/settings'); + } + + /** + * Navigate to a particular event. + * + * @param eventId Event to load. + */ + gotoEvent(eventId: number): void { + this.eventId = eventId; + + if (eventId < 0) { + // It's an offline event, go to the edit page. + this.openEdit(eventId); + } else { + /* this.splitviewCtrl.push('/calendar/event', { + id: eventId, + });*/ + } + } + + /** + * Find an event and mark it as deleted. + * + * @param eventId Event ID. + * @param deleted Whether to mark it as deleted or not. + */ + protected markAsDeleted(eventId: number, deleted: boolean): void { + const event = this.onlineEvents.find((event) => event.id == eventId); + + if (event) { + event.deleted = deleted; + } + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.obsDefaultTimeChange?.off(); + this.newEventObserver?.off(); + this.discardedObserver?.off(); + this.editEventObserver?.off(); + this.deleteEventObserver?.off(); + this.undeleteEventObserver?.off(); + this.syncObserver?.off(); + this.manualSyncObserver?.off(); + this.filterChangedObserver?.off(); + this.onlineObserver?.unsubscribe(); + } + +} diff --git a/src/addons/calendar/pages/list/list.scss b/src/addons/calendar/pages/list/list.scss new file mode 100644 index 000000000..28505ddcb --- /dev/null +++ b/src/addons/calendar/pages/list/list.scss @@ -0,0 +1,5 @@ +:host { + ion-note { + max-width: 30%; + } +} diff --git a/src/addons/calendar/pages/settings/settings.html b/src/addons/calendar/pages/settings/settings.html new file mode 100644 index 000000000..b77d0cc09 --- /dev/null +++ b/src/addons/calendar/pages/settings/settings.html @@ -0,0 +1,25 @@ + + + + + + {{ 'core.settings.settings' | translate }} + + + + + + {{ 'addon.calendar.defaultnotificationtime' | translate }} + + {{ 'core.settings.disabled' | translate }} + {{ 600 | coreDuration }} + {{ 1800 | coreDuration }} + {{ 3600 | coreDuration }} + {{ 7200 | coreDuration }} + {{ 21600 | coreDuration }} + {{ 43200 | coreDuration }} + {{ 86400 | coreDuration }} + + + + diff --git a/src/addons/calendar/pages/settings/settings.module.ts b/src/addons/calendar/pages/settings/settings.module.ts new file mode 100644 index 000000000..019570fdf --- /dev/null +++ b/src/addons/calendar/pages/settings/settings.module.ts @@ -0,0 +1,50 @@ +// (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 { NgModule } from '@angular/core'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; + +import { AddonCalendarSettingsPage } from './settings'; + +const routes: Routes = [ + { + path: '', + component: AddonCalendarSettingsPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + FormsModule, + TranslateModule.forChild(), + CoreDirectivesModule, + CorePipesModule, + ], + declarations: [ + AddonCalendarSettingsPage, + ], + exports: [RouterModule], +}) +export class AddonCalendarSettingsPageModule {} diff --git a/src/addons/calendar/pages/settings/settings.ts b/src/addons/calendar/pages/settings/settings.ts new file mode 100644 index 000000000..11e6057ed --- /dev/null +++ b/src/addons/calendar/pages/settings/settings.ts @@ -0,0 +1,53 @@ +// (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, OnInit } from '@angular/core'; +import { AddonCalendar, AddonCalendarProvider } from '../../services/calendar'; +import { CoreEvents } from '@singletons/events'; +import { CoreSites } from '@services/sites'; + +/** + * Page that displays the calendar settings. + */ +@Component({ + selector: 'page-addon-calendar-settings', + templateUrl: 'settings.html', +}) +export class AddonCalendarSettingsPage implements OnInit { + + defaultTime = 0; + + /** + * View loaded. + */ + async ngOnInit(): Promise { + this.defaultTime = await AddonCalendar.instance.getDefaultNotificationTime(); + } + + /** + * Update default time. + * + * @param newTime New time. + */ + updateDefaultTime(newTime: number): void { + AddonCalendar.instance.setDefaultNotificationTime(newTime); + + CoreEvents.trigger( + AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_CHANGED, + { time: newTime }, + CoreSites.instance.getCurrentSiteId(), + ); + } + +} diff --git a/src/addons/calendar/services/calendar-helper.ts b/src/addons/calendar/services/calendar-helper.ts new file mode 100644 index 000000000..e276b98f3 --- /dev/null +++ b/src/addons/calendar/services/calendar-helper.ts @@ -0,0 +1,736 @@ +// (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 { CoreSites } from '@services/sites'; +import { + AddonCalendar, + AddonCalendarDayName, + AddonCalendarEvent, + AddonCalendarEventBase, + AddonCalendarEventToDisplay, + AddonCalendarEventType, + AddonCalendarGetEventsEvent, + AddonCalendarProvider, + AddonCalendarWeek, + AddonCalendarWeekDay, +} from './calendar'; +import { CoreConfig } from '@services/config'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreCourse } from '@features/course/services/course'; +import { ContextLevel, CoreConstants } from '@/core/constants'; +import moment from 'moment'; +import { makeSingleton } from '@singletons'; +import { AddonCalendarSyncInvalidateEvent } from './calendar-sync'; +import { AddonCalendarOfflineEventDBRecord } from './database/calendar-offline'; +import { CoreCategoryData } from '@features/courses/services/courses'; + +/** + * Context levels enumeration. + */ +export enum AddonCalendarEventIcons { + SITE = 'fas-globe', + CATEGORY = 'fas-cubes', + COURSE = 'fas-graduation-cap', + GROUP = 'fas-users', + USER = 'fas-user', +} + +/** + * Service that provides some features regarding lists of courses and categories. + */ +@Injectable({ providedIn: 'root' }) +export class AddonCalendarHelperProvider { + + protected eventTypeIcons: string[] = []; + + + /** + * Returns event icon based on event type. + * + * @param eventType Type of the event. + * @return Event icon. + */ + getEventIcon(eventType: AddonCalendarEventType): string { + if (this.eventTypeIcons.length == 0) { + CoreUtils.instance.enumKeys(AddonCalendarEventType).forEach((name) => { + const value = AddonCalendarEventType[name]; + this.eventTypeIcons[value] = AddonCalendarEventIcons[name]; + }); + } + + return this.eventTypeIcons[eventType] || ''; + } + + /** + * Calculate some day data based on a list of events for that day. + * + * @param day Day. + * @param events Events. + */ + calculateDayData(day: AddonCalendarWeekDay, events: AddonCalendarEventToDisplay[]): void { + day.hasevents = events.length > 0; + day.haslastdayofevent = false; + + const types = {}; + events.forEach((event) => { + types[event.formattedType || event.eventtype] = true; + + if (event.islastday) { + day.haslastdayofevent = true; + } + }); + + day.calendareventtypes = Object.keys(types) as AddonCalendarEventType[]; + } + + /** + * Check if current user can create/edit events. + * + * @param courseId Course ID. If not defined, site calendar. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether the user can create events. + */ + async canEditEvents(courseId?: number, siteId?: string): Promise { + try { + const canEdit = await AddonCalendar.instance.canEditEvents(siteId); + if (!canEdit) { + return false; + } + + const types = await AddonCalendar.instance.getAllowedEventTypes(courseId, siteId); + + return Object.keys(types).length > 0; + } catch { + return false; + } + } + + /** + * 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. + * @return Object with the classified events. + */ + classifyIntoMonths( + offlineEvents: AddonCalendarOfflineEventDBRecord[], + ): { [monthId: string]: { [day: number]: AddonCalendarEventToDisplay[] } } { + // Format data. + const events: AddonCalendarEventToDisplay[] = offlineEvents.map((event) => + AddonCalendarHelper.instance.formatOfflineEventData(event)); + + const result = {}; + + events.forEach((event) => { + const treatedDay = moment(new Date(event.timestart * 1000)); + const endDay = moment(new Date((event.timestart + event.timeduration) * 1000)); + + // Add the event to all the days it lasts. + while (!treatedDay.isAfter(endDay, 'day')) { + const monthId = this.getMonthId(treatedDay.year(), treatedDay.month() + 1); + const day = treatedDay.date(); + + if (!result[monthId]) { + result[monthId] = {}; + } + if (!result[monthId][day]) { + result[monthId][day] = []; + } + result[monthId][day].push(event); + + treatedDay.add(1, 'day'); // Treat next day. + } + }); + + return result; + } + + /** + * Convenience function to format some event data to be rendered. + * + * @param event Event to format. + */ + formatEventData(event: AddonCalendarEvent | AddonCalendarEventBase | AddonCalendarGetEventsEvent): AddonCalendarEventToDisplay { + + const eventFormatted: AddonCalendarEventToDisplay = { + id: event.id!, + name: event.name, + eventtype: event.eventtype, + categoryid: event.categoryid, + groupid: event.groupid, + description: event.description, + location: 'location' in event? event.location : undefined, + timestart: event.timestart, + timeduration: event.timeduration, + eventcount: 'eventcount' in event? event.eventcount || 0 : 0, + repeatid: event.repeatid || 0, + // repeateditall: event.repeateditall, + userid: event.userid, + timemodified: event.timemodified, + eventIcon: this.getEventIcon(event.eventtype), + formattedType: AddonCalendar.instance.getEventType(event), + modulename: event.modulename, + format: 1, + visible: 1, + offline: false, + }; + + if (event.modulename) { + eventFormatted.eventIcon = CoreCourse.instance.getModuleIconSrc(event.modulename); + eventFormatted.moduleIcon = eventFormatted.eventIcon; + } + + eventFormatted.formattedType = AddonCalendar.instance.getEventType(event); + + // Calculate context. + if ('course' in event) { + eventFormatted.courseid = event.course?.id; + } else if ('courseid' in event) { + eventFormatted.courseid = event.courseid; + } + + // Calculate context. + if ('category' in event) { + eventFormatted.categoryid = event.category?.id; + } else if ('categoryid' in event) { + eventFormatted.categoryid = event.categoryid; + } + + if ('canedit' in event) { + eventFormatted.canedit = event.canedit; + } + + if ('candelete' in event) { + eventFormatted.candelete = event.candelete; + } + + this.formatEventContext(eventFormatted, eventFormatted.courseid, eventFormatted.categoryid); + + return eventFormatted; + } + + /** + * Convenience function to format some event data to be rendered. + * + * @param e Event to format. + */ + formatOfflineEventData(event: AddonCalendarOfflineEventDBRecord): AddonCalendarEventToDisplay { + + const eventFormatted: AddonCalendarEventToDisplay = { + id: event.id!, + name: event.name, + timestart: event.timestart, + eventtype: event.eventtype, + categoryid: event.categoryid, + courseid: event.courseid || event.groupcourseid, + groupid: event.groupid, + description: event.description, + location: event.location, + duration: event.duration, + timedurationuntil: event.timedurationuntil, + timedurationminutes: event.timedurationminutes, + // repeat: event.repeat, + eventcount: event.repeats || 0, + repeatid: event.repeatid || 0, + // repeateditall: event.repeateditall, + userid: event.userid, + timemodified: event.timecreated || 0, + eventIcon: this.getEventIcon(event.eventtype), + formattedType: event.eventtype, + format: 1, + visible: 1, + offline: true, + timeduration: 0, + }; + + // Calculate context. + const categoryId = event.categoryid; + const courseId = event.courseid || event.groupcourseid; + this.formatEventContext(eventFormatted, courseId, categoryId); + + if (eventFormatted.duration == 1) { + eventFormatted.timeduration = (event.timedurationuntil || 0) - event.timestart; + } else if (eventFormatted.duration == 2) { + eventFormatted.timeduration = (event.timedurationminutes || 0) * CoreConstants.SECONDS_MINUTE; + } else { + eventFormatted.timeduration = 0; + } + + return eventFormatted; + } + + /** + * Modifies event data with the context information. + * + * @param eventFormatted Event formatted to be displayed. + * @param courseId Course Id if any. + * @param categoryId Category Id if any. + */ + protected formatEventContext(eventFormatted: AddonCalendarEventToDisplay, courseId?: number, categoryId?: number): void { + if (categoryId && categoryId > 0) { + eventFormatted.contextLevel = ContextLevel.COURSECAT; + eventFormatted.contextInstanceId = categoryId; + } else if (courseId && courseId > 0) { + eventFormatted.contextLevel = ContextLevel.COURSE; + eventFormatted.contextInstanceId = courseId; + } else { + eventFormatted.contextLevel = ContextLevel.USER; + eventFormatted.contextInstanceId = eventFormatted.userid; + } + } + + /** + * Get options (name & value) for each allowed event type. + * + * @param eventTypes Result of getAllowedEventTypes. + * @return Options. + */ + getEventTypeOptions(eventTypes: {[name: string]: boolean}): AddonCalendarEventTypeOption[] { + const options: AddonCalendarEventTypeOption[] = []; + + if (eventTypes.user) { + options.push({ name: 'core.user', value: AddonCalendarEventType.USER }); + } + if (eventTypes.group) { + options.push({ name: 'core.group', value: AddonCalendarEventType.GROUP }); + } + if (eventTypes.course) { + options.push({ name: 'core.course', value: AddonCalendarEventType.COURSE }); + } + if (eventTypes.category) { + options.push({ name: 'core.category', value: AddonCalendarEventType.CATEGORY }); + } + if (eventTypes.site) { + options.push({ name: 'core.site', value: AddonCalendarEventType.SITE }); + } + + return options; + } + + /** + * Get the month "id" (year + month). + * + * @param year Year. + * @param month Month. + * @return The "id". + */ + getMonthId(year: number, month: number): string { + return year + '#' + month; + } + + /** + * Get weeks of a month in offline (with no events). + * + * The result has the same structure than getMonthlyEvents, but it only contains fields that are actually used by the app. + * + * @param year Year to get. + * @param month Month to get. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the response. + */ + async getOfflineMonthWeeks( + year: number, + month: number, + siteId?: string, + ): Promise<{ daynames: Partial[]; weeks: Partial[] }> { + const site = await CoreSites.instance.getSite(siteId); + // Get starting week day user preference, fallback to site configuration. + let startWeekDayStr = site.getStoredConfig('calendar_startwday'); + startWeekDayStr = await CoreConfig.instance.get(AddonCalendarProvider.STARTING_WEEK_DAY, startWeekDayStr); + const startWeekDay = parseInt(startWeekDayStr, 10); + + const today = moment(); + const isCurrentMonth = today.year() == year && today.month() == month - 1; + const weeks: Partial[] = []; + + let date = moment({ year, month: month - 1, date: 1 }); + for (let mday = 1; mday <= date.daysInMonth(); mday++) { + date = moment({ year, month: month - 1, date: mday }); + + // Add new week and calculate prepadding. + if (!weeks.length || date.day() == startWeekDay) { + const prepaddingLength = (date.day() - startWeekDay + 7) % 7; + const prepadding: number[] = []; + for (let i = 0; i < prepaddingLength; i++) { + prepadding.push(i); + } + weeks.push({ prepadding, postpadding: [], days: [] }); + } + + // Calculate postpadding of last week. + if (mday == date.daysInMonth()) { + const postpaddingLength = (startWeekDay - date.day() + 6) % 7; + const postpadding: number[] = []; + for (let i = 0; i < postpaddingLength; i++) { + postpadding.push(i); + } + weeks[weeks.length - 1].postpadding = postpadding; + } + + // Add day to current week. + weeks[weeks.length - 1].days!.push({ + events: [], + hasevents: false, + mday: date.date(), + isweekend: date.day() == 0 || date.day() == 6, + istoday: isCurrentMonth && today.date() == date.date(), + calendareventtypes: [], + // Added to match the type. And possibly unused. + popovertitle: '', + ispast: today.date() > date.date(), + seconds: date.seconds(), + minutes: date.minutes(), + hours: date.hours(), + wday: date.weekday(), + year: year, + yday: date.dayOfYear(), + timestamp: date.date(), + haslastdayofevent: false, + neweventtimestamp: 0, + previousperiod: 0, // Previousperiod. + nextperiod: 0, // Nextperiod. + navigation: '', // Navigation. + }); + } + + return { weeks, daynames: [{ dayno: startWeekDay }] }; + } + + /** + * Check if the data of an event has changed. + * + * @param data Current data. + * @param original Original data. + * @return True if data has changed, false otherwise. + */ + hasEventDataChanged(data: AddonCalendarOfflineEventDBRecord, original?: AddonCalendarOfflineEventDBRecord): boolean { + if (!original) { + // There is no original data, assume it hasn't changed. + return false; + } + + // Check the fields that don't depend on any other. + if (data.name != original.name || data.timestart != original.timestart || data.eventtype != original.eventtype || + data.description != original.description || data.location != original.location || + data.duration != original.duration || data.repeat != original.repeat) { + return true; + } + + // Check data that depends on eventtype. + if ((data.eventtype == AddonCalendarEventType.CATEGORY && data.categoryid != original.categoryid) || + (data.eventtype == AddonCalendarEventType.COURSE && data.courseid != original.courseid) || + (data.eventtype == AddonCalendarEventType.GROUP && data.groupcourseid != original.groupcourseid && + data.groupid != original.groupid)) { + return true; + } + + // Check data that depends on duration. + if ((data.duration == 1 && data.timedurationuntil != original.timedurationuntil) || + (data.duration == 2 && data.timedurationminutes != original.timedurationminutes)) { + return true; + } + + if (data.repeat && data.repeats != original.repeats) { + return true; + } + + return false; + } + + /** + * Filter events to be shown on the events list. + * + * @param events Events without filtering. + * @param filter Filter from popover. + * @param categories Categories indexed by ID. + * @return Filtered events. + */ + getFilteredEvents( + events: AddonCalendarEventToDisplay[], + filter: AddonCalendarFilter, + categories: { [id: number]: CoreCategoryData }, + ): AddonCalendarEventToDisplay[] { + // Do not filter. + if (!filter.filtered) { + return events; + } + + const courseId = filter.courseId ? Number(filter.courseId) : undefined; + + if (!courseId || courseId < 0) { + // Filter only by type. + return events.filter((event) => filter[event.formattedType]); + } + + const categoryId = filter.categoryId ? Number(filter.categoryId) : undefined; + + return events.filter((event) => filter[event.formattedType] && + this.shouldDisplayEvent(event, categories, courseId, categoryId)); + } + + /** + * Check if an event should be displayed based on the filter. + * + * @param event Event object. + * @param courseId Course ID to filter. + * @param categoryId Category ID the course belongs to. + * @param categories Categories indexed by ID. + * @return Whether it should be displayed. + */ + protected shouldDisplayEvent( + event: AddonCalendarEventToDisplay, + categories: { [id: number]: CoreCategoryData }, + courseId: number, + categoryId?: number, + ): boolean { + if (event.eventtype == 'user' || event.eventtype == 'site') { + // User or site event, display it. + return true; + } + + if (event.eventtype == 'category' && categories) { + if (!event.categoryid || !Object.keys(categories).length) { + // We can't tell if the course belongs to the category, display them all. + return true; + } + + if (event.categoryid == categoryId) { + // The event is in the same category as the course, display it. + return true; + } + + // Check parent categories. + let category = categories[categoryId!]; + while (category) { + if (!category.parent) { + // Category doesn't have parent, stop. + break; + } + + if (event.categoryid == category.parent) { + return true; + } + category = categories[category.parent]; + } + + return false; + } + + const eventCourse = (event.course && event.course.id) || event.courseid; + + // Show the event if it is from site home or if it matches the selected course. + return !!eventCourse && (eventCourse == CoreSites.instance.getCurrentSiteHomeId() || eventCourse == courseId); + } + + /** + * Refresh the month & day for several created/edited/deleted events, and invalidate the months & days + * for their repeated events if needed. + * + * @param events Events that have been touched and number of times each event is repeated. + * @param siteId Site ID. If not defined, current site. + * @return Resolved when done. + */ + async refreshAfterChangeEvents(events: AddonCalendarSyncInvalidateEvent[], siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const fetchTimestarts: number[] = []; + const invalidateTimestarts: number[] = []; + const promises: Promise[] = []; + + // Always fetch upcoming events. + promises.push(AddonCalendar.instance.getUpcomingEvents(undefined, undefined, true, site.id)); + + promises.concat(events.map(async (eventData) => { + + if (eventData.repeated <= 1) { + // Not repeated. + fetchTimestarts.push(eventData.timestart); + + return AddonCalendar.instance.invalidateEvent(eventData.id); + } + + if (eventData.repeatid) { + // Being edited or deleted. + // We need to calculate the days to invalidate because the event date could have changed. + // We don't know if the repeated events are before or after this one, invalidate them all. + fetchTimestarts.push(eventData.timestart); + + for (let i = 1; i < eventData.repeated; i++) { + invalidateTimestarts.push(eventData.timestart + CoreConstants.SECONDS_DAY * 7 * i); + invalidateTimestarts.push(eventData.timestart - CoreConstants.SECONDS_DAY * 7 * i); + } + + // Get the repeated events to invalidate them. + const repeatedEvents = + await AddonCalendar.instance.getLocalEventsByRepeatIdFromLocalDb(eventData.repeatid, site.id); + + await CoreUtils.instance.allPromises(repeatedEvents.map((event) => + AddonCalendar.instance.invalidateEvent(event.id!))); + + return; + } + + // Being added. + let time = eventData.timestart; + fetchTimestarts.push(time); + + while (eventData.repeated > 1) { + time += CoreConstants.SECONDS_DAY * 7; + eventData.repeated--; + invalidateTimestarts.push(time); + } + + return; + + })); + + try { + await CoreUtils.instance.allPromisesIgnoringErrors(promises); + } finally { + const treatedMonths = {}; + const treatedDays = {}; + const finalPromises: Promise[] =[AddonCalendar.instance.invalidateAllUpcomingEvents()]; + + // Fetch months and days. + fetchTimestarts.map((fetchTime) => { + const day = moment(new Date(fetchTime * 1000)); + + const monthId = this.getMonthId(day.year(), day.month() + 1); + if (!treatedMonths[monthId]) { + // Month not refetch or invalidated already, do it now. + treatedMonths[monthId] = true; + + finalPromises.push(AddonCalendar.instance.getMonthlyEvents( + day.year(), + day.month() + 1, + undefined, + undefined, + true, + site.id, + )); + } + + const dayId = monthId + '#' + day.date(); + if (!treatedDays[dayId]) { + // Dat not refetch or invalidated already, do it now. + treatedDays[dayId] = true; + + finalPromises.push(AddonCalendar.instance.getDayEvents( + day.year(), + day.month() + 1, + day.date(), + undefined, + undefined, + true, + site.id, + )); + } + }); + + // Invalidate months and days. + invalidateTimestarts.map((fetchTime) => { + const day = moment(new Date(fetchTime * 1000)); + + const monthId = this.getMonthId(day.year(), day.month() + 1); + if (!treatedMonths[monthId]) { + // Month not refetch or invalidated already, do it now. + treatedMonths[monthId] = true; + + finalPromises.push(AddonCalendar.instance.invalidateMonthlyEvents(day.year(), day.month() + 1, site.id)); + } + + const dayId = monthId + '#' + day.date(); + if (!treatedDays[dayId]) { + // Dat not refetch or invalidated already, do it now. + treatedDays[dayId] = true; + + finalPromises.push(AddonCalendar.instance.invalidateDayEvents( + day.year(), + day.month() + 1, + day.date(), + site.id, + )); + } + }); + + await CoreUtils.instance.allPromisesIgnoringErrors(finalPromises); + } + } + + /** + * Refresh the month & day for a created/edited/deleted event, and invalidate the months & days + * for their repeated events if needed. + * + * @param event Event that has been touched. + * @param repeated Number of times the event is repeated. + * @param siteId Site ID. If not defined, current site. + * @return Resolved when done. + */ + refreshAfterChangeEvent( + event: { + id?: number; + repeatid?: number; + timestart: number; + }, + repeated: number, + siteId?: string, + ): Promise { + return this.refreshAfterChangeEvents( + [{ + id: event.id!, + repeatid: event.repeatid, + timestart: event.timestart, + repeated: repeated, + }], + siteId, + ); + } + + /** + * Sort events by timestart. + * + * @param events List to sort. + */ + sortEvents(events: (AddonCalendarEventToDisplay)[]): (AddonCalendarEventToDisplay)[] { + return events.sort((a, b) => { + if (a.timestart == b.timestart) { + return a.timeduration - b.timeduration; + } + + return a.timestart - b.timestart; + }); + } + +} + +export class AddonCalendarHelper extends makeSingleton(AddonCalendarHelperProvider) {} + +/** + * Calculated data for Calendar filtering. + */ +export type AddonCalendarFilter = { + filtered: boolean; // If filter enabled (some filters applied). + courseId: number; // Course Id to filter. + categoryId?: number; // Category Id to filter. + course: boolean; // Filter to show course events. + group: boolean; // Filter to show group events. + site: boolean; // Filter to show show site events. + user: boolean; // Filter to show user events. + category: boolean; // Filter to show category events. +}; + +export type AddonCalendarEventTypeOption = { + name: string; + value: AddonCalendarEventType; +}; diff --git a/src/addons/calendar/services/calendar-offline.ts b/src/addons/calendar/services/calendar-offline.ts new file mode 100644 index 000000000..eb8d04518 --- /dev/null +++ b/src/addons/calendar/services/calendar-offline.ts @@ -0,0 +1,276 @@ +// (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 { SQLiteDBRecordValues } from '@classes/sqlitedb'; +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { AddonCalendarSubmitCreateUpdateFormDataWSParams } from './calendar'; +import { + AddonCalendarOfflineDeletedEventDBRecord, + AddonCalendarOfflineEventDBRecord, + DELETED_EVENTS_TABLE, + EVENTS_TABLE, +} from './database/calendar-offline'; + +/** + * Service to handle offline calendar events. + */ +@Injectable({ providedIn: 'root' }) +export class AddonCalendarOfflineProvider { + + /** + * Delete an offline event. + * + * @param eventId Event ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if deleted, rejected if failure. + */ + async deleteEvent(eventId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const conditions: SQLiteDBRecordValues = { + id: eventId, + }; + + await site.getDb().deleteRecords(EVENTS_TABLE, conditions); + } + + /** + * Get the IDs of all the events created/edited/deleted in offline. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the IDs. + */ + async getAllEventsIds(siteId?: string): Promise { + const promises: Promise[] = []; + + promises.push(this.getAllDeletedEventsIds(siteId)); + promises.push(this.getAllEditedEventsIds(siteId)); + + const result = await Promise.all(promises); + + return CoreUtils.instance.mergeArraysWithoutDuplicates(result[0], result[1]); + } + + /** + * Get all the events deleted in offline. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with all the events deleted in offline. + */ + async getAllDeletedEvents(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return await site.getDb().getRecords(DELETED_EVENTS_TABLE); + } + + /** + * Get the IDs of all the events deleted in offline. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the IDs of all the events deleted in offline. + */ + async getAllDeletedEventsIds(siteId?: string): Promise { + const events = await this.getAllDeletedEvents(siteId); + + return events.map((event) => event.id); + } + + /** + * Get all the events created/edited in offline. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with events. + */ + async getAllEditedEvents(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return await site.getDb().getRecords(EVENTS_TABLE); + } + + /** + * Get the IDs of all the events created/edited in offline. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with events IDs. + */ + async getAllEditedEventsIds(siteId?: string): Promise { + const events = await this.getAllEditedEvents(siteId); + + return events.map((event) => event.id!); + } + + /** + * Get an event deleted in offline. + * + * @param eventId Event ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the deleted event. + */ + async getDeletedEvent(eventId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const conditions: SQLiteDBRecordValues = { + id: eventId, + }; + + return await site.getDb().getRecord(DELETED_EVENTS_TABLE, conditions); + } + + /** + * Get an offline event. + * + * @param eventId Event ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the event. + */ + async getEvent(eventId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const conditions: SQLiteDBRecordValues = { + id: eventId, + }; + + return await site.getDb().getRecord(EVENTS_TABLE, conditions); + } + + /** + * Check if there are offline events to send. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if has offline events, false otherwise. + */ + async hasEditedEvents(siteId?: string): Promise { + try { + const events = await this.getAllEditedEvents(siteId); + + return !!events.length; + } catch { + // No offline data found, return false. + return false; + } + } + + /** + * Check whether there's offline data for a site. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if has offline data, false otherwise. + */ + async hasOfflineData(siteId?: string): Promise { + const ids = await this.getAllEventsIds(siteId); + + return ids.length > 0; + } + + /** + * Check if an event is deleted. + * + * @param eventId Event ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether the event is deleted. + */ + async isEventDeleted(eventId: number, siteId?: string): Promise { + try { + const event = await this.getDeletedEvent(eventId, siteId); + + return !!event; + } catch { + return false; + } + } + + /** + * Mark an event as deleted. + * + * @param eventId Event ID to delete. + * @param name Name of the event to delete. + * @param deleteAll If it's a repeated event. whether to delete all events of the series. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async markDeleted(eventId: number, name: string, deleteAll?: boolean, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const event: AddonCalendarOfflineDeletedEventDBRecord = { + id: eventId, + name: name || '', + repeat: deleteAll ? 1 : 0, + timemodified: Date.now(), + }; + + return await site.getDb().insertRecord(DELETED_EVENTS_TABLE, event); + } + + /** + * Offline version for adding a new discussion to a forum. + * + * @param eventId Event ID. If it's a new event, set it to undefined/null. + * @param data Event data. + * @param timeCreated The time the event was created. If not defined, current time. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the stored event. + */ + async saveEvent( + eventId: number | undefined, + data: AddonCalendarSubmitCreateUpdateFormDataWSParams, + timeCreated?: number, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + timeCreated = timeCreated || Date.now(); + const event: AddonCalendarOfflineEventDBRecord = { + id: eventId || -timeCreated, + name: data.name, + timestart: data.timestart, + eventtype: data.eventtype, + categoryid: data.categoryid, + courseid: data.courseid, + groupcourseid: data.groupcourseid, + groupid: data.groupid, + description: data.description && data.description.text, + location: data.location, + duration: data.duration, + timedurationuntil: data.timedurationuntil, + timedurationminutes: data.timedurationminutes, + repeat: data.repeat ? 1 : 0, + repeats: data.repeats, + repeatid: data.repeatid, + repeateditall: data.repeateditall ? 1 : 0, + timecreated: timeCreated, + userid: site.getUserId(), + }; + await site.getDb().insertRecord(EVENTS_TABLE, event); + + return event; + } + + /** + * Unmark an event as deleted. + * + * @param eventId Event ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if deleted, rejected if failure. + */ + async unmarkDeleted(eventId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const conditions: SQLiteDBRecordValues = { + id: eventId, + }; + + await site.getDb().deleteRecords(DELETED_EVENTS_TABLE, conditions); + } + +} +export class AddonCalendarOffline extends makeSingleton(AddonCalendarOfflineProvider) {} + diff --git a/src/addons/calendar/services/calendar-sync.ts b/src/addons/calendar/services/calendar-sync.ts new file mode 100644 index 000000000..d897a3d19 --- /dev/null +++ b/src/addons/calendar/services/calendar-sync.ts @@ -0,0 +1,322 @@ +// (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 { CoreSyncBaseProvider } from '@classes/base-sync'; +import { CoreApp } from '@services/app'; +import { CoreEvents } from '@singletons/events'; +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { + AddonCalendar, + AddonCalendarEvent, + AddonCalendarProvider, + AddonCalendarSubmitCreateUpdateFormDataWSParams, +} from './calendar'; +import { AddonCalendarOffline } from './calendar-offline'; +import { AddonCalendarHelper } from './calendar-helper'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreError } from '@classes/errors/error'; +import { CoreSync } from '@services/sync'; +import { CoreTextUtils } from '@services/utils/text'; + +/** + * Service to sync calendar. + */ +@Injectable({ providedIn: 'root' }) +export class AddonCalendarSyncProvider extends CoreSyncBaseProvider { + + static readonly AUTO_SYNCED = 'addon_calendar_autom_synced'; + static readonly MANUAL_SYNCED = 'addon_calendar_manual_synced'; + static readonly SYNC_ID = 'calendar'; + + constructor() { + super('AddonCalendarSync'); + } + + /** + * Try to synchronize all events in a certain site or in all sites. + * + * @param siteId Site ID to sync. If not defined, sync all sites. + * @param force Wether to force sync not depending on last execution. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + async syncAllEvents(siteId?: string, force?: boolean): Promise { + await this.syncOnSites('all calendar events', this.syncAllEventsFunc.bind(this, [force]), siteId); + } + + /** + * Sync all events on a site. + * + * @param siteId Site ID to sync. + * @param force Wether to force sync not depending on last execution. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + protected async syncAllEventsFunc(siteId: string, force?: boolean): Promise { + const result = await (force ? this.syncEvents(siteId) : this.syncEventsIfNeeded(siteId)); + + if (result && result.updated) { + // Sync successful, send event. + CoreEvents.trigger(AddonCalendarSyncProvider.AUTO_SYNCED, result, siteId); + } + } + + /** + * Sync a site events only if a certain time has passed since the last time. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the events are synced or if it doesn't need to be synced. + */ + async syncEventsIfNeeded(siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const needed = await this.isSyncNeeded(AddonCalendarSyncProvider.SYNC_ID, siteId); + + if (needed) { + await this.syncEvents(siteId); + } + } + + /** + * Synchronize all offline events of a certain site. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + async syncEvents(siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (this.isSyncing(AddonCalendarSyncProvider.SYNC_ID, siteId)) { + // There's already a sync ongoing for this site, return the promise. + return this.getOngoingSync(AddonCalendarSyncProvider.SYNC_ID, siteId)!; + } + + this.logger.debug('Try to sync calendar events for site ' + siteId); + + // Get offline events. + const syncPromise = this.performSyncEvents(siteId); + + return this.addOngoingSync(AddonCalendarSyncProvider.SYNC_ID, syncPromise, siteId); + } + + /** + * Sync user preferences of a site. + * + * @param siteId Site ID to sync. + * @param Promise resolved if sync is successful, rejected if sync fails. + */ + protected async performSyncEvents(siteId: string): Promise { + const result: AddonCalendarSyncEvents = { + warnings: [], + events: [], + deleted: [], + toinvalidate: [], + updated: false, + }; + + let eventIds: number[] = []; + try { + eventIds = await AddonCalendarOffline.instance.getAllEventsIds(siteId); + } catch { + // No offline data found. + } + + if (eventIds.length > 0) { + if (!CoreApp.instance.isOnline()) { + // Cannot sync in offline. + throw new CoreError('Cannot sync while offline'); + } + + const promises = eventIds.map((eventId) => this.syncOfflineEvent(eventId, result, siteId)); + + await CoreUtils.instance.allPromises(promises); + + if (result.updated) { + + // Data has been sent to server. Now invalidate the WS calls. + const promises = [ + AddonCalendar.instance.invalidateEventsList(siteId), + AddonCalendarHelper.instance.refreshAfterChangeEvents(result.toinvalidate, siteId), + ]; + + await CoreUtils.instance.ignoreErrors(Promise.all(promises)); + } + } + + // Sync finished, set sync time. + await CoreUtils.instance.ignoreErrors(this.setSyncTime(AddonCalendarSyncProvider.SYNC_ID, siteId)); + + // All done, return the result. + return result; + } + + /** + * Synchronize an offline event. + * + * @param eventId The event ID to sync. + * @param result Object where to store the result of the sync. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + protected async syncOfflineEvent(eventId: number, result: AddonCalendarSyncEvents, siteId?: string): Promise { + + // Verify that event isn't blocked. + if (CoreSync.instance.isBlocked(AddonCalendarProvider.COMPONENT, eventId, siteId)) { + this.logger.debug('Cannot sync event ' + eventId + ' because it is blocked.'); + + throw Translate.instance.instant( + 'core.errorsyncblocked', + { $a: Translate.instance.instant('addon.calendar.calendarevent') }, + ); + } + + // First of all, check if the event has been deleted. + try { + const data = await AddonCalendarOffline.instance.getDeletedEvent(eventId, siteId); + // Delete the event. + try { + await AddonCalendar.instance.deleteEventOnline(data.id, !!data.repeat, siteId); + + result.updated = true; + result.deleted.push(eventId); + + // Event sent, delete the offline data. + const promises: Promise[] = []; + + promises.push(AddonCalendarOffline.instance.unmarkDeleted(eventId, siteId)); + promises.push(AddonCalendarOffline.instance.deleteEvent(eventId, siteId).catch(() => { + // Ignore errors, maybe there was no edit data. + })); + + // We need the event data to invalidate it. Get it from local DB. + promises.push(AddonCalendar.instance.getEventFromLocalDb(eventId, siteId).then((event) => { + result.toinvalidate.push({ + id: event.id, + repeatid: event.repeatid, + timestart: event.timestart, + repeated: data?.repeat ? (event as AddonCalendarEvent).eventcount || 1 : 1, + }); + + return; + }).catch(() => { + // Ignore errors. + })); + + await Promise.all(promises); + } catch (error) { + + if (!CoreUtils.instance.isWebServiceError(error)) { + // Local error, reject. + throw error; + } + + // The WebService has thrown an error, this means that the event cannot be created. Delete it. + result.updated = true; + + const promises: Promise[] = []; + + promises.push(AddonCalendarOffline.instance.unmarkDeleted(eventId, siteId)); + promises.push(AddonCalendarOffline.instance.deleteEvent(eventId, siteId).catch(() => { + // Ignore errors, maybe there was no edit data. + })); + + await Promise.all(promises); + // Event deleted, add a warning. + result.warnings.push(Translate.instance.instant('core.warningofflinedatadeleted', { + component: Translate.instance.instant('addon.calendar.calendarevent'), + name: data.name, + error: CoreTextUtils.instance.getErrorMessageFromError(error), + })); + } + + return; + } catch { + // Not deleted. + } + + // Not deleted. Now get the event data. + const event = await AddonCalendarOffline.instance.getEvent(eventId, siteId); + + // Try to send the data. + const data: AddonCalendarSubmitCreateUpdateFormDataWSParams = Object.assign( + CoreUtils.instance.clone(event), + { + description: { + text: event.description || '', + format: 1, + }, + }, + ); // Clone the object because it will be modified in the submit function. + + try { + const newEvent = await AddonCalendar.instance.submitEventOnline(eventId > 0 ? eventId : 0, data, siteId); + + result.updated = true; + result.events.push(newEvent); + + // Add data to invalidate. + const numberOfRepetitions = data.repeat ? data.repeats : + (data.repeateditall && newEvent.repeatid ? newEvent.eventcount : 1); + + result.toinvalidate.push({ + id: newEvent.id, + repeatid: newEvent.repeatid, + timestart: newEvent.timestart, + repeated: numberOfRepetitions || 1, + }); + + // Event sent, delete the offline data. + return AddonCalendarOffline.instance.deleteEvent(event.id!, siteId); + + } catch (error) { + if (!CoreUtils.instance.isWebServiceError(error)) { + // Local error, reject. + throw error; + } + + // The WebService has thrown an error, this means that the event cannot be created. Delete it. + result.updated = true; + + await AddonCalendarOffline.instance.deleteEvent(event.id!, siteId); + // Event deleted, add a warning. + result.warnings.push(Translate.instance.instant('core.warningofflinedatadeleted', { + component: Translate.instance.instant('addon.calendar.calendarevent'), + name: event.name, + error: CoreTextUtils.instance.getErrorMessageFromError(error), + })); + } + } + +} + +export class AddonCalendarSync extends makeSingleton(AddonCalendarSyncProvider) {} + +export type AddonCalendarSyncEvents = { + warnings: string[]; + events: AddonCalendarEvent[]; + deleted: number[]; + toinvalidate: AddonCalendarSyncInvalidateEvent[]; + updated: boolean; + source?: string; // Added on pages. + day?: number; // Added on day page. + month?: number; // Added on day page. + year?: number; // Added on day page. +}; + +export type AddonCalendarSyncInvalidateEvent = { + id: number; + repeatid?: number; + timestart: number; + repeated: number; +}; diff --git a/src/addons/calendar/services/calendar.ts b/src/addons/calendar/services/calendar.ts new file mode 100644 index 000000000..4644dded1 --- /dev/null +++ b/src/addons/calendar/services/calendar.ts @@ -0,0 +1,2230 @@ +// (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 { CoreSites } from '@services/sites'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { CoreApp } from '@services/app'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUrlUtils } from '@services/utils/url'; +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'; +import { AddonCalendarEventDBRecord, AddonCalendarReminderDBRecord, EVENTS_TABLE, REMINDERS_TABLE } from './database/calendar'; +import { CoreCourses } from '@features/courses/services/courses'; +import { ContextLevel, CoreConstants } from '@/core/constants'; +import { CoreWSError } from '@classes/errors/wserror'; +import { ApplicationInit, makeSingleton, Translate, Platform } from '@singletons'; +import { AddonCalendarOfflineEventDBRecord } from './database/calendar-offline'; +import { AddonCalendarMainMenuHandlerService } from './handlers/mainmenu'; +import { SafeUrl } from '@angular/platform-browser'; +import { CoreNavigator } from '@services/navigator'; + +const ROOT_CACHE_KEY = 'mmaCalendar:'; + +/** + * Context levels enumeration. + */ +export enum AddonCalendarEventType { + SITE = 'site', + CATEGORY = 'category', + COURSE = 'course', + GROUP = 'group', + USER = 'user', +} + +/** + * Service to handle calendar events. + */ +@Injectable({ providedIn: 'root' }) +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 DEFAULT_NOTIFICATION_TIME = 60; + 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'; + static readonly EDIT_EVENT_EVENT = 'addon_calendar_edit_event'; + static readonly DELETED_EVENT_EVENT = 'addon_calendar_deleted_event'; + static readonly UNDELETED_EVENT_EVENT = 'addon_calendar_undeleted_event'; + static readonly FILTER_CHANGED_EVENT = 'addon_calendar_filter_changed_event'; + + static readonly CALENDAR_TF_24 = '%H:%M'; // Calendar time in 24 hours format. + static readonly CALENDAR_TF_12 = '%I:%M %p'; // Calendar time in 12 hours format. + + protected weekDays: AddonCalendarWeekDaysTranslationKeys[] = [ + { + shortname: 'addon.calendar.sun', + fullname: 'addon.calendar.sunday', + }, + { + shortname: 'addon.calendar.mon', + fullname: 'addon.calendar.monday', + }, + { + shortname: 'addon.calendar.tue', + fullname: 'addon.calendar.tuesday', + }, + { + shortname: 'addon.calendar.wed', + fullname: 'addon.calendar.wednesday', + }, + { + shortname: 'addon.calendar.thu', + fullname: 'addon.calendar.thursday', + }, + { + shortname: 'addon.calendar.fri', + fullname: 'addon.calendar.friday', + }, + { + shortname: 'addon.calendar.sat', + fullname: 'addon.calendar.saturday', + }, + ]; + + /** + * Check if a certain site allows deleting events. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if can delete. + * @since 3.3 + */ + async canDeleteEvents(siteId?: string): Promise { + try { + const site = await CoreSites.instance.getSite(siteId); + + return this.canDeleteEventsInSite(site); + } catch { + return false; + } + } + + /** + * Check if a certain site allows deleting events. + * + * @param site Site. If not defined, use current site. + * @return Whether events can be deleted. + * @since 3.3 + */ + canDeleteEventsInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site?.wsAvailable('core_calendar_delete_calendar_events'); + } + + /** + * Check if a certain site allows creating and editing events. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if can create/edit. + * @since 3.7.1 + */ + async canEditEvents(siteId?: string): Promise { + try { + const site = await CoreSites.instance.getSite(siteId); + + return this.canEditEventsInSite(site); + } catch { + return false; + } + } + + /** + * Check if a certain site allows creating and editing events. + * + * @param site Site. If not defined, use current site. + * @return Whether events can be created and edited. + * @since 3.7.1 + */ + canEditEventsInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + // The WS to create/edit events requires a fix that was integrated in 3.7.1. + return !!site?.isVersionGreaterEqualThan('3.7.1'); + } + + /** + * Check if a certain site allows viewing events in monthly view. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if monthly view is supported. + * @since 3.4 + */ + async canViewMonth(siteId?: string): Promise { + try { + const site = await CoreSites.instance.getSite(siteId); + + return this.canViewMonthInSite(site); + } catch { + return false; + } + } + + /** + * Check if a certain site allows viewing events in monthly view. + * + * @param site Site. If not defined, use current site. + * @return Whether monthly view is supported. + * @since 3.4 + */ + canViewMonthInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site?.wsAvailable('core_calendar_get_calendar_monthly_view'); + } + + /** + * Gets the site main calendar page path. + * + * @param site Site. If not defined, use current site. + * @return Main calendar page path of the site. + */ + getMainCalendarPagePath(site?: CoreSite): string { + return AddonCalendarMainMenuHandlerService.PAGE_NAME + (this.canViewMonthInSite(site) ? '' : '/list'); + } + + /** + * Removes expired events from local DB. + * + * @param siteId ID of the site the event belongs to. If not defined, use current site. + * @return Promise resolved when done. + */ + async cleanExpiredEvents(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + if (this.canViewMonthInSite(site)) { + // Site supports monthly view, don't clean expired events because user can see past events. + return; + } + const events = await site.getDb().getRecordsSelect( + EVENTS_TABLE, + 'timestart + timeduration < ?', + [CoreTimeUtils.instance.timestamp()], + ); + + await Promise.all(events.map((event) => this.deleteLocalEvent(event.id!, siteId))); + } + + /** + * Delete an event. + * + * @param eventId Event ID to delete. + * @param name Name of the event to delete. + * @param deleteAll If it's a repeated event. whether to delete all events of the series. + * @param forceOffline True to always save it in offline. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteEvent( + eventId: number, + name: string, + deleteAll = false, + forceOffline = false, + siteId?: string, + ): Promise { + + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Function to store the submission to be synchronized later. + const storeOffline = (): Promise => + AddonCalendarOffline.instance.markDeleted(eventId, name, deleteAll, siteId).then(() => false); + + if (forceOffline || !CoreApp.instance.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + // If the event is already stored, discard it first. + await AddonCalendarOffline.instance.unmarkDeleted(eventId, siteId); + try { + await this.deleteEventOnline(eventId, deleteAll, siteId); + + return true; + } catch (error) { + if (error && !CoreUtils.instance.isWebServiceError(error)) { + // Couldn't connect to server, store in offline. + return storeOffline(); + } else { + // The WebService has thrown an error, reject. + throw error; + } + } + } + + /** + * Delete an event. It will fail if offline or cannot connect. + * + * @param eventId Event ID to delete. + * @param deleteAll If it's a repeated event. whether to delete all events of the series. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteEventOnline(eventId: number, deleteAll = false, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const params: AddonCalendarDeleteCalendarEventsWSParams = { + events: [ + { + eventid: eventId, + repeat: deleteAll, + }, + ], + }; + const preSets: CoreSiteWSPreSets = { + responseExpected: false, + }; + + await site.write('core_calendar_delete_calendar_events', params, preSets); + } + + /** + * Delete a locally stored event cancelling all the reminders and notifications. + * + * @param eventId Event ID. + * @param siteId ID of the site the event belongs to. If not defined, use current site. + * @return Resolved when done. + */ + protected async deleteLocalEvent(eventId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + siteId = site.getId(); + + const promises: Promise[] = []; + + promises.push(site.getDb().deleteRecords( + 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))))); + + try { + await Promise.all(promises); + } catch { + // Ignore errors. + } + } + + /** + * Initialize the service. + * + * @return Promise resolved when done. + */ + async initialize(): Promise { + + CoreLocalNotifications.instance.registerClick( + AddonCalendarProvider.COMPONENT, + async (notification) => { + if (notification.eventId) { + await ApplicationInit.instance.donePromise; + + const disabled = await this.isDisabled(notification.siteId); + if (disabled) { + // The calendar is disabled in the site, don't open it. + return; + } + + // Check which page we should load. + const site = await CoreSites.instance.getSite(notification.siteId); + const pageName = this.getMainCalendarPagePath(site); + + CoreNavigator.instance.navigateToSitePath( + pageName, + { params: { eventId: notification.eventId }, siteId: notification.siteId }, + ); + } + }, + ); + + } + + /** + * Format event time. Similar to calendar_format_event_time. + * + * @param event Event to format. + * @param format Calendar time format (from getCalendarTimeFormat). + * @param useCommonWords Whether to use common words like "Today", "Yesterday", etc. + * @param seenDay Timestamp of day currently seen. If set, the function will not add links to this day. + * @param showTime Determine the show time GMT timestamp. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the formatted event time. + */ + async formatEventTime( + event: AddonCalendarEventToDisplay, + format: string, + useCommonWords = true, + seenDay?: number, + showTime = 0, + siteId?: string, + ): Promise { + + const start = event.timestart * 1000; + const end = (event.timestart + event.timeduration) * 1000; + let time: string; + + if (!event.timeduration) { + + if (moment(start).isSame(end, 'day')) { + // Event starts and ends the same day. + if (event.timeduration == CoreConstants.SECONDS_DAY) { + time = Translate.instance.instant('addon.calendar.allday'); + } else { + time = CoreTimeUtils.instance.userDate(start, format) + ' » ' + + CoreTimeUtils.instance.userDate(end, format); + } + + } else { + // Event lasts more than one day. + const timeStart = CoreTimeUtils.instance.userDate(start, format); + const timeEnd = CoreTimeUtils.instance.userDate(end, format); + const promises: Promise[] = []; + + // Don't use common words when the event lasts more than one day. + let dayStart = this.getDayRepresentation(start, false) + ', '; + let dayEnd = this.getDayRepresentation(end, false) + ', '; + + // Add links to the days if needed. + if (dayStart && (!seenDay || !moment(seenDay).isSame(start, 'day'))) { + promises.push(this.getViewUrl('day', event.timestart, undefined, siteId).then((url) => { + dayStart = CoreUrlUtils.instance.buildLink(url, dayStart); + + return; + })); + } + if (dayEnd && (!seenDay || !moment(seenDay).isSame(end, 'day'))) { + promises.push(this.getViewUrl('day', end / 1000, undefined, siteId).then((url) => { + dayEnd = CoreUrlUtils.instance.buildLink(url, dayEnd); + + return; + })); + } + + await Promise.all(promises); + + return dayStart + timeStart + ' » ' + dayEnd + timeEnd; + } + } else { + // There is no time duration. + time = CoreTimeUtils.instance.userDate(start, format); + } + + if (showTime) { + return time; + } + + // Display day + time. + if (seenDay && moment(seenDay).isSame(start, 'day')) { + // This day is currently being displayed, don't add an link. + return this.getDayRepresentation(start, useCommonWords) + ', ' + time; + } + + // Add link to view the day. + const url = await this.getViewUrl('day', event.timestart, undefined, siteId); + + return CoreUrlUtils.instance.buildLink(url, this.getDayRepresentation(start, useCommonWords)) + ', ' + time; + } + + /** + * Get access information for a calendar (either course calendar or site calendar). + * + * @param courseId Course ID. If not defined, site calendar. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with object with access information. + * @since 3.7 + */ + async getAccessInformation(courseId?: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const params: AddonCalendarGetCalendarAccessInformationWSParams = {}; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getAccessInformationCacheKey(courseId), + }; + if (courseId) { + params.courseid = courseId; + } + + return site.read('core_calendar_get_calendar_access_information', params, preSets); + } + + /** + * Get cache key for calendar access information WS calls. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getAccessInformationCacheKey(courseId?: number): string { + return ROOT_CACHE_KEY + 'accessInformation:' + (courseId || 0); + } + + /** + * Get all calendar events from local Db. + * + * @param siteId ID of the site the event belongs to. If not defined, use current site. + * @return Promise resolved with all the events. + */ + async getAllEventsFromLocalDb(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return await site.getDb().getAllRecords(EVENTS_TABLE); + } + + /** + * Get the type of events a user can create (either course calendar or site calendar). + * + * @param courseId Course ID. If not defined, site calendar. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with an object indicating the types. + * @since 3.7 + */ + async getAllowedEventTypes(courseId?: number, siteId?: string): Promise<{[name: string]: boolean}> { + const site = await CoreSites.instance.getSite(siteId); + const params: AddonCalendarGetAllowedEventTypesWSParams = {}; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getAllowedEventTypesCacheKey(courseId), + }; + if (courseId) { + params.courseid = courseId; + } + const response: AddonCalendarGetAllowedEventTypesWSResponse = + await site.read('core_calendar_get_allowed_event_types', params, preSets); + + // Convert the array to an object. + const result = {}; + if (response.allowedeventtypes) { + response.allowedeventtypes.map((type) => { + result[type] = true; + }); + } + + return result; + } + + /** + * Get cache key for calendar allowed event types WS calls. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getAllowedEventTypesCacheKey(courseId?: number): string { + return ROOT_CACHE_KEY + 'allowedEventTypes:' + (courseId || 0); + } + + /** + * Get the "look ahead" for a certain user. + * + * @param siteId ID of the site. If not defined, use current site. + * @return Promise resolved with the look ahead (number of days). + */ + async getCalendarLookAhead(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + let value: string | undefined; + try { + value = await CoreUser.instance.getUserPreference('calendar_lookahead'); + } catch { + // Ignore errors. + } + + if (typeof value == 'undefined') { + value = site.getStoredConfig('calendar_lookahead'); + } + + return parseInt(value as string, 10); + } + + /** + * Get the time format to use in calendar. + * + * @param siteId ID of the site. If not defined, use current site. + * @return Promise resolved with the format. + */ + async getCalendarTimeFormat(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + let format: string | undefined; + + try { + format = await CoreUser.instance.getUserPreference('calendar_timeformat'); + } catch { + // Ignore errors. + } + + if (!format || format === '0') { + format = site.getStoredConfig('calendar_site_timeformat'); + } + + if (format === AddonCalendarProvider.CALENDAR_TF_12) { + format = Translate.instance.instant('core.strftimetime12'); + } else if (format === AddonCalendarProvider.CALENDAR_TF_24) { + format = Translate.instance.instant('core.strftimetime24'); + } + + return format && format !== '0' ? format : Translate.instance.instant('core.strftimetime'); + } + + /** + * Return the representation day. Equivalent to Moodle's calendar_day_representation. + * + * @param time Timestamp to get the day from. + * @param useCommonWords Whether to use common words like "Today", "Yesterday", etc. + * @return The formatted date/time. + */ + getDayRepresentation(time: number, useCommonWords: boolean = true): string { + + if (!useCommonWords) { + // We don't want words, just a date. + return CoreTimeUtils.instance.userDate(time, 'core.strftimedayshort'); + } + + const date = moment(time); + const today = moment(); + + if (date.isSame(today, 'day')) { + return Translate.instance.instant('addon.calendar.today'); + } + if (date.isSame(today.clone().subtract(1, 'days'), 'day')) { + return Translate.instance.instant('addon.calendar.yesterday'); + } + if (date.isSame(today.clone().add(1, 'days'), 'day')) { + return Translate.instance.instant('addon.calendar.tomorrow'); + } + + return CoreTimeUtils.instance.userDate(time, 'core.strftimedayshort'); + } + + /** + * 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. + */ + async getDefaultNotificationTime(siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const key = AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_SETTING + '#' + siteId; + + return CoreConfig.instance.get(key, AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME); + } + + /** + * Get a calendar event. If the server request fails and data is not cached, try to get it from local DB. + * + * @param id Event ID. + * @param siteId ID of the site. If not defined, use current site. + * @return Promise resolved when the event data is retrieved. + */ + async getEvent(id: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getEventCacheKey(id), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + const params: AddonCalendarGetCalendarEventsWSParams = { + options: { + userevents: false, + siteevents: false, + }, + events: { + eventids: [ + id, + ], + }, + }; + try { + const response: AddonCalendarGetCalendarEventsWSResponse = + await site.read('core_calendar_get_calendar_events', params, preSets); + // The WebService returns all category events. Check the response to search for the event we want. + const event = response.events.find((e) => e.id == id); + + return event || this.getEventFromLocalDb(id); + } catch { + return this.getEventFromLocalDb(id); + } + } + + /** + * Get a calendar event by ID. This function returns more data than getEvent, but it isn't available in all Moodles. + * + * @param id Event ID. + * @param siteId ID of the site. If not defined, use current site. + * @return Promise resolved when the event data is retrieved. + * @since 3.4 + */ + async getEventById(id: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getEventCacheKey(id), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + const params: AddonCalendarGetCalendarEventByIdWSParams = { + eventid: id, + }; + try { + const response: AddonCalendarGetCalendarEventByIdWSResponse = + await site.read('core_calendar_get_calendar_event_by_id', params, preSets); + + return response.event; + } catch (error) { + try { + return (await this.getEventFromLocalDb(id)) as AddonCalendarEvent; + } catch { + throw error; + } + } + } + + /** + * Get cache key for a single event WS call. + * + * @param id Event ID. + * @return Cache key. + */ + protected getEventCacheKey(id: number): string { + return ROOT_CACHE_KEY + 'events:' + id; + } + + /** + * Get a calendar event from local Db. + * + * @param id 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. + */ + async getEventFromLocalDb(id: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const record: AddonCalendarGetEventsEvent | AddonCalendarEvent | AddonCalendarEventDBRecord = + await site.getDb().getRecord(EVENTS_TABLE, { id: id }); + + if (!this.isGetEventByIdAvailableInSite(site)) { + return record as AddonCalendarGetEventsEvent; + } + + const eventConverted = record as AddonCalendarEvent; + const originalEvent = record as AddonCalendarGetEventsEvent; + const recordAsRecord = record as AddonCalendarEventDBRecord; + + + // Calculate data to match the new WS. + eventConverted.descriptionformat = originalEvent.format; + eventConverted.iscourseevent = originalEvent.eventtype == AddonCalendarEventType.COURSE; + eventConverted.iscategoryevent = originalEvent.eventtype == AddonCalendarEventType.CATEGORY; + eventConverted.normalisedeventtype = this.getEventType(recordAsRecord); + try { + eventConverted.category = CoreTextUtils.instance.parseJSON(recordAsRecord.category!); + } catch { + // Ignore errors. + } + + try { + eventConverted.course = CoreTextUtils.instance.parseJSON(recordAsRecord.course!); + } catch { + // Ignore errors. + } + try { + eventConverted.subscription = CoreTextUtils.instance.parseJSON(recordAsRecord.subscription!); + } catch { + // Ignore errors. + } + + return eventConverted; + } + + /** + * Adds an event reminder and schedule a new notification. + * + * @param event Event to update its notification time. + * @param time New notification setting timestamp. + * @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; timeduration: number; name: string}, + time: number, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + const reminder: AddonCalendarReminderDBRecord = { + eventid: event.id, + time: time, + }; + const reminderId = await site.getDb().insertRecord(REMINDERS_TABLE, reminder); + + await this.scheduleEventNotification(event, reminderId, time, site.getId()); + } + + /** + * Return the normalised event type. + * Activity events are normalised to be course events. + * + * @param event The event to get its type. + * @return Event type. + */ + getEventType(event: { modulename?: string; eventtype: AddonCalendarEventType}): string { + if (event.modulename) { + return 'course'; + } + + return event.eventtype; + } + + /** + * Remove an event reminder and cancel the notification. + * + * @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. + */ + async deleteEventReminder(id: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + if (CoreLocalNotifications.instance.isAvailable()) { + CoreLocalNotifications.instance.cancel(id, AddonCalendarProvider.COMPONENT, site.getId()); + } + + await site.getDb().deleteRecords(REMINDERS_TABLE, { id: id }); + } + + /** + * Get calendar events for a certain day. + * + * @param year Year to get. + * @param month Month to get. + * @param day Day to get. + * @param courseId Course to get. + * @param categoryId Category to get. + * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the response. + */ + async getDayEvents( + year: number, + month: number, + day: number, + courseId?: number, + categoryId?: number, + ignoreCache = false, + siteId?: string, + ): Promise { + + const site = await CoreSites.instance.getSite(siteId); + const params: AddonCalendarGetCalendarDayViewWSParams = { + year: year, + month: month, + day: day, + }; + if (courseId) { + params.courseid = courseId; + } + if (categoryId) { + params.categoryid = categoryId; + } + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getDayEventsCacheKey(year, month, day, courseId, categoryId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + }; + if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + const response: AddonCalendarCalendarDay = await site.read('core_calendar_get_calendar_day_view', params, preSets); + this.storeEventsInLocalDB(response.events, siteId); + + return response; + } + + /** + * Get prefix cache key for day events WS calls. + * + * @return Prefix Cache key. + */ + protected getDayEventsPrefixCacheKey(): string { + return ROOT_CACHE_KEY + 'day:'; + } + + /** + * Get prefix cache key for a certain day for day events WS calls. + * + * @param year Year to get. + * @param month Month to get. + * @param day Day to get. + * @return Prefix Cache key. + */ + protected getDayEventsDayPrefixCacheKey(year: number, month: number, day: number): string { + return this.getDayEventsPrefixCacheKey() + year + ':' + month + ':' + day + ':'; + } + + /** + * Get cache key for day events WS calls. + * + * @param year Year to get. + * @param month Month to get. + * @param day Day to get. + * @param courseId Course to get. + * @param categoryId Category to get. + * @return Cache key. + */ + protected getDayEventsCacheKey(year: number, month: number, day: number, courseId?: number, categoryId?: number): string { + return this.getDayEventsDayPrefixCacheKey(year, month, day) + (courseId ? courseId : '') + ':' + + (categoryId ? categoryId : ''); + } + + /** + * Get a calendar reminders from local Db. + * + * @param id 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. + */ + async getEventReminders(id: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return await site.getDb().getRecords(REMINDERS_TABLE, { eventid: id }, 'time ASC'); + } + + /** + * Get the events in a certain period. The period is calculated like this: + * start time: now + daysToStart + * end time: start time + daysInterval + * E.g. using provider.getEventsList(undefined, 30, 30) is going to get the events starting after 30 days from now + * and ending before 60 days from now. + * + * @param initialTime Timestamp when the first fetch was done. If not defined, current time. + * @param daysToStart Number of days from now to start getting events. + * @param daysInterval Number of days between timestart and timeend. + * @param siteId Site to get the events from. If not defined, use current site. + * @return Promise to be resolved when the events are retrieved. + */ + async getEventsList( + initialTime?: number, + daysToStart: number = 0, + daysInterval: number = AddonCalendarProvider.DAYS_INTERVAL, + siteId?: string, + ): Promise { + + initialTime = initialTime || CoreTimeUtils.instance.timestamp(); + + const site = await CoreSites.instance.getSite(siteId); + siteId = site.getId(); + + const start = initialTime + (CoreConstants.SECONDS_DAY * daysToStart); + const end = start + (CoreConstants.SECONDS_DAY * daysInterval) - 1; + + const params: AddonCalendarGetCalendarEventsWSParams = { + options: { + userevents: true, + siteevents: true, + timestart: start, + timeend: end, + }, + events: { + courseids: [], + groupids: [], + }, + }; + + const promises: Promise[] = []; + + promises.push(CoreCourses.instance.getUserCourses(false, siteId).then((courses) => { + params.events!.courseids = courses.map((course) => course.id); + params.events!.courseids.push(site.getSiteHomeId()); // Add front page. + + + return; + })); + + promises.push(CoreGroups.instance.getAllUserGroups(siteId).then((groups) => { + params.events!.groupids = groups.map((group) => group.id); + + return; + })); + + await Promise.all(promises); + + // We need to retrieve cached data using cache key because we have timestamp in the params. + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getEventsListCacheKey(daysToStart, daysInterval), + getCacheUsingCacheKey: true, + uniqueCacheKey: true, + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + }; + const response: AddonCalendarGetCalendarEventsWSResponse = + await site.read('core_calendar_get_calendar_events', params, preSets); + if (!this.canViewMonthInSite(site)) { + // Store events only in 3.1-3.3. In 3.4+ we'll use the new WS that return more info. + this.storeEventsInLocalDB(response.events, siteId); + } + + return response.events; + } + + /** + * Get prefix cache key for events list WS calls. + * + * @return Prefix Cache key. + */ + protected getEventsListPrefixCacheKey(): string { + return ROOT_CACHE_KEY + 'events:'; + } + + /** + * Get cache key for events list WS calls. + * + * @param daysToStart Number of days from now to start getting events. + * @param daysInterval Number of days between timestart and timeend. + * @return Cache key. + */ + protected getEventsListCacheKey(daysToStart: number, daysInterval: number): string { + return this.getEventsListPrefixCacheKey() + daysToStart + ':' + daysInterval; + } + + /** + * Get calendar events from local Db that have the same repeatid. + * + * @param repeatId Repeat Id of the event. + * @param siteId ID of the site the event belongs to. If not defined, use current site. + * @return Promise resolved with all the events. + */ + async getLocalEventsByRepeatIdFromLocalDb(repeatId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return await site.getDb().getRecords(EVENTS_TABLE, { repeatid: repeatId }); + } + + /** + * Get monthly calendar events. + * + * @param year Year to get. + * @param month Month to get. + * @param courseId Course to get. + * @param categoryId Category to get. + * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the response. + */ + async getMonthlyEvents( + year: number, + month: number, + courseId?: number, + categoryId?: number, + ignoreCache = false, + siteId?: string, + ): Promise { + + const site = await CoreSites.instance.getSite(siteId); + const params: AddonCalendarGetCalendarMonthlyViewWSParams = { + year: year, + month: month, + }; + // This parameter requires Moodle 3.5. + if (site.isVersionGreaterEqualThan('3.5')) { + // Set mini to 1 to prevent returning the course selector HTML. + params.mini = true; + } + if (courseId) { + params.courseid = courseId; + } + if (categoryId) { + params.categoryid = categoryId; + } + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getMonthlyEventsCacheKey(year, month, courseId, categoryId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + }; + if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + const response = await site.read('core_calendar_get_calendar_monthly_view', params, preSets); + response.weeks.forEach((week) => { + week.days.forEach((day) => { + this.storeEventsInLocalDB(day.events as AddonCalendarCalendarEvent[], siteId); + }); + }); + + // Store starting week day preference, we need it in offline to show months that are not in cache. + if (CoreApp.instance.isOnline()) { + CoreConfig.instance.set(AddonCalendarProvider.STARTING_WEEK_DAY, response.daynames[0].dayno); + } + + return response; + } + + /** + * Get prefix cache key for monthly events WS calls. + * + * @return Prefix Cache key. + */ + protected getMonthlyEventsPrefixCacheKey(): string { + return ROOT_CACHE_KEY + 'monthly:'; + } + + /** + * Get prefix cache key for a certain month for monthly events WS calls. + * + * @param year Year to get. + * @param month Month to get. + * @return Prefix Cache key. + */ + protected getMonthlyEventsMonthPrefixCacheKey(year: number, month: number): string { + return this.getMonthlyEventsPrefixCacheKey() + year + ':' + month + ':'; + } + + /** + * Get cache key for monthly events WS calls. + * + * @param year Year to get. + * @param month Month to get. + * @param courseId Course to get. + * @param categoryId Category to get. + * @return Cache key. + */ + protected getMonthlyEventsCacheKey(year: number, month: number, courseId?: number, categoryId?: number): string { + return this.getMonthlyEventsMonthPrefixCacheKey(year, month) + (courseId ? courseId : '') + ':' + + (categoryId ? categoryId : ''); + } + + /** + * Get upcoming calendar events. + * + * @param courseId Course to get. + * @param categoryId Category to get. + * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the response. + */ + async getUpcomingEvents( + courseId?: number, + categoryId?: number, + ignoreCache = false, + siteId?: string, + ): Promise { + + const site = await CoreSites.instance.getSite(siteId); + + const params: AddonCalendarGetCalendarUpcomingViewWSParams = {}; + if (courseId) { + params.courseid = courseId; + } + + if (categoryId) { + params.categoryid = categoryId; + } + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getUpcomingEventsCacheKey(courseId, categoryId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + }; + + if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + const response = await site.read('core_calendar_get_calendar_upcoming_view', params, preSets); + this.storeEventsInLocalDB(response.events, siteId); + + return response; + } + + /** + * Get prefix cache key for upcoming events WS calls. + * + * @return Prefix Cache key. + */ + protected getUpcomingEventsPrefixCacheKey(): string { + return ROOT_CACHE_KEY + 'upcoming:'; + } + + /** + * Get cache key for upcoming events WS calls. + * + * @param courseId Course to get. + * @param categoryId Category to get. + * @return Cache key. + */ + protected getUpcomingEventsCacheKey(courseId?: number, categoryId?: number): string { + return this.getUpcomingEventsPrefixCacheKey() + (courseId ? courseId : '') + ':' + (categoryId ? categoryId : ''); + } + + /** + * Get URL to view a calendar. + * + * @param view The view to load: 'month', 'day', 'upcoming', etc. + * @param time Time to load. If not defined, current time. + * @param courseId Course to load. If not defined, all courses. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the URL.x + */ + async getViewUrl(view: string, time?: number, courseId?: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + let url = CoreTextUtils.instance.concatenatePaths(site.getURL(), 'calendar/view.php?view=' + view); + + if (time) { + url += '&time=' + time; + } + if (courseId) { + url += '&course=' + courseId; + } + + return url; + } + + /** + * Get the week days, already ordered according to a specified starting day. + * + * @param startingDay Starting day. 0=Sunday, 1=Monday, ... + * @return Week days. + */ + getWeekDays(startingDay?: number): AddonCalendarWeekDaysTranslationKeys[] { + startingDay = startingDay || 0; + + return this.weekDays.slice(startingDay).concat(this.weekDays.slice(0, startingDay)); + } + + /** + * Invalidates access information. + * + * @param courseId Course ID. If not defined, site calendar. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAccessInformation(courseId?: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getAccessInformationCacheKey(courseId)); + } + + /** + * Invalidates allowed event types. + * + * @param courseId Course ID. If not defined, site calendar. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAllowedEventTypes(courseId?: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getAllowedEventTypesCacheKey(courseId)); + } + + /** + * Invalidates day events for all days. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAllDayEvents(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getDayEventsPrefixCacheKey()); + } + + /** + * Invalidates day events for a certain day. + * + * @param year Year. + * @param month Month. + * @param day Day. + * @return Promise resolved when the data is invalidated. + */ + async invalidateDayEvents(year: number, month: number, day: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getDayEventsDayPrefixCacheKey(year, month, day)); + } + + /** + * Invalidates events list and all the single events and related info. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the list is invalidated. + */ + async invalidateEventsList(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + siteId = site.getId(); + const promises: Promise[] = []; + promises.push(CoreCourses.instance.invalidateUserCourses(siteId)); + promises.push(CoreGroups.instance.invalidateAllUserGroups(siteId)); + promises.push(site.invalidateWsCacheForKeyStartingWith(this.getEventsListPrefixCacheKey())); + + await Promise.all(promises); + } + + /** + * Invalidates a single event. + * + * @param eventId List of courses or course ids. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the list is invalidated. + */ + async invalidateEvent(eventId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getEventCacheKey(eventId)); + } + + /** + * Invalidates monthly events for all months. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAllMonthlyEvents(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getMonthlyEventsPrefixCacheKey()); + } + + /** + * Invalidates monthly events for a certain months. + * + * @param year Year. + * @param month Month. + * @return Promise resolved when the data is invalidated. + */ + async invalidateMonthlyEvents(year: number, month: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getMonthlyEventsMonthPrefixCacheKey(year, month)); + } + + /** + * Invalidates upcoming events for all courses and categories. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAllUpcomingEvents(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getUpcomingEventsPrefixCacheKey()); + } + + /** + * Invalidates upcoming events for a certain course or category. + * + * @param courseId Course ID. + * @param categoryId Category ID. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUpcomingEvents(courseId?: number, categoryId?: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getUpcomingEventsCacheKey(courseId, categoryId)); + } + + /** + * Invalidates look ahead setting. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateLookAhead(siteId?: string): Promise { + await CoreUser.instance.invalidateUserPreference('calendar_lookahead', siteId); + } + + /** + * Invalidates time format setting. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + invalidateTimeFormat(siteId?: string): Promise { + return CoreUser.instance.invalidateUserPreference('calendar_timeformat', siteId); + } + + /** + * Check if Calendar is disabled in a certain site. + * + * @param site Site. If not defined, use current site. + * @return Whether it's disabled. + */ + isCalendarDisabledInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site?.isFeatureDisabled('CoreMainMenuDelegate_AddonCalendar'); + } + + /** + * Check if Calendar is disabled in a certain site. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + async isDisabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.isCalendarDisabledInSite(site); + } + + /** + * Check if the get event by ID WS is available. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if available. + * @since 3.4 + */ + async isGetEventByIdAvailable(siteId?: string): Promise { + try { + const site = await CoreSites.instance.getSite(siteId); + + return this.isGetEventByIdAvailableInSite(site); + } catch { + return false; + } + } + + /** + * Check if the get event by ID WS is available in a certain site. + * + * @param site Site. If not defined, use current site. + * @return Whether it's available. + * @since 3.4 + */ + isGetEventByIdAvailableInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site?.wsAvailable('core_calendar_get_calendar_event_by_id'); + } + + /** + * 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). + * If local notification plugin is not enabled, resolve the promise. + * + * @return Promise resolved when all the notifications have been scheduled. + */ + async scheduleAllSitesEventsNotifications(): Promise { + await Platform.instance.ready(); + + const notificationsEnabled = CoreLocalNotifications.instance.isAvailable(); + + const siteIds = await CoreSites.instance.getSitesIds(); + + const promises = siteIds.map((siteId: string) => this.cleanExpiredEvents(siteId).then(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); + } + } + + 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. + * + * @param event Event to schedule. + * @param reminderId The reminder ID. + * @param time Notification setting time (in minutes). E.g. 10 means "notificate 10 minutes before start". + * @param siteId Site ID the event belongs to. If not defined, use current site. + * @return Promise resolved when the notification is scheduled. + */ + protected async scheduleEventNotification( + event: { id: number; timestart: number; name: string}, + reminderId: number, + time: number, + siteId?: string, + ): Promise { + + if (!CoreLocalNotifications.instance.isAvailable()) { + return; + } + + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (time === 0) { + // Cancel if it was scheduled. + return CoreLocalNotifications.instance.cancel(reminderId, AddonCalendarProvider.COMPONENT, siteId); + } + + if (time == -1) { + // If time is -1, 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.instance.cancel(reminderId, AddonCalendarProvider.COMPONENT, siteId); + } + + time = event.timestart - (time * 60); + } + + time = time * 1000; + + if (time <= Date.now()) { + // This reminder is over, don't schedule. Cancel if it was scheduled. + return CoreLocalNotifications.instance.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.instance.userDate(event.timestart * 1000, 'core.strftimedaydatetime', true), + icon: 'file://assets/img/icons/calendar.png', + trigger: { + at: new Date(time), + }, + data: notificationData, + }; + + return CoreLocalNotifications.instance.schedule(notification, AddonCalendarProvider.COMPONENT, siteId); + } + + /** + * Schedules the notifications for a list of events. + * If an event notification time is 0, cancel its scheduled notification (if any). + * 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. + * @return Promise resolved when all the notifications have been scheduled. + */ + async scheduleEventsNotifications( + events: ({ id: number; timestart: number; timeduration: number; name: string})[], + siteId?: string, + ): Promise { + + if (!CoreLocalNotifications.instance.isAvailable()) { + return; + } + + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const promises = events.map(async (event) => { + const timeEnd = (event.timestart + event.timeduration) * 1000; + + if (timeEnd <= new Date().getTime()) { + // The event has finished already, don't schedule it. + return this.deleteLocalEvent(event.id, siteId); + } + + const reminders = await this.getEventReminders(event.id, siteId); + + const p2 = reminders.map((reminder: AddonCalendarReminderDBRecord) => + this.scheduleEventNotification(event, (reminder.id!), reminder.time, siteId)); + + await Promise.all(p2); + }); + + await Promise.all(promises); + } + + /** + * 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.instance.getCurrentSiteId(); + + const key = AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_SETTING + '#' + siteId; + + await CoreConfig.instance.set(key, time); + } + + /** + * Store an event in local DB as it is. + * + * @param event Event to store. + * @param siteId ID of the site the event belongs to. If not defined, use current site. + * @return Promise resolved when stored. + */ + async storeEventInLocalDb(event: AddonCalendarGetEventsEvent | AddonCalendarCalendarEvent, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + siteId = site.getId(); + try { + await this.getEventFromLocalDb(event.id, site.id); + } catch { + // Event does not exist. Check if any reminder exists first. + const reminders = await this.getEventReminders(event.id, siteId); + + if (reminders.length == 0) { + this.addEventReminder(event, -1, siteId); + } + } + + // Don't store data that can be calculated like formattedtime, iscategoryevent, etc. + let eventRecord: AddonCalendarEventDBRecord = { + id: event.id, + name: event.name, + description: event.description || '', + eventtype: event.eventtype, + timestart: event.timestart, + timeduration: event.timeduration, + categoryid: event.categoryid, + groupid: event.groupid, + userid: event.userid, + instance: event.instance, + modulename: event.modulename, + timemodified: event.timemodified, + repeatid: event.repeatid, + visible: event.visible, + }; + + if ('descriptionformat' in event) { + eventRecord = Object.assign(eventRecord, { + courseid: event.course?.id, + location: event.location, + eventcount: event.eventcount, + timesort: event.timesort, + category: event.category ? JSON.stringify(event.category) : undefined, + course: event.course ? JSON.stringify(event.course) : undefined, + subscription: event.subscription ? JSON.stringify(event.subscription) : undefined, + canedit: event.canedit ? 1 : 0, + candelete: event.candelete ? 1 : 0, + deleteurl: event.deleteurl, + editurl: event.editurl, + viewurl: event.viewurl, + isactionevent: event.isactionevent ? 1 : 0, + url: event.url, + islastday: event.islastday ? 1 : 0, + popupname: event.popupname, + mindaytimestamp: event.mindaytimestamp, + maxdaytimestamp: event.maxdaytimestamp, + draggable: event.draggable ? 1 : 0, + }); + } else if ('uuid' in event) { + eventRecord = Object.assign(eventRecord, { + courseid: event.courseid, + uuid: event.uuid, + sequence: event.sequence, + subscriptionid: event.subscriptionid, + }); + } + + await site.getDb().insertRecord(EVENTS_TABLE, eventRecord); + } + + /** + * Store events in local DB. + * + * @param events Events to store. + * @param siteId ID of the site the event belongs to. If not defined, use current site. + * @return Promise resolved when the events are stored. + */ + protected async storeEventsInLocalDB( + events: (AddonCalendarGetEventsEvent | AddonCalendarCalendarEvent)[], + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + siteId = site.getId(); + + await Promise.all(events.map((event: AddonCalendarGetEventsEvent| AddonCalendarCalendarEvent) => + // If event does not exist on the DB, schedule the reminder. + this.storeEventInLocalDb(event, siteId))); + } + + /** + * Submit a calendar event. + * + * @param eventId ID of the 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. + * @return Promise resolved with the event and a boolean indicating if data was sent to server or stored in offline. + */ + async submitEvent( + eventId: number | undefined, + formData: AddonCalendarSubmitCreateUpdateFormDataWSParams, + timeCreated?: number, + forceOffline = false, + siteId?: string, + ): Promise<{sent: boolean; event: AddonCalendarOfflineEventDBRecord | AddonCalendarEvent}> { + + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Function to store the event to be synchronized later. + const storeOffline = (): Promise<{ sent: boolean; event: AddonCalendarOfflineEventDBRecord }> => + AddonCalendarOffline.instance.saveEvent(eventId, formData, timeCreated, siteId).then((event) => + ({ sent: false, event })); + + if (forceOffline || !CoreApp.instance.isOnline()) { + // App is offline, store the event. + return storeOffline(); + } + + if (eventId) { + // If the event is already stored, discard it first. + await AddonCalendarOffline.instance.deleteEvent(eventId, siteId); + } + try { + const event = await this.submitEventOnline(eventId, formData, siteId); + + return ({ sent: true, event }); + } catch (error) { + if (error && !CoreUtils.instance.isWebServiceError(error)) { + // Couldn't connect to server, store in offline. + return storeOffline(); + } else { + // The WebService has thrown an error, reject. + throw error; + } + } + } + + /** + * Submit an event, either to create it or to edit it. It will fail if offline or cannot connect. + * + * @param eventId ID of the event. If undefined/null, create a new event. + * @param formData Form data. + * @param siteId Site ID. If not provided, current site. + * @return Promise resolved when done. + */ + async submitEventOnline( + eventId: number = 0, + formData: AddonCalendarSubmitCreateUpdateFormDataWSParams, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + // Add data that is "hidden" in web. + formData.id = eventId; + formData.userid = site.getUserId(); + formData.visible = 1; + formData.instance = 0; + if (eventId > 0) { + formData['_qf__core_calendar_local_event_forms_update'] = 1; + } else { + formData['_qf__core_calendar_local_event_forms_create'] = 1; + } + const params: AddonCalendarSubmitCreateUpdateFormWSParams = { + formdata: CoreUtils.instance.objectToGetParams(formData), + }; + const result = + await site.write('core_calendar_submit_create_update_form', params); + if (result.validationerror) { + // Simulate a WS error. + throw new CoreWSError({ + message: Translate.instance.instant('core.invalidformdata'), + errorcode: 'validationerror', + }); + } + + return result.event!; + } + +} + +export class AddonCalendar extends makeSingleton(AddonCalendarProvider) {} + +/** + * Data returned by calendar's events_exporter. + */ +export type AddonCalendarEvents = { + events: AddonCalendarEvent[]; // Events. + firstid: number; // Firstid. + lastid: number; // Lastid. +}; + +/** + * Data returned by calendar's events_grouped_by_course_exporter. + */ +export type AddonCalendarEventsGroupedByCourse = { + groupedbycourse: AddonCalendarEventsSameCourse[]; // Groupped by course. +}; + +/** + * Data returned by calendar's events_same_course_exporter. + */ +export type AddonCalendarEventsSameCourse = AddonCalendarEvents & { + courseid: number; // Courseid. +}; + +/** + * Data returned by calendar's event_exporter_base. + */ +export type AddonCalendarEventBase = { + id: number; // Id. + name: string; // Name. + description?: string; // Description. + descriptionformat?: number; // Description format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + location?: string; // @since 3.6. Location. + categoryid?: number; // Categoryid. + groupid?: number; // Groupid. + userid?: number; // Userid. + repeatid?: number; // Repeatid. + eventcount?: number; // Eventcount. + modulename?: string; // Modulename. + instance?: number; // Instance. + eventtype: AddonCalendarEventType; // Eventtype. + timestart: number; // Timestart. + timeduration: number; // Timeduration. + timesort: number; // Timesort. + visible: number; // Visible. + timemodified: number; // Timemodified. + icon: { + key: string; // Key. + component: string; // Component. + alttext: string; // Alttext. + }; + category?: { + id: number; // Id. + name: string; // Name. + idnumber: string; // Idnumber. + description?: string; // Description. + parent: number; // Parent. + coursecount: number; // Coursecount. + visible: number; // Visible. + timemodified: number; // Timemodified. + depth: number; // Depth. + nestedname: string; // Nestedname. + url: string; // Url. + }; + course?: { + id: number; // Id. + fullname: string; // Fullname. + shortname: string; // Shortname. + idnumber: string; // Idnumber. + summary: string; // Summary. + summaryformat: number; // Summary format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + startdate: number; // Startdate. + enddate: number; // Enddate. + visible: boolean; // @since 3.8. Visible. + fullnamedisplay: string; // Fullnamedisplay. + viewurl: string; // Viewurl. + courseimage: string; // @since 3.6. Courseimage. + progress?: number; // @since 3.6. Progress. + hasprogress: boolean; // @since 3.6. Hasprogress. + isfavourite: boolean; // @since 3.6. Isfavourite. + hidden: boolean; // @since 3.6. Hidden. + timeaccess?: number; // @since 3.6. Timeaccess. + showshortname: boolean; // @since 3.6. Showshortname. + coursecategory: string; // @since 3.7. Coursecategory. + }; + subscription?: { + displayeventsource: boolean; // Displayeventsource. + subscriptionname?: string; // Subscriptionname. + subscriptionurl?: string; // Subscriptionurl. + }; + canedit: boolean; // Canedit. + candelete: boolean; // Candelete. + deleteurl: string; // Deleteurl. + editurl: string; // Editurl. + viewurl: string; // Viewurl. + formattedtime: string; // Formattedtime. + isactionevent: boolean; // Isactionevent. + iscourseevent: boolean; // Iscourseevent. + iscategoryevent: boolean; // Iscategoryevent. + groupname?: string; // Groupname. + normalisedeventtype: string; // @since 3.7. Normalisedeventtype. + normalisedeventtypetext: string; // @since 3.7. Normalisedeventtypetext. +}; + +/** + * Data returned by calendar's event_exporter. Don't confuse it with AddonCalendarCalendarEvent. + */ +export type AddonCalendarEvent = AddonCalendarEventBase & { + url: string; // Url. + action?: { + name: string; // Name. + url: string; // Url. + itemcount: number; // Itemcount. + actionable: boolean; // Actionable. + showitemcount: boolean; // Showitemcount. + }; +}; + +/** + * Data returned by calendar's calendar_event_exporter. Don't confuse it with AddonCalendarEvent. + */ +export type AddonCalendarCalendarEvent = AddonCalendarEventBase & { + url: string; // Url. + islastday: boolean; // Islastday. + popupname: string; // Popupname. + mindaytimestamp?: number; // Mindaytimestamp. + mindayerror?: string; // Mindayerror. + maxdaytimestamp?: number; // Maxdaytimestamp. + maxdayerror?: string; // Maxdayerror. + draggable: boolean; // Draggable. +}; + +/** + * Any of the possible types of events. + */ +export type AddonCalendarAnyEvent = AddonCalendarGetEventsEvent | AddonCalendarEvent | AddonCalendarCalendarEvent; + +/** + * Data returned by calendar's calendar_day_exporter. Don't confuse it with AddonCalendarDay. + */ +export type AddonCalendarCalendarDay = { + events: AddonCalendarCalendarEvent[]; // Events. + defaulteventcontext: number; // Defaulteventcontext. + // eslint-disable-next-line @typescript-eslint/naming-convention + filter_selector: string; // Filter_selector. + courseid: number; // Courseid. + categoryid?: number; // Categoryid. + neweventtimestamp: number; // Neweventtimestamp. + date: CoreWSDate; + periodname: string; // Periodname. + previousperiod: CoreWSDate; + previousperiodlink: string; // Previousperiodlink. + previousperiodname: string; // Previousperiodname. + nextperiod: CoreWSDate; + nextperiodname: string; // Nextperiodname. + nextperiodlink: string; // Nextperiodlink. + larrow: string; // Larrow. + rarrow: string; // Rarrow. +}; + + +/** + * Params of core_calendar_get_calendar_monthly_view WS. + */ +export type AddonCalendarGetCalendarMonthlyViewWSParams = { + year: number; // Year to be viewed. + month: number; // Month to be viewed. + courseid?: number; // Course being viewed. + categoryid?: number; // Category being viewed. + includenavigation?: boolean; // Whether to show course navigation. + mini?: boolean; // Whether to return the mini month view or not. + day?: number; // Day to be viewed. +}; + +/** + * Data returned by calendar's month_exporter and core_calendar_get_calendar_monthly_view WS. + */ +export type AddonCalendarMonth = { + url: string; // Url. + courseid: number; // Courseid. + categoryid?: number; // Categoryid. + // eslint-disable-next-line @typescript-eslint/naming-convention + filter_selector?: string; // Filter_selector. + weeks: AddonCalendarWeek[]; // Weeks. + daynames: AddonCalendarDayName[]; // Daynames. + view: string; // View. + date: CoreWSDate; + periodname: string; // Periodname. + includenavigation: boolean; // Includenavigation. + initialeventsloaded: boolean; // @since 3.5. Initialeventsloaded. + previousperiod: CoreWSDate; + previousperiodlink: string; // Previousperiodlink. + previousperiodname: string; // Previousperiodname. + nextperiod: CoreWSDate; + nextperiodname: string; // Nextperiodname. + nextperiodlink: string; // Nextperiodlink. + larrow: string; // Larrow. + rarrow: string; // Rarrow. + defaulteventcontext: number; // Defaulteventcontext. +}; + +/** + * Data returned by calendar's week_exporter. + */ +export type AddonCalendarWeek = { + prepadding: number[]; // Prepadding. + postpadding: number[]; // Postpadding. + days: AddonCalendarWeekDay[]; // Days. +}; + +/** + * Data returned by calendar's week_day_exporter. + */ +export type AddonCalendarWeekDay = AddonCalendarDay & { + istoday: boolean; // Istoday. + isweekend: boolean; // Isweekend. + popovertitle: string; // Popovertitle. + ispast?: boolean; // Calculated in the app. Whether the day is in the past. + filteredEvents?: AddonCalendarEventToDisplay[]; // Calculated in the app. Filtered events. + eventsFormated?: AddonCalendarEventToDisplay[]; // Events. +}; + +/** + * Data returned by calendar's day_exporter. Don't confuse it with AddonCalendarCalendarDay. + */ +export type AddonCalendarDay = { + seconds: number; // Seconds. + minutes: number; // Minutes. + hours: number; // Hours. + mday: number; // Mday. + wday: number; // Wday. + year: number; // Year. + yday: number; // Yday. + timestamp: number; // Timestamp. + neweventtimestamp: number; // Neweventtimestamp. + viewdaylink?: string; // Viewdaylink. + events: AddonCalendarCalendarEvent[]; // Events. + hasevents: boolean; // Hasevents. + calendareventtypes: AddonCalendarEventType[]; // Calendareventtypes. + previousperiod: number; // Previousperiod. + nextperiod: number; // Nextperiod. + navigation: string; // Navigation. + haslastdayofevent: boolean; // Haslastdayofevent. +}; + +/** + * Data returned by calendar's day_name_exporter. + */ +export type AddonCalendarDayName = { + dayno: number; // Dayno. + shortname: string; // Shortname. + fullname: string; // Fullname. +}; + + +/** + * Params of core_calendar_get_calendar_upcoming_view WS. + */ +type AddonCalendarGetCalendarUpcomingViewWSParams = { + courseid?: number; // Course being viewed. + categoryid?: number; // Category being viewed. +}; + +/** + * Data returned by calendar's calendar_upcoming_exporter and core_calendar_get_calendar_upcoming_view WS. + */ +export type AddonCalendarUpcoming = { + events: AddonCalendarCalendarEvent[]; // Events. + defaulteventcontext: number; // Defaulteventcontext. + // eslint-disable-next-line @typescript-eslint/naming-convention + filter_selector: string; // Filter_selector. + courseid: number; // Courseid. + categoryid?: number; // Categoryid. + isloggedin: boolean; // Isloggedin. + date: CoreWSDate; // @since 3.8. Date. +}; + +/** + * Params of core_calendar_get_calendar_access_information WS. + */ +type AddonCalendarGetCalendarAccessInformationWSParams = { + courseid?: number; // Course to check, empty for site calendar events. +}; + +/** + * Data returned by core_calendar_get_calendar_access_information WS. + */ +export type AddonCalendarGetCalendarAccessInformationWSResponse = { + canmanageentries: boolean; // Whether the user can manage entries. + canmanageownentries: boolean; // Whether the user can manage its own entries. + canmanagegroupentries: boolean; // Whether the user can manage group entries. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of core_calendar_get_allowed_event_types WS. + */ +type AddonCalendarGetAllowedEventTypesWSParams = { + courseid?: number; // Course to check, empty for site. +}; + +/** + * Data returned by core_calendar_get_allowed_event_types WS. + */ +export type AddonCalendarGetAllowedEventTypesWSResponse = { + allowedeventtypes: AddonCalendarEventType[]; + warnings?: CoreWSExternalWarning[]; +}; + + +/** + * Params of core_calendar_get_calendar_events WS. + */ +type AddonCalendarGetCalendarEventsWSParams = { + events?: { + eventids?: number[]; // List of event ids. + courseids?: number[]; // List of course ids for which events will be returned. + groupids?: number[]; // List of group ids for which events should be returned. + categoryids?: number[]; // List of category ids for which events will be returned. + }; // Event details. + options?: { + userevents?: boolean; // Set to true to return current user's user events. + siteevents?: boolean; // Set to true to return site events. + timestart?: number; // Time from which events should be returned. + timeend?: number; // Time to which the events should be returned. We treat 0 and null as no end. + ignorehidden?: boolean; // Ignore hidden events or not. + }; // Options. +}; + +/** + * Data returned by core_calendar_get_calendar_events WS. + */ +export type AddonCalendarGetCalendarEventsWSResponse = { + events: AddonCalendarGetEventsEvent[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Event data returned by WS core_calendar_get_calendar_events. + */ +export type AddonCalendarGetEventsEvent = { + id: number; // Event id. + name: string; // Event name. + description?: string; // Description. + format: number; // Description format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + courseid: number; // Course id. + categoryid?: number; // @since 3.4. Category id (only for category events). + groupid: number; // Group id. + userid: number; // User id. + repeatid: number; // Repeat id. + modulename?: string; // Module name. + instance: number; // Instance id. + eventtype: AddonCalendarEventType; // Event type. + timestart: number; // Timestart. + timeduration: number; // Time duration. + visible: number; // Visible. + uuid?: string; // Unique id of ical events. + sequence: number; // Sequence. + timemodified: number; // Time modified. + subscriptionid?: number; // Subscription id. +}; + +/** + * Params of core_calendar_get_calendar_event_by_id WS. + */ +type AddonCalendarGetCalendarEventByIdWSParams = { + eventid: number; // The event id to be retrieved. +}; + +/** + * Data returned by core_calendar_get_calendar_event_by_id WS. + */ +export type AddonCalendarGetCalendarEventByIdWSResponse = { + event: AddonCalendarEvent; // Event. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Result of WS core_calendar_submit_create_update_form. + */ +export type AddonCalendarSubmitCreateUpdateFormResult = { + event?: AddonCalendarEvent; // Event. + validationerror: boolean; // Invalid form data. +}; + +/** + * Params of core_calendar_delete_calendar_events WS. + */ +type AddonCalendarDeleteCalendarEventsWSParams = { + events: { + eventid: number; // Event ID. + repeat: boolean; // Delete comeplete series if repeated event. + }[]; +}; + +/** + * Params of core_calendar_get_calendar_day_view WS. + */ +type AddonCalendarGetCalendarDayViewWSParams = { + year: number; // Year to be viewed. + month: number; // Month to be viewed. + day: number; // Day to be viewed. + courseid?: number; // Course being viewed. + categoryid?: number; // Category being viewed. +}; + + +/** + * Params of core_calendar_submit_create_update_form WS. + */ +type AddonCalendarSubmitCreateUpdateFormWSParams = { + formdata: string; // The data from the event form. See @AddonCalendarSubmitCreateUpdateFormDataWSParams +}; + +/** + * Form data on AddonCalendarSubmitCreateUpdateFormWSParams. + */ +export type AddonCalendarSubmitCreateUpdateFormDataWSParams = Omit & { + description?: { + text: string; + format: number; + }; + visible?: number; + instance?: number; + // eslint-disable-next-line @typescript-eslint/naming-convention + _qf__core_calendar_local_event_forms_update?: number; + // eslint-disable-next-line @typescript-eslint/naming-convention + _qf__core_calendar_local_event_forms_create?: number; +}; + +/** + * Data returned by core_calendar_submit_create_update_form WS. + */ +export type AddonCalendarSubmitCreateUpdateFormWSResponse = { + event?: AddonCalendarEvent; + validationerror?: boolean; // Invalid form data. +}; + +export type AddonCalendarWeekDaysTranslationKeys = { shortname: string; fullname: string }; + +export type AddonCalendarEventToDisplay = Partial & { + id: number; + name: string; + timestart: number; + timeduration: number; + eventcount: number; + eventtype: AddonCalendarEventType; + courseid?: number; + offline?: boolean; + showDate?: boolean; // Calculated in the app. Whether date should be shown before this event. + endsSameDay?: boolean; // Calculated in the app. Whether the event finishes the same day it starts. + deleted?: boolean; // Calculated in the app. Whether it has been deleted in offline. + encodedLocation?: SafeUrl; // Calculated in the app. Sanitized location link. + eventIcon?: string; // Calculated in the app. Event icon. + moduleIcon?: string; // Calculated in the app. Module icon. + formattedType: string; // Calculated in the app. Formatted type. + duration?: number; // Calculated in the app. Duration of offline event. + format?: number; // Calculated in the app. Format of offline event. + timedurationuntil?: number; // Calculated in the app. Time duration until of offline event. + timedurationminutes?: number; // Calculated in the app. Time duration in minutes of offline event. + ispast?: boolean; // Calculated in the app. Whether the event is in the past. + contextLevel?: ContextLevel; + contextInstanceId?: number; +}; + +/** + * Event triggered when an event is modified with event types: + * NEW_EVENT_EVENT, EDIT_EVENT_EVENT, DELETED_EVENT_EVENT, UNDELETED_EVENT_EVENT. + */ +export type AddonCalendarUpdatedEventEvent = { + eventId: number; + sent?: boolean; +}; + +/** + * Additional data sent in push notifications, with some calculated data. + */ +type AddonCalendarPushNotificationData = { + eventId: number; + reminderId: number; + siteId: string; +}; diff --git a/src/addons/calendar/services/database/calendar-offline.ts b/src/addons/calendar/services/database/calendar-offline.ts new file mode 100644 index 000000000..046dc59f5 --- /dev/null +++ b/src/addons/calendar/services/database/calendar-offline.ts @@ -0,0 +1,164 @@ +// (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 { CoreSiteSchema } from '@services/sites'; +import { AddonCalendarEventType } from '../calendar'; +/** + * Database variables for AddonDatabaseOffline service. + */ +export const EVENTS_TABLE = 'addon_calendar_offline_events'; +export const DELETED_EVENTS_TABLE = 'addon_calendar_deleted_events'; +export const CALENDAR_OFFLINE_SITE_SCHEMA: CoreSiteSchema = { + name: 'AddonCalendarOfflineProvider', + version: 1, + tables: [ + { + name: EVENTS_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'name', + type: 'TEXT', + notNull: true, + }, + { + name: 'timestart', + type: 'INTEGER', + notNull: true, + }, + { + name: 'eventtype', + type: 'TEXT', + notNull: true, + }, + { + name: 'categoryid', + type: 'INTEGER', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'groupcourseid', + type: 'INTEGER', + }, + { + name: 'groupid', + type: 'INTEGER', + }, + { + name: 'description', + type: 'TEXT', + }, + { + name: 'location', + type: 'TEXT', + }, + { + name: 'duration', + type: 'INTEGER', + }, + { + name: 'timedurationuntil', + type: 'INTEGER', + }, + { + name: 'timedurationminutes', + type: 'INTEGER', + }, + { + name: 'repeat', + type: 'INTEGER', + }, + { + name: 'repeats', + type: 'INTEGER', + }, + { + name: 'repeatid', + type: 'INTEGER', + }, + { + name: 'repeateditall', + type: 'INTEGER', + }, + { + name: 'userid', + type: 'INTEGER', + }, + { + name: 'timecreated', + type: 'INTEGER', + }, + ], + }, + { + name: DELETED_EVENTS_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'name', + type: 'TEXT', + notNull: true, + }, + { + name: 'repeat', + type: 'INTEGER', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + ], + }, + ], +}; + +export type AddonCalendarOfflineEventDBRecord = { + id?: number; // Negative for offline entries. + name: string; + timestart: number; + eventtype: AddonCalendarEventType; + categoryid?: number; + courseid?: number; + groupcourseid?: number; + groupid?: number; + description?: string; + location?: string; + duration?: number; + timedurationuntil?: number; + timedurationminutes?: number; + repeat?: number; + repeats?: number; + repeatid?: number; + repeateditall?: number; + userid?: number; + timecreated?: number; +}; + +export type AddonCalendarOfflineDeletedEventDBRecord = { + id: number; + name: string; // Save the name to be able to notify the user. + repeat?: number; + timemodified?: number; +}; diff --git a/src/addons/calendar/services/database/calendar.ts b/src/addons/calendar/services/database/calendar.ts new file mode 100644 index 000000000..fd088c963 --- /dev/null +++ b/src/addons/calendar/services/database/calendar.ts @@ -0,0 +1,276 @@ +// (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 { SQLiteDB } from '@classes/sqlitedb'; +import { CoreSiteSchema } from '@services/sites'; +import { AddonCalendarEventType } from '../calendar'; + +/** + * Database variables for AddonDatabase service. + */ +export const EVENTS_TABLE = 'addon_calendar_events_3'; +export const REMINDERS_TABLE = 'addon_calendar_reminders'; +export const CALENDAR_SITE_SCHEMA: CoreSiteSchema = { + name: 'AddonCalendarProvider', + version: 3, + canBeCleared: [EVENTS_TABLE], + tables: [ + { + name: EVENTS_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'name', + type: 'TEXT', + notNull: true, + }, + { + name: 'description', + type: 'TEXT', + }, + { + name: 'eventtype', + type: 'TEXT', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'timestart', + type: 'INTEGER', + }, + { + name: 'timeduration', + type: 'INTEGER', + }, + { + name: 'categoryid', + type: 'INTEGER', + }, + { + name: 'groupid', + type: 'INTEGER', + }, + { + name: 'userid', + type: 'INTEGER', + }, + { + name: 'instance', + type: 'INTEGER', + }, + { + name: 'modulename', + type: 'TEXT', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + { + name: 'repeatid', + type: 'INTEGER', + }, + { + name: 'visible', + type: 'INTEGER', + }, + { + name: 'uuid', + type: 'TEXT', + }, + { + name: 'sequence', + type: 'INTEGER', + }, + { + name: 'subscriptionid', + type: 'INTEGER', + }, + { + name: 'location', + type: 'TEXT', + }, + { + name: 'eventcount', + type: 'INTEGER', + }, + { + name: 'timesort', + type: 'INTEGER', + }, + { + name: 'category', + type: 'TEXT', + }, + { + name: 'course', + type: 'TEXT', + }, + { + name: 'subscription', + type: 'TEXT', + }, + { + name: 'canedit', + type: 'INTEGER', + }, + { + name: 'candelete', + type: 'INTEGER', + }, + { + name: 'deleteurl', + type: 'TEXT', + }, + { + name: 'editurl', + type: 'TEXT', + }, + { + name: 'viewurl', + type: 'TEXT', + }, + { + name: 'isactionevent', + type: 'INTEGER', + }, + { + name: 'url', + type: 'TEXT', + }, + { + name: 'islastday', + type: 'INTEGER', + }, + { + name: 'popupname', + type: 'TEXT', + }, + { + name: 'mindaytimestamp', + type: 'INTEGER', + }, + { + name: 'maxdaytimestamp', + type: 'INTEGER', + }, + { + name: 'draggable', + type: 'INTEGER', + }, + ], + }, + { + name: REMINDERS_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'eventid', + type: 'INTEGER', + }, + { + name: 'time', + type: 'INTEGER', + }, + ], + uniqueKeys: [ + ['eventid', 'time'], + ], + }, + ], + async migrate(db: SQLiteDB, oldVersion: number): Promise { + if (oldVersion < 3) { + const newTable = EVENTS_TABLE; + 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.tableExists(oldTable); + + // Move the records from the old table. + const events = await db.getAllRecords(oldTable); + const promises = events.map((event) => db.insertRecord(newTable, event)); + + await Promise.all(promises); + + try { + db.dropTable(oldTable); + } catch { + // Old table does not exist, ignore. + } + } + }, +}; + +export type AddonCalendarEventDBRecord = { + id?: number; + name: string; + description: string; + eventtype: AddonCalendarEventType; + timestart: number; + timeduration: number; + categoryid?: number; + groupid?: number; + userid?: number; + instance?: number; + modulename?: string; + timemodified: number; + repeatid?: number; + visible: number; + // Following properties are only available on AddonCalendarGetEventsEvent + courseid?: number; + uuid?: string; + sequence?: number; + subscriptionid?: number; + // Following properties are only available on AddonCalendarCalendarEvent + location?: string; + eventcount?: number; + timesort?: number; + category?: string; + course?: string; + subscription?: string; + canedit?: number; + candelete?: number; + deleteurl?: string; + editurl?: string; + viewurl?: string; + isactionevent?: number; + url?: string; + islastday?: number; + popupname?: string; + mindaytimestamp?: number; + maxdaytimestamp?: number; + draggable?: number; +}; + +export type AddonCalendarReminderDBRecord = { + id?: number; + eventid: number; + time: number; +}; diff --git a/src/addons/calendar/services/handlers/mainmenu.ts b/src/addons/calendar/services/handlers/mainmenu.ts new file mode 100644 index 000000000..ebda4f8f0 --- /dev/null +++ b/src/addons/calendar/services/handlers/mainmenu.ts @@ -0,0 +1,57 @@ +// (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 { AddonCalendar } from '../calendar'; +import { makeSingleton } from '@singletons'; +import { CoreMainMenuHandler, CoreMainMenuHandlerData } from '@features/mainmenu/services/mainmenu-delegate'; + +/** + * Handler to inject an option into main menu. + */ +@Injectable({ providedIn: 'root' }) +export class AddonCalendarMainMenuHandlerService implements CoreMainMenuHandler { + + static readonly PAGE_NAME = 'calendar'; + + + name = 'AddonCalendar'; + priority = 900; + + /** + * Check if the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + async isEnabled(): Promise { + return !AddonCalendar.instance.isCalendarDisabledInSite(); + } + + /** + * Returns the data needed to render the handler. + * + * @return Data needed to render the handler. + */ + getDisplayData(): CoreMainMenuHandlerData { + return { + icon: 'far-calendar', + title: 'addon.calendar.calendar', + page: AddonCalendar.instance.getMainCalendarPagePath(), + class: 'addon-calendar-handler', + }; + } + +} + +export class AddonCalendarMainMenuHandler extends makeSingleton(AddonCalendarMainMenuHandlerService) {} diff --git a/src/addons/calendar/services/handlers/sync-cron.ts b/src/addons/calendar/services/handlers/sync-cron.ts new file mode 100644 index 000000000..a30754ec0 --- /dev/null +++ b/src/addons/calendar/services/handlers/sync-cron.ts @@ -0,0 +1,51 @@ +// (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 { CoreCronHandler } from '@services/cron'; +import { makeSingleton } from '@singletons'; +import { AddonCalendarSync } from '../calendar-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable({ providedIn: 'root' }) +export class AddonCalendarSyncCronHandlerService implements CoreCronHandler { + + name = 'AddonCalendarSyncCronHandler'; + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param siteId ID of the site affected, undefined for all sites. + * @param force Wether the execution is forced (manual sync). + * @return Promise resolved when done, rejected if failure. + */ + async execute(siteId?: string, force?: boolean): Promise { + await AddonCalendarSync.instance.syncAllEvents(siteId, force); + } + + /** + * Get the time between consecutive executions. + * + * @return Time between consecutive executions (in ms). + */ + getInterval(): number { + return AddonCalendarSync.instance.syncInterval; + } + +} + +export class AddonCalendarSyncCronHandler extends makeSingleton(AddonCalendarSyncCronHandlerService) {} diff --git a/src/addons/calendar/services/handlers/view-link.ts b/src/addons/calendar/services/handlers/view-link.ts new file mode 100644 index 000000000..1aa0bb8bd --- /dev/null +++ b/src/addons/calendar/services/handlers/view-link.ts @@ -0,0 +1,114 @@ +// (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 { Params } from '@angular/router'; +import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreNavigator } from '@services/navigator'; +import { makeSingleton } from '@singletons'; +import { AddonCalendar } from '../calendar'; + +const SUPPORTED_VIEWS = ['month', 'mini', 'minithree', 'day', 'upcoming', 'upcoming_mini']; + +/** + * Content links handler for calendar view page. + */ +@Injectable({ providedIn: 'root' }) +export class AddonCalendarViewLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonCalendarViewLinkHandler'; + pattern = /\/calendar\/view\.php/; + + /** + * Get the list of actions for a link (url). + * + * @param siteIds List of sites the URL belongs to. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @return List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: Params): CoreContentLinksAction[] | Promise { + return [{ + action: (siteId?: string): void => { + if (!params.view || params.view == 'month' || params.view == 'mini' || params.view == 'minithree') { + // Monthly view, open the calendar tab. + const stateParams: Params = { + courseId: params.course, + }; + const timestamp = params.time ? params.time * 1000 : Date.now(); + + const date = new Date(timestamp); + stateParams.year = date.getFullYear(); + stateParams.month = date.getMonth() + 1; + + // @todo: Add checkMenu param. + CoreNavigator.instance.navigateToSitePath('/calendar/index', { params: stateParams, siteId }); + + } else if (params.view == 'day') { + // Daily view, open the page. + const stateParams: Params = { + courseId: params.course, + }; + const timestamp = params.time ? params.time * 1000 : Date.now(); + + const date = new Date(timestamp); + stateParams.year = date.getFullYear(); + stateParams.month = date.getMonth() + 1; + stateParams.day = date.getDate(); + + CoreNavigator.instance.navigateToSitePath('/calendar/day', { params: stateParams, siteId }); + + } else if (params.view == 'upcoming' || params.view == 'upcoming_mini') { + // Upcoming view, open the calendar tab. + const stateParams: Params = { + courseId: params.course, + upcoming: true, + }; + + // @todo: Add checkMenu param. + CoreNavigator.instance.navigateToSitePath('/calendar/index', { params: stateParams, siteId }); + + } + }, + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param siteId The site ID. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @return Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string, url: string, params: Params): boolean | Promise { + if (params.view && SUPPORTED_VIEWS.indexOf(params.view) == -1) { + // This type of view isn't supported in the app. + return false; + } + + return AddonCalendar.instance.isDisabled(siteId).then((disabled) => { + if (disabled) { + return false; + } + + return AddonCalendar.instance.canViewMonth(siteId); + }); + } + +} + +export class AddonCalendarViewLinkHandler extends makeSingleton(AddonCalendarViewLinkHandlerService) {} diff --git a/src/assets/img/mod/assign.svg b/src/assets/img/mod/assign.svg new file mode 100644 index 000000000..41a788985 --- /dev/null +++ b/src/assets/img/mod/assign.svg @@ -0,0 +1,89 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/assignment.svg b/src/assets/img/mod/assignment.svg new file mode 100644 index 000000000..41a788985 --- /dev/null +++ b/src/assets/img/mod/assignment.svg @@ -0,0 +1,89 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/book.svg b/src/assets/img/mod/book.svg new file mode 100644 index 000000000..740a35160 --- /dev/null +++ b/src/assets/img/mod/book.svg @@ -0,0 +1,80 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/chat.svg b/src/assets/img/mod/chat.svg new file mode 100644 index 000000000..9dd304b78 --- /dev/null +++ b/src/assets/img/mod/chat.svg @@ -0,0 +1,77 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/choice.svg b/src/assets/img/mod/choice.svg new file mode 100644 index 000000000..4d455910c --- /dev/null +++ b/src/assets/img/mod/choice.svg @@ -0,0 +1,46 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/data.svg b/src/assets/img/mod/data.svg new file mode 100644 index 000000000..954777f09 --- /dev/null +++ b/src/assets/img/mod/data.svg @@ -0,0 +1,87 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/database.svg b/src/assets/img/mod/database.svg new file mode 100644 index 000000000..954777f09 --- /dev/null +++ b/src/assets/img/mod/database.svg @@ -0,0 +1,87 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/external-tool.svg b/src/assets/img/mod/external-tool.svg new file mode 100644 index 000000000..ebbbe3084 --- /dev/null +++ b/src/assets/img/mod/external-tool.svg @@ -0,0 +1,55 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/feedback.svg b/src/assets/img/mod/feedback.svg new file mode 100644 index 000000000..58d0f080b --- /dev/null +++ b/src/assets/img/mod/feedback.svg @@ -0,0 +1,133 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/file.svg b/src/assets/img/mod/file.svg new file mode 100644 index 000000000..2039a2ea2 --- /dev/null +++ b/src/assets/img/mod/file.svg @@ -0,0 +1,60 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/folder.svg b/src/assets/img/mod/folder.svg new file mode 100644 index 000000000..6c2a9fe19 --- /dev/null +++ b/src/assets/img/mod/folder.svg @@ -0,0 +1,65 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/forum.svg b/src/assets/img/mod/forum.svg new file mode 100644 index 000000000..aab9a8f44 --- /dev/null +++ b/src/assets/img/mod/forum.svg @@ -0,0 +1,71 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/glossary.svg b/src/assets/img/mod/glossary.svg new file mode 100644 index 000000000..f330727e3 --- /dev/null +++ b/src/assets/img/mod/glossary.svg @@ -0,0 +1,146 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/h5pactivity.svg b/src/assets/img/mod/h5pactivity.svg new file mode 100644 index 000000000..97fef5728 --- /dev/null +++ b/src/assets/img/mod/h5pactivity.svg @@ -0,0 +1 @@ +h5p finalArtboard 1 \ No newline at end of file diff --git a/src/assets/img/mod/ims.svg b/src/assets/img/mod/ims.svg new file mode 100644 index 000000000..5589cd0c5 --- /dev/null +++ b/src/assets/img/mod/ims.svg @@ -0,0 +1,156 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/imscp.svg b/src/assets/img/mod/imscp.svg new file mode 100644 index 000000000..5589cd0c5 --- /dev/null +++ b/src/assets/img/mod/imscp.svg @@ -0,0 +1,156 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/label.svg b/src/assets/img/mod/label.svg new file mode 100644 index 000000000..ac232fc58 --- /dev/null +++ b/src/assets/img/mod/label.svg @@ -0,0 +1,94 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/lesson.svg b/src/assets/img/mod/lesson.svg new file mode 100644 index 000000000..0a0e5dfd5 --- /dev/null +++ b/src/assets/img/mod/lesson.svg @@ -0,0 +1,126 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/lti.svg b/src/assets/img/mod/lti.svg new file mode 100644 index 000000000..ebbbe3084 --- /dev/null +++ b/src/assets/img/mod/lti.svg @@ -0,0 +1,55 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/page.svg b/src/assets/img/mod/page.svg new file mode 100644 index 000000000..eb7cae6c8 --- /dev/null +++ b/src/assets/img/mod/page.svg @@ -0,0 +1,112 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/quiz.svg b/src/assets/img/mod/quiz.svg new file mode 100644 index 000000000..90473416f --- /dev/null +++ b/src/assets/img/mod/quiz.svg @@ -0,0 +1,90 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/resource.svg b/src/assets/img/mod/resource.svg new file mode 100644 index 000000000..2039a2ea2 --- /dev/null +++ b/src/assets/img/mod/resource.svg @@ -0,0 +1,60 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/scorm.svg b/src/assets/img/mod/scorm.svg new file mode 100644 index 000000000..77891eca4 --- /dev/null +++ b/src/assets/img/mod/scorm.svg @@ -0,0 +1,84 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/survey.svg b/src/assets/img/mod/survey.svg new file mode 100644 index 000000000..a97fe77ef --- /dev/null +++ b/src/assets/img/mod/survey.svg @@ -0,0 +1,89 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/url.svg b/src/assets/img/mod/url.svg new file mode 100644 index 000000000..56bdb5541 --- /dev/null +++ b/src/assets/img/mod/url.svg @@ -0,0 +1,485 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/wiki.svg b/src/assets/img/mod/wiki.svg new file mode 100644 index 000000000..f3101ce19 --- /dev/null +++ b/src/assets/img/mod/wiki.svg @@ -0,0 +1,228 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/workshop.svg b/src/assets/img/mod/workshop.svg new file mode 100644 index 000000000..f466455a6 --- /dev/null +++ b/src/assets/img/mod/workshop.svg @@ -0,0 +1,98 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/core/features/courses/components/course-options-menu/course-options-menu.ts b/src/core/features/courses/components/course-options-menu/course-options-menu.ts index f4f977470..a767b5919 100644 --- a/src/core/features/courses/components/course-options-menu/course-options-menu.ts +++ b/src/core/features/courses/components/course-options-menu/course-options-menu.ts @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit } from '@angular/core'; -import { NavParams, PopoverController } from '@ionic/angular'; +import { Component, Input, OnInit } from '@angular/core'; +import { PopoverController } from '@ionic/angular'; import { CoreCourses } from '../../services/courses'; import { CoreEnrolledCourseDataWithExtraInfoAndOptions } from '../../services/courses-helper'; import { CorePrefetchStatusInfo } from '@features/course/services/course-helper'; @@ -27,18 +27,14 @@ import { CorePrefetchStatusInfo } from '@features/course/services/course-helper' }) export class CoreCoursesCourseOptionsMenuComponent implements OnInit { - course!: CoreEnrolledCourseDataWithExtraInfoAndOptions; // The course. - prefetch!: CorePrefetchStatusInfo; // The prefecth info. + @Input() course!: CoreEnrolledCourseDataWithExtraInfoAndOptions; // The course. + @Input() prefetch!: CorePrefetchStatusInfo; // The prefecth info. downloadCourseEnabled = false; constructor( - navParams: NavParams, protected popoverController: PopoverController, - ) { - this.course = navParams.get('course') || {}; - this.prefetch = navParams.get('prefetch') || {}; - } + ) { } /** * Component being initialized. diff --git a/src/core/features/courses/components/self-enrol-password/self-enrol-password.html b/src/core/features/courses/components/self-enrol-password/self-enrol-password.html index ee4c213f3..470dc5d13 100644 --- a/src/core/features/courses/components/self-enrol-password/self-enrol-password.html +++ b/src/core/features/courses/components/self-enrol-password/self-enrol-password.html @@ -16,7 +16,7 @@
- + { - const frontpageCourseId = CoreSites.instance.getCurrentSite()!.getSiteHomeId(); + const frontpageCourseId = CoreSites.instance.getCurrentSiteHomeId(); try { const courses = await CoreCourses.instance.getCoursesByField(); diff --git a/src/core/features/courses/services/courses-helper.ts b/src/core/features/courses/services/courses-helper.ts index d06f7b814..85633923b 100644 --- a/src/core/features/courses/services/courses-helper.ts +++ b/src/core/features/courses/services/courses-helper.ts @@ -17,7 +17,7 @@ import { Injectable } from '@angular/core'; import { CoreUtils } from '@services/utils/utils'; import { CoreSites } from '@services/sites'; import { CoreCourses, CoreCourseSearchedData, CoreCourseUserAdminOrNavOptionIndexed, CoreEnrolledCourseData } from './courses'; -import { makeSingleton } from '@singletons'; +import { makeSingleton, Translate } from '@singletons'; import { CoreWSExternalFile } from '@services/ws'; import { AddonCourseCompletion } from '@/addons/coursecompletion/services/coursecompletion'; // import { CoreCoursePickerMenuPopoverComponent } from '@components/course-picker-menu/course-picker-menu-popover'; @@ -34,8 +34,30 @@ export class CoreCoursesHelperProvider { * @param courseId Course ID to get the category. * @return Promise resolved with the list of courses and the category. */ - async getCoursesForPopover(): Promise { - // @todo params and logic + async getCoursesForPopover(courseId?: number): Promise<{courses: Partial[]; categoryId?: number}> { + const courses: Partial[] = await CoreCourses.instance.getUserCourses(false); + + // Add "All courses". + courses.unshift({ + id: -1, + fullname: Translate.instance.instant('core.fulllistofcourses'), + categoryid: -1, + }); + + let categoryId: number | undefined; + if (courseId) { + // Search the course to get the category. + const course = courses.find((course) => course.id == courseId); + + if (course) { + categoryId = course.categoryid; + } + } + + return { + courses: courses, + categoryId: categoryId, + }; } /** diff --git a/src/core/features/login/pages/credentials/credentials.html b/src/core/features/login/pages/credentials/credentials.html index 498b4f7ec..5c0e22e74 100644 --- a/src/core/features/login/pages/credentials/credentials.html +++ b/src/core/features/login/pages/credentials/credentials.html @@ -39,7 +39,7 @@ - + diff --git a/src/core/features/login/pages/email-signup/email-signup.html b/src/core/features/login/pages/email-signup/email-signup.html index 95378a395..5fafd20e2 100644 --- a/src/core/features/login/pages/email-signup/email-signup.html +++ b/src/core/features/login/pages/email-signup/email-signup.html @@ -106,7 +106,7 @@ {{ 'core.login.password' | translate }} - + diff --git a/src/core/features/login/pages/reconnect/reconnect.html b/src/core/features/login/pages/reconnect/reconnect.html index ad4887437..2f3b4dfda 100644 --- a/src/core/features/login/pages/reconnect/reconnect.html +++ b/src/core/features/login/pages/reconnect/reconnect.html @@ -40,7 +40,7 @@ - + diff --git a/src/core/features/sitehome/pages/index/index.ts b/src/core/features/sitehome/pages/index/index.ts index e4beca68f..905c2a6f8 100644 --- a/src/core/features/sitehome/pages/index/index.ts +++ b/src/core/features/sitehome/pages/index/index.ts @@ -81,7 +81,7 @@ export class CoreSiteHomeIndexPage implements OnInit, OnDestroy { }, CoreSites.instance.getCurrentSiteId()); this.currentSite = CoreSites.instance.getCurrentSite()!; - this.siteHomeId = this.currentSite?.getSiteHomeId() || 1; + this.siteHomeId = CoreSites.instance.getCurrentSiteHomeId(); const module = navParams['module']; if (module) { diff --git a/src/core/features/sitehome/services/sitehome.ts b/src/core/features/sitehome/services/sitehome.ts index 9fd56c6a5..668891af7 100644 --- a/src/core/features/sitehome/services/sitehome.ts +++ b/src/core/features/sitehome/services/sitehome.ts @@ -47,7 +47,7 @@ export class CoreSiteHomeProvider { */ async getNewsForum(siteHomeId?: number): Promise { if (!siteHomeId) { - siteHomeId = CoreSites.instance.getCurrentSite()?.getSiteHomeId() || 1; + siteHomeId = CoreSites.instance.getCurrentSiteHomeId(); } const forums = await AddonModForum.instance.getCourseForums(siteHomeId); diff --git a/src/core/features/tag/pages/index/index.page.ts b/src/core/features/tag/pages/index/index.page.ts index 9366303b1..3dc89b254 100644 --- a/src/core/features/tag/pages/index/index.page.ts +++ b/src/core/features/tag/pages/index/index.page.ts @@ -17,7 +17,7 @@ import { IonRefresher } from '@ionic/angular'; import { CoreDomUtils } from '@services/utils/dom'; // import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { CoreTag } from '@features/tag/services/tag'; -import { CoreTagAreaDelegate } from '@/core/features/tag/services/tag-area-delegate'; +import { CoreTagAreaDelegate } from '@features/tag/services/tag-area-delegate'; import { ActivatedRoute, Router } from '@angular/router'; import { CoreTagFeedElement } from '../../services/tag-helper'; diff --git a/src/core/features/tag/services/handlers/index.link.ts b/src/core/features/tag/services/handlers/index-link.ts similarity index 100% rename from src/core/features/tag/services/handlers/index.link.ts rename to src/core/features/tag/services/handlers/index-link.ts diff --git a/src/core/features/tag/services/handlers/tag.mainmenu.ts b/src/core/features/tag/services/handlers/mainmenu.ts similarity index 100% rename from src/core/features/tag/services/handlers/tag.mainmenu.ts rename to src/core/features/tag/services/handlers/mainmenu.ts diff --git a/src/core/features/tag/services/handlers/search.link.ts b/src/core/features/tag/services/handlers/search-link.ts similarity index 100% rename from src/core/features/tag/services/handlers/search.link.ts rename to src/core/features/tag/services/handlers/search-link.ts diff --git a/src/core/features/tag/tag.module.ts b/src/core/features/tag/tag.module.ts index ec058f983..45db517af 100644 --- a/src/core/features/tag/tag.module.ts +++ b/src/core/features/tag/tag.module.ts @@ -17,9 +17,9 @@ import { Routes } from '@angular/router'; import { CoreMainMenuDelegate } from '@features/mainmenu/services/mainmenu-delegate'; import { CoreMainMenuRoutingModule } from '../mainmenu/mainmenu-routing.module'; import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate'; -import { CoreTagMainMenuHandler, CoreTagMainMenuHandlerService } from './services/handlers/tag.mainmenu'; -import { CoreTagIndexLinkHandler } from './services/handlers/index.link'; -import { CoreTagSearchLinkHandler } from './services/handlers/search.link'; +import { CoreTagMainMenuHandler, CoreTagMainMenuHandlerService } from './services/handlers/mainmenu'; +import { CoreTagIndexLinkHandler } from './services/handlers/index-link'; +import { CoreTagSearchLinkHandler } from './services/handlers/search-link'; const routes: Routes = [ { diff --git a/src/core/features/user/components/tag-area/tag-area.ts b/src/core/features/user/components/tag-area/tag-area.ts index 116ecb0dc..279109892 100644 --- a/src/core/features/user/components/tag-area/tag-area.ts +++ b/src/core/features/user/components/tag-area/tag-area.ts @@ -14,7 +14,7 @@ import { Component, Input } from '@angular/core'; -import { CoreUserTagFeedElement } from '@features/user/services/handlers/tag-area-handler'; +import { CoreUserTagFeedElement } from '@features/user/services/handlers/tag-area'; /** * Component to render the user tag area. diff --git a/src/core/features/user/pages/about/about.page.ts b/src/core/features/user/pages/about/about.page.ts index bd2dd5c8b..549deaf40 100644 --- a/src/core/features/user/pages/about/about.page.ts +++ b/src/core/features/user/pages/about/about.page.ts @@ -58,8 +58,8 @@ export class CoreUserAboutPage implements OnInit { * @return Promise resolved when done. */ async ngOnInit(): Promise { - this.userId = this.route.snapshot.queryParams['userId']; - this.courseId = this.route.snapshot.queryParams['courseId']; + this.userId = parseInt(this.route.snapshot.queryParams['userId'], 10) || 0; + this.courseId = parseInt(this.route.snapshot.queryParams['courseId'], 10) || 0; this.fetchUser().finally(() => { this.userLoaded = true; diff --git a/src/core/features/user/pages/profile/profile.page.ts b/src/core/features/user/pages/profile/profile.page.ts index f4e29d3bb..a932a4645 100644 --- a/src/core/features/user/pages/profile/profile.page.ts +++ b/src/core/features/user/pages/profile/profile.page.ts @@ -81,8 +81,8 @@ export class CoreUserProfilePage implements OnInit, OnDestroy { */ async ngOnInit(): Promise { this.site = CoreSites.instance.getCurrentSite(); - this.userId = this.route.snapshot.queryParams['userId']; - this.courseId = this.route.snapshot.queryParams['courseId']; + this.userId = parseInt(this.route.snapshot.queryParams['userId'], 10); + this.courseId = parseInt(this.route.snapshot.queryParams['courseId'], 10); if (!this.site) { return; diff --git a/src/core/features/user/services/handlers/tag-area-handler.ts b/src/core/features/user/services/handlers/tag-area.ts similarity index 100% rename from src/core/features/user/services/handlers/tag-area-handler.ts rename to src/core/features/user/services/handlers/tag-area.ts diff --git a/src/core/features/user/user.module.ts b/src/core/features/user/user.module.ts index 8d0528ce9..e0bdc99cb 100644 --- a/src/core/features/user/user.module.ts +++ b/src/core/features/user/user.module.ts @@ -25,7 +25,7 @@ import { CoreContentLinksDelegate } from '@features/contentlinks/services/conten import { CoreUserProfileLinkHandler } from './services/handlers/profile-link'; import { CoreCronDelegate } from '@services/cron'; import { CoreUserSyncCronHandler } from './services/handlers/sync-cron'; -import { CoreUserTagAreaHandler } from './services/handlers/tag-area-handler'; +import { CoreUserTagAreaHandler } from './services/handlers/tag-area'; import { CoreTagAreaDelegate } from '@features/tag/services/tag-area-delegate'; const routes: Routes = [ diff --git a/src/core/pipes/duration.ts b/src/core/pipes/duration.ts new file mode 100644 index 000000000..45d343f69 --- /dev/null +++ b/src/core/pipes/duration.ts @@ -0,0 +1,54 @@ +// (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'; +import { CoreLogger } from '@singletons/logger'; +import { CoreTimeUtils } from '@services/utils/time'; + +/** + * Filter to turn a number of seconds to a duration. E.g. 60 -> 1 minute. + */ +@Pipe({ + name: 'coreDuration', +}) +export class CoreDurationPipe implements PipeTransform { + + protected logger: CoreLogger; + + constructor() { + this.logger = CoreLogger.getInstance('CoreBytesToSizePipe'); + } + + /** + * Turn a number of seconds to a duration. E.g. 60 -> 1 minute. + * + * @param seconds The number of seconds. + * @return Formatted duration. + */ + transform(seconds: string | number): string { + if (typeof seconds == 'string') { + // Convert the value to a number. + const numberSeconds = parseInt(seconds, 10); + if (isNaN(numberSeconds)) { + this.logger.error('Invalid value received', seconds); + + return seconds; + } + seconds = numberSeconds; + } + + return CoreTimeUtils.instance.formatTime(seconds); + } + +} diff --git a/src/core/pipes/pipes.module.ts b/src/core/pipes/pipes.module.ts index 301300530..e27839889 100644 --- a/src/core/pipes/pipes.module.ts +++ b/src/core/pipes/pipes.module.ts @@ -19,6 +19,7 @@ import { CoreNoTagsPipe } from './no-tags'; import { CoreSecondsToHMSPipe } from './seconds-to-hms'; import { CoreTimeAgoPipe } from './time-ago'; import { CoreBytesToSizePipe } from './bytes-to-size'; +import { CoreDurationPipe } from './duration'; @NgModule({ declarations: [ @@ -28,6 +29,7 @@ import { CoreBytesToSizePipe } from './bytes-to-size'; CoreFormatDatePipe, CoreBytesToSizePipe, CoreSecondsToHMSPipe, + CoreDurationPipe, ], imports: [], exports: [ @@ -37,6 +39,7 @@ import { CoreBytesToSizePipe } from './bytes-to-size'; CoreFormatDatePipe, CoreBytesToSizePipe, CoreSecondsToHMSPipe, + CoreDurationPipe, ], }) export class CorePipesModule {} diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index 9e9db3227..e3a286976 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -1300,8 +1300,14 @@ export class CoreDomUtilsProvider { * @param options More options. * @return Promise resolved if the user confirms and rejected with a canceled error if he cancels. */ - showConfirm(message: string, header?: string, okText?: string, cancelText?: string, options: AlertOptions = {}): Promise { - return new Promise((resolve, reject): void => { + showConfirm( + message: string, + header?: string, + okText?: string, + cancelText?: string, + options: AlertOptions = {}, + ): Promise { + return new Promise((resolve, reject): void => { options.header = header; options.message = message; @@ -1315,8 +1321,8 @@ export class CoreDomUtilsProvider { }, { text: okText || Translate.instance.instant('core.ok'), - handler: () => { - resolve(); + handler: (data: T) => { + resolve(data); }, }, ]; diff --git a/src/core/services/utils/utils.ts b/src/core/services/utils/utils.ts index 16342150d..da0f1dc72 100644 --- a/src/core/services/utils/utils.ts +++ b/src/core/services/utils/utils.ts @@ -94,6 +94,16 @@ export class CoreUtilsProvider { } } + /** + * Combination of allPromises and ignoreErrors functions. + * + * @param promises Promises. + * @return Promise resolved if all promises are resolved and rejected if at least 1 promise fails. + */ + async allPromisesIgnoringErrors(promises: Promise[]): Promise { + await CoreUtils.instance.ignoreErrors(this.allPromises(promises)); + } + /** * Converts an array of objects to an object, using a property of each entry as the key. * It can also be used to convert an array of strings to an object where the keys are the elements of the array. @@ -1198,6 +1208,13 @@ export class CoreUtilsProvider { return newObj; } + /** + * Function to enumerate enum keys. + */ + enumKeys(enumeration: O): K[] { + return Object.keys(enumeration).filter(k => Number.isNaN(+k)) as K[]; + } + /** * Similar to AngularJS $q.defer(). * @@ -1434,12 +1451,12 @@ export class CoreUtilsProvider { const value = key ? entry[key] : entry; if (value in unique) { - unique[value] = true; - - return true; + return false; } - return false; + unique[value] = true; + + return true; }); } diff --git a/src/theme/app.scss b/src/theme/app.scss index c42f9f5bd..12f1c8a3e 100644 --- a/src/theme/app.scss +++ b/src/theme/app.scss @@ -8,6 +8,7 @@ ion-item.ion-text-wrap ion-label { ion-toolbar ion-back-button, ion-toolbar .in-toolbar.button-clear { --color: var(--ion-color-primary-contrast); + --ion-toolbar-color: var(--ion-color-primary-contrast); } ion-toolbar .core-navbar-button-hidden { @@ -71,7 +72,6 @@ ion-alert.core-nohead { // Ionic item divider. ion-item-divider { - --background: var(--gray-lighter); .item-detail-icon { font-size: 20px; opacity: 0.25; @@ -178,25 +178,49 @@ ion-card.core-success-card, ion-card.core-warning-card, ion-card.core-danger-card { border-bottom: 3px solid transparent; + ion-item::part(native) { + --inner-border-width: 0; + } + ion-label { + white-space: normal !important; + } } ion-card.core-primary-card { border-bottom-color: var(--ion-color-primary); + ion-icon { + color: var(--ion-color-primary); + } } ion-card.core-info-card, ion-card.core-secondary-card { border-bottom-color: var(--ion-color-secondary); + ion-icon { + color: var(--ion-color-secondary); + } } ion-card.core-tertiary-card { border-bottom-color: var(--ion-color-tertiary); + ion-icon { + color: var(--ion-color-tertiary); + } } ion-card.core-success-card { border-bottom-color: var(--ion-color-success); + ion-icon { + color: var(--ion-color-success); + } } ion-card.core-warning-card { border-bottom-color: var(--ion-color-warning); + ion-icon { + color: var(--ion-color-warning); + } } ion-card.core-danger-card { border-bottom-color: var(--ion-color-danger); + ion-icon { + color: var(--ion-color-danger); + } } // Avatar @@ -227,6 +251,18 @@ ion-avatar ion-img, ion-avatar img { background-color: var(--gray-light); } +// Activity modules +ion-item img.core-module-icon[slot="start"] { + margin-top: 12px; + margin-bottom: 12px; + margin-right: 32px; + padding: 6px; +} +[dir=rtl] ion-item img.core-module-icon[slot="start"] { + margin-right: unset; + margin-left: 32px; +} + // Action sheet. .md ion-action-sheet { .action-sheet-group-cancel { diff --git a/src/theme/variables.scss b/src/theme/variables.scss index 25dd057bc..6e85594df 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -94,6 +94,7 @@ ion-content { --background: var(--gray-light); + --contrast-background: var(--white); } ion-tab-bar { @@ -156,6 +157,10 @@ --side-blocks-min-width: var(--custom-side-blocks-min-width, 280px); } + ion-item-divider { + --background: var(--gray-lighter); + } + --selected-item-color: var(--custom-selected-item-color, var(--core-color)); --selected-item-border-width: var(--custom-selected-item-border-width, 5px); @@ -179,6 +184,15 @@ --core-large-avatar-size: var(--custom-large-avatar-size, 90px); --core-avatar-size: var(--custom-avatar-size, 64px); + + --addon-calendar-event-category-color: var(--custom-calendar-event-category-color, var(--purple)); + --addon-calendar-event-course-color: var(--custom-calendar-event-course-color, var(--red)); + --addon-calendar-event-group-color: var(--custom-calendar-event-group-color, var(--yellow)); + --addon-calendar-event-user-color: var(--custom-calendar-event-user-color, var(--blue)); + --addon-calendar-event-site-color: var(--custom-calendar-event-site-color, var(--green)); + --addon-calendar-today-bgcolor: var(--custom-calendar-today-bgcolor, var(--core-color)); + --addon-calendar-today-color: var(--custom-calendar-today-color, var(--white)); + --addon-calendar-border-color: var(--custom-calendar-border-color, var(--gray)); } /* @@ -222,6 +236,7 @@ ion-content { --background: var(--ion-background-color); + --contrast-background: var(--ion-background-color); } core-tabs { @@ -237,6 +252,14 @@ --text-color: var(--custom-progress-text-color, var(--gray-lighter)); } + ion-item-divider { + --background: var(--black); + --color: var(--white); + .item-detail-icon { + color: var(--white); + } + } + --core-login-background: var(--custom-login-background, #3a3a3a); --core-login-text-color: var(--custom-login-text-color, white); }