commit
						efece1d4ac
					
				| @ -13,13 +13,13 @@ | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Params } from '@angular/router'; | ||||
| import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; | ||||
| import { CoreRoutedItemsManagerSource } from '@classes/items-management/routed-items-manager-source'; | ||||
| import { AddonBadges, AddonBadgesUserBadge } from '../services/badges'; | ||||
| 
 | ||||
| /** | ||||
|  * Provides a collection of user badges. | ||||
|  */ | ||||
| export class AddonBadgesUserBadgesSource extends CoreItemsManagerSource<AddonBadgesUserBadge> { | ||||
| export class AddonBadgesUserBadgesSource extends CoreRoutedItemsManagerSource<AddonBadgesUserBadge> { | ||||
| 
 | ||||
|     readonly COURSE_ID: number; | ||||
|     readonly USER_ID: number; | ||||
|  | ||||
| @ -23,9 +23,9 @@ import { CoreUtils } from '@services/utils/utils'; | ||||
| import { CoreCourses, CoreEnrolledCourseData } from '@features/courses/services/courses'; | ||||
| import { CoreNavigator } from '@services/navigator'; | ||||
| import { ActivatedRoute } from '@angular/router'; | ||||
| import { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; | ||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||
| import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager'; | ||||
| import { AddonBadgesUserBadgesSource } from '@addons/badges/classes/user-badges-source'; | ||||
| import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; | ||||
| 
 | ||||
| /** | ||||
|  * Page that displays the list of calendar events. | ||||
| @ -43,7 +43,7 @@ export class AddonBadgesIssuedBadgePage implements OnInit { | ||||
|     user?: CoreUserProfile; | ||||
|     course?: CoreEnrolledCourseData; | ||||
|     badge?: AddonBadgesUserBadge; | ||||
|     badges?: CoreSwipeItemsManager; | ||||
|     badges?: CoreSwipeNavigationItemsManager; | ||||
|     badgeLoaded = false; | ||||
|     currentTime = 0; | ||||
| 
 | ||||
| @ -61,8 +61,11 @@ export class AddonBadgesIssuedBadgePage implements OnInit { | ||||
|             this.badgeLoaded = true; | ||||
|         }); | ||||
| 
 | ||||
|         const source = CoreItemsManagerSourcesTracker.getOrCreateSource(AddonBadgesUserBadgesSource, [this.courseId, this.userId]); | ||||
|         this.badges = new CoreSwipeItemsManager(source); | ||||
|         const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( | ||||
|             AddonBadgesUserBadgesSource, | ||||
|             [this.courseId, this.userId], | ||||
|         ); | ||||
|         this.badges = new CoreSwipeNavigationItemsManager(source); | ||||
| 
 | ||||
|         this.badges.start(); | ||||
|     } | ||||
|  | ||||
| @ -23,7 +23,7 @@ import { CoreSplitViewComponent } from '@components/split-view/split-view'; | ||||
| import { CoreNavigator } from '@services/navigator'; | ||||
| import { CoreListItemsManager } from '@classes/items-management/list-items-manager'; | ||||
| import { AddonBadgesUserBadgesSource } from '@addons/badges/classes/user-badges-source'; | ||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||
| import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; | ||||
| 
 | ||||
| /** | ||||
|  * Page that displays the list of calendar events. | ||||
| @ -49,7 +49,7 @@ export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy { | ||||
|         } | ||||
| 
 | ||||
|         this.badges = new CoreListItemsManager( | ||||
|             CoreItemsManagerSourcesTracker.getOrCreateSource(AddonBadgesUserBadgesSource, [courseId, userId]), | ||||
|             CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(AddonBadgesUserBadgesSource, [courseId, userId]), | ||||
|             AddonBadgesUserBadgesPage, | ||||
|         ); | ||||
|     } | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| <!-- Add buttons to the nav bar. --> | ||||
| <core-navbar-buttons slot="end" prepend> | ||||
|     <core-context-menu> | ||||
|         <core-context-menu-item *ngIf="canNavigate && !isCurrentMonth && displayNavButtons" [priority]="900" | ||||
|         <core-context-menu-item *ngIf="canNavigate && !selectedMonthIsCurrent() && displayNavButtons" [priority]="900" | ||||
|             [content]="'addon.calendar.currentmonth' | translate" iconAction="fas-calendar-day" (action)="goToCurrentMonth()"> | ||||
|         </core-context-menu-item> | ||||
|     </core-context-menu> | ||||
| @ -17,7 +17,11 @@ | ||||
|                 </ion-button> | ||||
|             </ion-col> | ||||
|             <ion-col class="ion-text-center addon-calendar-period"> | ||||
|                 <h2 id="addon-calendar-monthname">{{ periodName }}</h2> | ||||
|                 <h2 id="addon-calendar-monthname"> | ||||
|                     {{ periodName }} | ||||
|                     <ion-spinner *ngIf="!selectedMonthLoaded()" class="addon-calendar-loading-month"> | ||||
|                     </ion-spinner> | ||||
|                 </h2> | ||||
|             </ion-col> | ||||
|             <ion-col class="ion-text-end" *ngIf="canNavigate"> | ||||
|                 <ion-button fill="clear" (click)="loadNext()" [attr.aria-label]="'core.next' | translate"> | ||||
| @ -27,12 +31,14 @@ | ||||
|         </ion-row> | ||||
|     </ion-grid> | ||||
| 
 | ||||
|     <core-swipe-slides [manager]="manager"> | ||||
|         <ng-template let-month="item"> | ||||
|             <!-- Calendar view. --> | ||||
|             <ion-grid class="addon-calendar-months" role="table" aria-describedby="addon-calendar-monthname"> | ||||
|                 <div role="rowgroup"> | ||||
|                     <!-- List of days. --> | ||||
|                     <ion-row role="row"> | ||||
|                 <ion-col class="ion-text-center addon-calendar-weekday" *ngFor="let day of weekDays" role="columnheader"> | ||||
|                         <ion-col class="ion-text-center addon-calendar-weekday" *ngFor="let day of month.weekDays" role="columnheader"> | ||||
|                             <span class="sr-only">{{ day.fullname | translate }}</span> | ||||
|                             <span class="ion-hide-md-up" aria-hidden="true">{{ day.shortname | translate }}</span> | ||||
|                             <span class="ion-hide-md-down" aria-hidden="true">{{ day.fullname | translate }}</span> | ||||
| @ -40,17 +46,17 @@ | ||||
|                     </ion-row> | ||||
|                 </div> | ||||
|                 <div role="rowgroup"> | ||||
| 
 | ||||
|                     <!-- Weeks. --> | ||||
|             <ion-row *ngFor="let week of weeks" class="addon-calendar-week" role="row"> | ||||
|                     <ion-row *ngFor="let week of month.weeks" class="addon-calendar-week" role="row"> | ||||
|                         <!-- Empty slots (first week). --> | ||||
|                 <ion-col *ngFor="let value of week.prepadding" class="dayblank addon-calendar-day" role="cell"></ion-col> | ||||
|                         <ion-col *ngFor="let value of week.prepadding" class="dayblank addon-calendar-day" role="cell"> | ||||
|                         </ion-col> | ||||
|                         <ion-col *ngFor="let day of week.days" class="addon-calendar-day ion-text-center" [ngClass]='{ | ||||
|                                 "hasevents": day.hasevents, | ||||
|                         "today": isCurrentMonth && day.istoday, | ||||
|                                 "today": month.isCurrentMonth && day.istoday, | ||||
|                                 "weekend": day.isweekend, | ||||
|                                 "duration_finish": day.haslastdayofevent | ||||
|                     }' [class.addon-calendar-event-past-day]="isPastMonth || day.ispast" role="cell" tabindex="0" | ||||
|                             }' [class.addon-calendar-event-past-day]="month.isPastMonth || day.ispast" role="cell" tabindex="0" | ||||
|                             (ariaButtonClick)="dayClicked(day.mday)"> | ||||
|                             <p class="addon-calendar-day-number" role="button"> | ||||
|                                 <span aria-hidden="true">{{ day.mday }}</span> | ||||
| @ -62,7 +68,7 @@ | ||||
|                                     class="calendar_event_type calendar_event_{{type}}"></span></p> | ||||
| 
 | ||||
|                             <!-- In tablet, display list of events. --> | ||||
|                     <div class="ion-hide-md-down addon-calendar-day-events"> | ||||
|                             <div class="ion-hide-md-down addon-calendar-day-events" *ngIf="day.filteredEvents"> | ||||
|                                 <ng-container *ngFor="let event of day.filteredEvents | slice:0:4; let index = index"> | ||||
|                                     <div *ngIf="index < 3 || day.filteredEvents.length == 4" class="addon-calendar-event" | ||||
|                                         [class.addon-calendar-event-past]="event.ispast" role="button" tabindex="0" | ||||
| @ -70,8 +76,8 @@ | ||||
|                                         <span class="calendar_event_type calendar_event_{{event.formattedType}}"></span> | ||||
|                                         <ion-icon *ngIf="event.offline && !event.deleted" name="fas-clock" | ||||
|                                             [attr.aria-label]="'core.notsent' | translate"></ion-icon> | ||||
|                                 <ion-icon *ngIf="event.deleted" name="fas-trash" [attr.aria-label]="'core.deletedoffline' | translate"> | ||||
|                                 </ion-icon> | ||||
|                                         <ion-icon *ngIf="event.deleted" name="fas-trash" | ||||
|                                             [attr.aria-label]="'core.deletedoffline' | translate"></ion-icon> | ||||
|                                         <span class="addon-calendar-event-time"> | ||||
|                                             {{ event.timestart * 1000 | coreFormatDate: timeFormat }} | ||||
|                                         </span> | ||||
| @ -81,7 +87,9 @@ | ||||
|                                         <!-- Add the icon title so accessibility tools read it. --> | ||||
|                                         <span class="sr-only"> | ||||
|                                             {{ 'addon.calendar.type' + event.formattedType | translate }} | ||||
|                                     <span class="sr-only" *ngIf="event.moduleIcon && event.iconTitle">{{ event.iconTitle }}</span> | ||||
|                                             <span class="sr-only" *ngIf="event.moduleIcon && event.iconTitle"> | ||||
|                                                 {{ event.iconTitle }} | ||||
|                                             </span> | ||||
|                                         </span> | ||||
|                                         <span class="addon-calendar-event-name">{{event.name}}</span> | ||||
|                                     </div> | ||||
| @ -92,9 +100,12 @@ | ||||
|                             </div> | ||||
|                         </ion-col> | ||||
|                         <!-- Empty slots (last week). --> | ||||
|                 <ion-col *ngFor="let value of week.postpadding" class="dayblank addon-calendar-day" role="cell"></ion-col> | ||||
|                         <ion-col *ngFor="let value of week.postpadding" class="dayblank addon-calendar-day" role="cell"> | ||||
|                         </ion-col> | ||||
|                     </ion-row> | ||||
|                 </div> | ||||
|             </ion-grid> | ||||
|         </ng-template> | ||||
|     </core-swipe-slides> | ||||
| 
 | ||||
| </core-loading> | ||||
|  | ||||
| @ -98,6 +98,10 @@ | ||||
|             margin-top: 10px; | ||||
|             font-size: 1.2rem; | ||||
|         } | ||||
| 
 | ||||
|         .addon-calendar-loading-month { | ||||
|             height: 20px; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     .addon-calendar-weekday { | ||||
| @ -154,6 +158,14 @@ | ||||
|             display: block; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     ion-slide { | ||||
|         display: block; | ||||
|         font-size: inherit; | ||||
|         justify-content: start; | ||||
|         align-items: start; | ||||
|         text-align: start; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| :host-context([dir=rtl]) { | ||||
|  | ||||
| @ -22,6 +22,7 @@ import { | ||||
|     EventEmitter, | ||||
|     KeyValueDiffers, | ||||
|     KeyValueDiffer, | ||||
|     ViewChild, | ||||
| } from '@angular/core'; | ||||
| import { CoreEventObserver, CoreEvents } from '@singletons/events'; | ||||
| import { CoreSites } from '@services/sites'; | ||||
| @ -41,6 +42,13 @@ 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'; | ||||
| import { CoreSwipeSlidesComponent } from '@components/swipe-slides/swipe-slides'; | ||||
| import { | ||||
|     CoreSwipeSlidesDynamicItem, | ||||
|     CoreSwipeSlidesDynamicItemsManagerSource, | ||||
| } from '@classes/items-management/swipe-slides-dynamic-items-manager-source'; | ||||
| import { CoreSwipeSlidesDynamicItemsManager } from '@classes/items-management/swipe-slides-dynamic-items-manager'; | ||||
| import moment from 'moment'; | ||||
| 
 | ||||
| /** | ||||
|  * Component that displays a calendar. | ||||
| @ -52,6 +60,8 @@ import { CoreLocalNotifications } from '@services/local-notifications'; | ||||
| }) | ||||
| export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestroy { | ||||
| 
 | ||||
|     @ViewChild(CoreSwipeSlidesComponent) slides?: CoreSwipeSlidesComponent<PreloadedMonth>; | ||||
| 
 | ||||
|     @Input() initialYear?: number; // Initial year to load.
 | ||||
|     @Input() initialMonth?: number; // Initial month to load.
 | ||||
|     @Input() filter?: AddonCalendarFilter; // Filter to apply.
 | ||||
| @ -61,28 +71,15 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro | ||||
|     @Output() onDayClicked = new EventEmitter<{day: number; month: number; year: number}>(); | ||||
| 
 | ||||
|     periodName?: string; | ||||
|     weekDays: AddonCalendarWeekDaysTranslationKeys[] = []; | ||||
|     weeks: AddonCalendarWeek[] = []; | ||||
|     manager?: CoreSwipeSlidesDynamicItemsManager<PreloadedMonth, AddonCalendarMonthSlidesItemsManagerSource>; | ||||
|     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<unknown, unknown>; // To detect changes in the data input.
 | ||||
|     // Observers.
 | ||||
|     // Observers and listeners.
 | ||||
|     protected undeleteEventObserver: CoreEventObserver; | ||||
|     protected obsDefaultTimeChange?: CoreEventObserver; | ||||
|     protected managerUnsubscribe?: () => void; | ||||
| 
 | ||||
|     constructor( | ||||
|         differs: KeyValueDiffers, | ||||
| @ -92,11 +89,17 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro | ||||
|         if (CoreLocalNotifications.isAvailable()) { | ||||
|             // Re-schedule events if default time changes.
 | ||||
|             this.obsDefaultTimeChange = CoreEvents.on(AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_CHANGED, () => { | ||||
|                 this.weeks.forEach((week) => { | ||||
|                 this.manager?.getSource().getItems()?.forEach((month) => { | ||||
|                     if (!month.loaded) { | ||||
|                         return; | ||||
|                     } | ||||
| 
 | ||||
|                     month.weeks?.forEach((week) => { | ||||
|                         week.days.forEach((day) => { | ||||
|                             AddonCalendar.scheduleEventsNotifications(day.eventsFormated || []); | ||||
|                         }); | ||||
|                     }); | ||||
|                 }); | ||||
|             }, this.currentSiteId); | ||||
|         } | ||||
| 
 | ||||
| @ -112,30 +115,35 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro | ||||
|                 this.undeleteEvent(data.eventId); | ||||
| 
 | ||||
|                 // Remove it from the list of deleted events if it's there.
 | ||||
|                 const index = this.deletedEvents.indexOf(data.eventId); | ||||
|                 const index = this.manager?.getSource().deletedEvents.indexOf(data.eventId) ?? -1; | ||||
|                 if (index != -1) { | ||||
|                     this.deletedEvents.splice(index, 1); | ||||
|                     this.manager?.getSource().deletedEvents.splice(index, 1); | ||||
|                 } | ||||
|             }, | ||||
|             this.currentSiteId, | ||||
|         ); | ||||
| 
 | ||||
|         this.differ = differs.find([]).create(); | ||||
| 
 | ||||
|         const now = new Date(); | ||||
| 
 | ||||
|         this.year = now.getFullYear(); | ||||
|         this.month = now.getMonth() + 1; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Component loaded. | ||||
|      */ | ||||
|     ngOnInit(): void { | ||||
|         this.year = this.initialYear ? this.initialYear : this.year; | ||||
|         this.month = this.initialMonth ? this.initialMonth : this.month; | ||||
|         this.canNavigate = typeof this.canNavigate == 'undefined' ? true : CoreUtils.isTrueOrOne(this.canNavigate); | ||||
|         this.displayNavButtons = typeof this.displayNavButtons == 'undefined' ? true : | ||||
|             CoreUtils.isTrueOrOne(this.displayNavButtons); | ||||
| 
 | ||||
|         this.calculateIsCurrentMonth(); | ||||
|         const source = new AddonCalendarMonthSlidesItemsManagerSource(this, moment({ | ||||
|             year: this.initialYear, | ||||
|             month: this.initialMonth ? this.initialMonth - 1 : undefined, | ||||
|         })); | ||||
|         this.manager = new CoreSwipeSlidesDynamicItemsManager(source); | ||||
|         this.managerUnsubscribe = this.manager.addListener({ | ||||
|             onSelectedItemUpdated: (item) => { | ||||
|                 this.onMonthViewed(item); | ||||
|             }, | ||||
|         }); | ||||
| 
 | ||||
|         this.fetchData(); | ||||
|     } | ||||
| @ -144,60 +152,35 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro | ||||
|      * 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.isTrueOrOne(this.canNavigate); | ||||
|         this.displayNavButtons = typeof this.displayNavButtons == 'undefined' ? true : | ||||
|             CoreUtils.isTrueOrOne(this.displayNavButtons); | ||||
|         const items = this.manager?.getSource().getItems(); | ||||
| 
 | ||||
|         if (this.weeks) { | ||||
|         if (items?.length) { | ||||
|             // Check if there's any change in the filter object.
 | ||||
|             const changes = this.differ.diff(this.filter || {}); | ||||
|             if (changes) { | ||||
|                 this.filterEvents(); | ||||
|                 items.forEach((month) => { | ||||
|                     if (month.loaded && month.weeks) { | ||||
|                         this.manager?.getSource().filterEvents(month.weeks, this.filter); | ||||
|                     } | ||||
|                 }); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     get timeFormat(): string { | ||||
|         return this.manager?.getSource().timeFormat || 'core.strftimetime'; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Fetch contacts. | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async fetchData(): Promise<void> { | ||||
|         const promises: Promise<void>[] = []; | ||||
| 
 | ||||
|         promises.push(this.loadCategories()); | ||||
| 
 | ||||
|         // Get offline events.
 | ||||
|         promises.push(AddonCalendarOffline.getAllEditedEvents().then((events) => { | ||||
|             // Classify them by month.
 | ||||
|             this.offlineEvents = AddonCalendarHelper.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.getAllDeletedEventsIds().then((ids) => { | ||||
|             this.deletedEvents = ids; | ||||
| 
 | ||||
|             return; | ||||
|         })); | ||||
| 
 | ||||
|         // Get time format to use.
 | ||||
|         promises.push(AddonCalendar.getCalendarTimeFormat().then((value) => { | ||||
|             this.timeFormat = value; | ||||
| 
 | ||||
|             return; | ||||
|         })); | ||||
| 
 | ||||
|         try { | ||||
|             await Promise.all(promises); | ||||
| 
 | ||||
|             await this.fetchEvents(); | ||||
|             await this.manager?.getSource().fetchData(); | ||||
| 
 | ||||
|             await this.manager?.getSource().load(this.manager?.getSelectedItem()); | ||||
|         } catch (error) { | ||||
|             CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); | ||||
|         } | ||||
| @ -206,115 +189,16 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Fetch the events for current month. | ||||
|      * Update data related to month being viewed. | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      * @param month Month being viewed. | ||||
|      */ | ||||
|     async fetchEvents(): Promise<void> { | ||||
|         // Don't pass courseId and categoryId, we'll filter them locally.
 | ||||
|         let result: { daynames: Partial<AddonCalendarDayName>[]; weeks: Partial<AddonCalendarWeek>[] }; | ||||
|         try { | ||||
|             result = await AddonCalendar.getMonthlyEvents(this.year, this.month); | ||||
|         } catch (error) { | ||||
|             if (!CoreApp.isOnline()) { | ||||
|                 // Allow navigating to non-cached months in offline (behave as if using emergency cache).
 | ||||
|                 result = await AddonCalendarHelper.getOfflineMonthWeeks(this.year, this.month); | ||||
|             } else { | ||||
|                 throw error; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|     onMonthViewed(month: MonthBasicData): void { | ||||
|         // Calculate the period name. We don't use the one in result because it's in server's language.
 | ||||
|         this.periodName = CoreTimeUtils.userDate( | ||||
|             new Date(this.year, this.month - 1).getTime(), | ||||
|             month.moment.unix() * 1000, | ||||
|             'core.strftimemonthyear', | ||||
|         ); | ||||
|         this.weekDays = AddonCalendar.getWeekDays(result.daynames[0].dayno); | ||||
|         this.weeks = result.weeks as AddonCalendarWeek[]; | ||||
|         this.calculateIsCurrentMonth(); | ||||
| 
 | ||||
|         await Promise.all(this.weeks.map(async (week) => { | ||||
|             await Promise.all(week.days.map(async (day) => { | ||||
|                 day.periodName = CoreTimeUtils.userDate( | ||||
|                     new Date(this.year, this.month - 1, day.mday).getTime(), | ||||
|                     'core.strftimedaydate', | ||||
|                 ); | ||||
|                 day.eventsFormated = day.eventsFormated || []; | ||||
|                 day.filteredEvents = day.filteredEvents || []; | ||||
|                 // Format online events.
 | ||||
|                 const onlineEventsFormatted = await Promise.all( | ||||
|                     day.events.map(async (event) => AddonCalendarHelper.formatEventData(event)), | ||||
|                 ); | ||||
| 
 | ||||
|                 day.eventsFormated = day.eventsFormated.concat(onlineEventsFormatted); | ||||
|             })); | ||||
|         })); | ||||
| 
 | ||||
|         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<void> { | ||||
|         if (this.categoriesRetrieved) { | ||||
|             // Already retrieved, stop.
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         try { | ||||
|             const cats = await CoreCourses.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.getFilteredEvents( | ||||
|                     day.eventsFormated || [], | ||||
|                     this.filter, | ||||
|                     this.categories, | ||||
|                 ); | ||||
| 
 | ||||
|                 // Re-calculate some properties.
 | ||||
|                 AddonCalendarHelper.calculateDayData(day, day.filteredEvents); | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -323,55 +207,30 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro | ||||
|      * @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<void> { | ||||
|         const promises: Promise<void>[] = []; | ||||
|     async refreshData(afterChange = false): Promise<void> { | ||||
|         const selectedMonth = this.manager?.getSelectedItem() || null; | ||||
| 
 | ||||
|         // Don't invalidate monthly events after a change, it has already been handled.
 | ||||
|         if (!afterChange) { | ||||
|             promises.push(AddonCalendar.invalidateMonthlyEvents(this.year, this.month)); | ||||
|         if (afterChange) { | ||||
|             this.manager?.getSource().markAllItemsDirty(); | ||||
|         } | ||||
|         promises.push(CoreCourses.invalidateCategories(0, true)); | ||||
|         promises.push(AddonCalendar.invalidateTimeFormat()); | ||||
| 
 | ||||
|         this.categoriesRetrieved = false; // Get categories again.
 | ||||
|         await this.manager?.getSource().invalidateContent(selectedMonth); | ||||
| 
 | ||||
|         await Promise.all(promises); | ||||
| 
 | ||||
|         this.fetchData(); | ||||
|         await this.fetchData(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load next month. | ||||
|      */ | ||||
|     async loadNext(): Promise<void> { | ||||
|         this.increaseMonth(); | ||||
| 
 | ||||
|         this.loaded = false; | ||||
| 
 | ||||
|         try { | ||||
|             await this.fetchEvents(); | ||||
|         } catch (error) { | ||||
|             CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); | ||||
|             this.decreaseMonth(); | ||||
|         } | ||||
|         this.loaded = true; | ||||
|     loadNext(): void { | ||||
|         this.slides?.slideNext(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load previous month. | ||||
|      */ | ||||
|     async loadPrevious(): Promise<void> { | ||||
|         this.decreaseMonth(); | ||||
| 
 | ||||
|         this.loaded = false; | ||||
| 
 | ||||
|         try { | ||||
|             await this.fetchEvents(); | ||||
|         } catch (error) { | ||||
|             CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); | ||||
|             this.increaseMonth(); | ||||
|         } | ||||
|         this.loaded = true; | ||||
|     loadPrevious(): void { | ||||
|         this.slides?.slidePrev(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -391,78 +250,332 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro | ||||
|      * @param day Day. | ||||
|      */ | ||||
|     dayClicked(day: number): void { | ||||
|         this.onDayClicked.emit({ day: day, month: this.month, year: this.year }); | ||||
|         const selectedMonth = this.manager?.getSelectedItem(); | ||||
|         if (!selectedMonth) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|     /** | ||||
|      * Check if user is viewing the current month. | ||||
|      */ | ||||
|     calculateIsCurrentMonth(): void { | ||||
|         const now = new Date(); | ||||
| 
 | ||||
|         this.currentTime = CoreTimeUtils.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); | ||||
|         this.onDayClicked.emit({ day: day, month: selectedMonth.moment.month() + 1, year: selectedMonth.moment.year() }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Go to current month. | ||||
|      */ | ||||
|     async goToCurrentMonth(): Promise<void> { | ||||
|         const now = new Date(); | ||||
|         const initialMonth = this.month; | ||||
|         const initialYear = this.year; | ||||
| 
 | ||||
|         this.month = now.getMonth() + 1; | ||||
|         this.year = now.getFullYear(); | ||||
|         const manager = this.manager; | ||||
|         const slides = this.slides; | ||||
|         if (!manager || !slides) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const currentMonth = { | ||||
|             moment: moment(), | ||||
|         }; | ||||
|         this.loaded = false; | ||||
| 
 | ||||
|         try { | ||||
|             await this.fetchEvents(); | ||||
|             this.isCurrentMonth = true; | ||||
|             // Make sure the day is loaded.
 | ||||
|             await manager.getSource().loadItem(currentMonth); | ||||
| 
 | ||||
|             slides.slideToItem(currentMonth); | ||||
|         } catch (error) { | ||||
|             CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); | ||||
|             this.year = initialYear; | ||||
|             this.month = initialMonth; | ||||
|         } | ||||
| 
 | ||||
|         } finally { | ||||
|             this.loaded = true; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Decrease the current month. | ||||
|      * Check whether selected month is loaded. | ||||
|      */ | ||||
|     protected decreaseMonth(): void { | ||||
|         if (this.month === 1) { | ||||
|             this.month = 12; | ||||
|             this.year--; | ||||
|         } else { | ||||
|             this.month--; | ||||
|     selectedMonthLoaded(): boolean { | ||||
|         return !!this.manager?.getSelectedItem()?.loaded; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Check whether selected month is current month. | ||||
|      */ | ||||
|     selectedMonthIsCurrent(): boolean { | ||||
|         return !!this.manager?.getSelectedItem()?.isCurrentMonth; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Undelete a certain event. | ||||
|      * | ||||
|      * @param eventId Event ID. | ||||
|      */ | ||||
|     protected async undeleteEvent(eventId: number): Promise<void> { | ||||
|         this.manager?.getSource().getItems()?.some((month) => { | ||||
|             if (!month.loaded) { | ||||
|                 return false; | ||||
|             } | ||||
| 
 | ||||
|             return month.weeks?.some((week) => week.days.some((day) => { | ||||
|                 const event = day.eventsFormated?.find((event) => event.id == eventId); | ||||
| 
 | ||||
|                 if (event) { | ||||
|                     event.deleted = false; | ||||
| 
 | ||||
|                     return true; | ||||
|                 } | ||||
| 
 | ||||
|                 return false; | ||||
|             })); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Component destroyed. | ||||
|      */ | ||||
|     ngOnDestroy(): void { | ||||
|         this.undeleteEventObserver?.off(); | ||||
|         this.obsDefaultTimeChange?.off(); | ||||
|         this.managerUnsubscribe && this.managerUnsubscribe(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Basic data to identify a month. | ||||
|  */ | ||||
| type MonthBasicData = { | ||||
|     moment: moment.Moment; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Preloaded month. | ||||
|  */ | ||||
| type PreloadedMonth = MonthBasicData & CoreSwipeSlidesDynamicItem & { | ||||
|     weekDays?: AddonCalendarWeekDaysTranslationKeys[]; | ||||
|     weeks?: AddonCalendarWeek[]; | ||||
|     isCurrentMonth?: boolean; | ||||
|     isPastMonth?: boolean; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Helper to manage swiping within months. | ||||
|  */ | ||||
| class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicItemsManagerSource<PreloadedMonth> { | ||||
| 
 | ||||
|     categories?: { [id: number]: CoreCategoryData }; | ||||
|     // Offline events classified in month & day.
 | ||||
|     offlineEvents: { [monthId: string]: { [day: number]: AddonCalendarEventToDisplay[] } } = {}; | ||||
|     offlineEditedEventsIds: number[] = []; // IDs of events edited in offline.
 | ||||
|     deletedEvents: number[] = []; // Events deleted in offline.
 | ||||
|     timeFormat?: string; | ||||
| 
 | ||||
|     protected calendarComponent: AddonCalendarCalendarComponent; | ||||
| 
 | ||||
|     constructor(component: AddonCalendarCalendarComponent, initialMoment: moment.Moment) { | ||||
|         super({ moment: initialMoment }); | ||||
| 
 | ||||
|         this.calendarComponent = component; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Fetch data. | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async fetchData(): Promise<void> { | ||||
|         await Promise.all([ | ||||
|             this.loadCategories(), | ||||
|             this.loadOfflineEvents(), | ||||
|             this.loadOfflineDeletedEvents(), | ||||
|             this.loadTimeFormat(), | ||||
|         ]); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Filter events based on the filter popover. | ||||
|      * | ||||
|      * @param weeks Weeks with the events to filter. | ||||
|      * @param filter Filter to apply. | ||||
|      */ | ||||
|     filterEvents(weeks: AddonCalendarWeek[], filter?: AddonCalendarFilter): void { | ||||
|         weeks.forEach((week) => { | ||||
|             week.days.forEach((day) => { | ||||
|                 day.filteredEvents = AddonCalendarHelper.getFilteredEvents( | ||||
|                     day.eventsFormated || [], | ||||
|                     filter, | ||||
|                     this.categories || {}, | ||||
|                 ); | ||||
| 
 | ||||
|                 // Re-calculate some properties.
 | ||||
|                 AddonCalendarHelper.calculateDayData(day, day.filteredEvents); | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load categories to be able to filter events. | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async loadCategories(): Promise<void> { | ||||
|         if (this.categories) { | ||||
|             // Already retrieved, stop.
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         try { | ||||
|             const categories = await CoreCourses.getCategories(0, true); | ||||
| 
 | ||||
|             // Index categories by ID.
 | ||||
|             this.categories = CoreUtils.arrayToObject(categories, 'id'); | ||||
|         } catch { | ||||
|             // Ignore errors.
 | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Increase the current month. | ||||
|      * Load events created or edited in offline. | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     protected increaseMonth(): void { | ||||
|         if (this.month === 12) { | ||||
|             this.month = 1; | ||||
|             this.year++; | ||||
|         } else { | ||||
|             this.month++; | ||||
|     async loadOfflineEvents(): Promise<void> { | ||||
|         // Get offline events.
 | ||||
|         const events = await AddonCalendarOffline.getAllEditedEvents(); | ||||
| 
 | ||||
|         // Classify them by month.
 | ||||
|         this.offlineEvents = AddonCalendarHelper.classifyIntoMonths(events); | ||||
| 
 | ||||
|         // Get the IDs of events edited in offline.
 | ||||
|         this.offlineEditedEventsIds = events.filter((event) => event.id > 0).map((event) => event.id); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load events deleted in offline. | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async loadOfflineDeletedEvents(): Promise<void> { | ||||
|         this.deletedEvents = await AddonCalendarOffline.getAllDeletedEventsIds(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load time format. | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async loadTimeFormat(): Promise<void> { | ||||
|         this.timeFormat = await AddonCalendar.getCalendarTimeFormat(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getItemId(item: MonthBasicData): string | number { | ||||
|         return AddonCalendarHelper.getMonthId(item.moment); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getPreviousItem(item: MonthBasicData): MonthBasicData | null { | ||||
|         return { | ||||
|             moment: item.moment.clone().subtract(1, 'month'), | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getNextItem(item: MonthBasicData): MonthBasicData | null { | ||||
|         return { | ||||
|             moment: item.moment.clone().add(1, 'month'), | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     async loadItemData(month: MonthBasicData, preload = false): Promise<PreloadedMonth | null> { | ||||
|         // Load or preload the weeks.
 | ||||
|         let result: { daynames: Partial<AddonCalendarDayName>[]; weeks: Partial<AddonCalendarWeek>[] }; | ||||
|         const year = month.moment.year(); | ||||
|         const monthNumber = month.moment.month() + 1; | ||||
| 
 | ||||
|         if (preload) { | ||||
|             result = await AddonCalendarHelper.getOfflineMonthWeeks(year, monthNumber); | ||||
|         } else { | ||||
|             try { | ||||
|                 // Don't pass courseId and categoryId, we'll filter them locally.
 | ||||
|                 result = await AddonCalendar.getMonthlyEvents(year, monthNumber); | ||||
|             } catch (error) { | ||||
|                 if (!CoreApp.isOnline()) { | ||||
|                     // Allow navigating to non-cached months in offline (behave as if using emergency cache).
 | ||||
|                     result = await AddonCalendarHelper.getOfflineMonthWeeks(year, monthNumber); | ||||
|                 } else { | ||||
|                     throw error; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         const weekDays = AddonCalendar.getWeekDays(result.daynames[0].dayno); | ||||
|         const weeks = result.weeks as AddonCalendarWeek[]; | ||||
|         const currentDay = new Date().getDate(); | ||||
|         const currentTime = CoreTimeUtils.timestamp(); | ||||
|         let isPast = true; | ||||
| 
 | ||||
|         const preloadedMonth: PreloadedMonth = { | ||||
|             ...month, | ||||
|             weeks, | ||||
|             weekDays, | ||||
|             isCurrentMonth: month.moment.isSame(moment(), 'month'), | ||||
|             isPastMonth: month.moment.isBefore(moment(), 'month'), | ||||
|         }; | ||||
| 
 | ||||
|         await Promise.all(weeks.map(async (week) => { | ||||
|             await Promise.all(week.days.map(async (day) => { | ||||
|                 day.periodName = CoreTimeUtils.userDate( | ||||
|                     month.moment.unix() * 1000, | ||||
|                     'core.strftimedaydate', | ||||
|                 ); | ||||
|                 day.eventsFormated = day.eventsFormated || []; | ||||
|                 day.filteredEvents = day.filteredEvents || []; | ||||
|                 // Format online events.
 | ||||
|                 const onlineEventsFormatted = await Promise.all( | ||||
|                     day.events.map(async (event) => AddonCalendarHelper.formatEventData(event)), | ||||
|                 ); | ||||
| 
 | ||||
|                 day.eventsFormated = day.eventsFormated.concat(onlineEventsFormatted); | ||||
| 
 | ||||
|                 if (preloadedMonth.isCurrentMonth) { | ||||
|                     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, currentTime); | ||||
|                         }); | ||||
|                     } | ||||
|                 } | ||||
|             })); | ||||
|         })); | ||||
| 
 | ||||
|         if (!preload) { | ||||
|             // Merge the online events with offline data.
 | ||||
|             this.mergeEvents(month, weeks); | ||||
|             // Filter events by course.
 | ||||
|             this.filterEvents(weeks, this.calendarComponent.filter); | ||||
|         } | ||||
| 
 | ||||
|         return preloadedMonth; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Merge online events with the offline events of that period. | ||||
|      * | ||||
|      * @param month Month. | ||||
|      * @param weeks Weeks with the events to filter. | ||||
|      */ | ||||
|     protected mergeEvents(): void { | ||||
|     mergeEvents(month: MonthBasicData, weeks: AddonCalendarWeek[]): void { | ||||
|         const monthOfflineEvents: { [day: number]: AddonCalendarEventToDisplay[] } = | ||||
|             this.offlineEvents[AddonCalendarHelper.getMonthId(this.year, this.month)]; | ||||
|             this.offlineEvents[AddonCalendarHelper.getMonthId(month.moment)]; | ||||
| 
 | ||||
|         this.weeks.forEach((week) => { | ||||
|         weeks.forEach((week) => { | ||||
|             week.days.forEach((day) => { | ||||
| 
 | ||||
|                 // Schedule notifications for the events retrieved (only future events will be scheduled).
 | ||||
| @ -493,43 +606,39 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * 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. | ||||
|      * @param currentTime Current time. | ||||
|      * @return True if it's in the past. | ||||
|      */ | ||||
|     protected isEventPast(event: { timestart: number; timeduration: number}): boolean { | ||||
|         return (event.timestart + event.timeduration) < (this.currentTime || CoreTimeUtils.timestamp()); | ||||
|     isEventPast(event: { timestart: number; timeduration: number}, currentTime: number): boolean { | ||||
|         return (event.timestart + event.timeduration) < currentTime; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Component destroyed. | ||||
|      * Invalidate content. | ||||
|      * | ||||
|      * @param selectedMonth The current selected month. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     ngOnDestroy(): void { | ||||
|         this.undeleteEventObserver?.off(); | ||||
|         this.obsDefaultTimeChange?.off(); | ||||
|     async invalidateContent(selectedMonth: PreloadedMonth | null): Promise<void> { | ||||
|         const promises: Promise<void>[] = []; | ||||
| 
 | ||||
|         if (selectedMonth) { | ||||
|             promises.push(AddonCalendar.invalidateMonthlyEvents(selectedMonth.moment.year(), selectedMonth.moment.month() + 1)); | ||||
|         } | ||||
|         promises.push(CoreCourses.invalidateCategories(0, true)); | ||||
|         promises.push(AddonCalendar.invalidateTimeFormat()); | ||||
| 
 | ||||
|         this.categories = undefined; // Get categories again.
 | ||||
| 
 | ||||
|         if (selectedMonth) { | ||||
|             selectedMonth.dirty = true; | ||||
|         } | ||||
| 
 | ||||
|         await Promise.all(promises); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -227,16 +227,12 @@ export class AddonCalendarUpcomingEventsComponent implements OnInit, DoCheck, On | ||||
|     /** | ||||
|      * 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<void> { | ||||
|     async refreshData(): Promise<void> { | ||||
|         const promises: Promise<void>[] = []; | ||||
| 
 | ||||
|         // Don't invalidate upcoming events after a change, it has already been handled.
 | ||||
|         if (!afterChange) { | ||||
|         promises.push(AddonCalendar.invalidateAllUpcomingEvents()); | ||||
|         } | ||||
|         promises.push(CoreCourses.invalidateCategories(0, true)); | ||||
|         promises.push(AddonCalendar.invalidateLookAhead()); | ||||
|         promises.push(AddonCalendar.invalidateTimeFormat()); | ||||
|  | ||||
| @ -11,10 +11,10 @@ | ||||
|                 <ion-icon slot="icon-only" name="fas-filter" aria-hidden="true"></ion-icon> | ||||
|             </ion-button> | ||||
|             <core-context-menu> | ||||
|                 <core-context-menu-item *ngIf="!isCurrentDay" [priority]="900" [content]="'addon.calendar.today' | translate" | ||||
|                 <core-context-menu-item *ngIf="!selectedDayIsCurrent()" [priority]="900" [content]="'addon.calendar.today' | translate" | ||||
|                     iconAction="fas-calendar-day" (action)="goToCurrentDay()"> | ||||
|                 </core-context-menu-item> | ||||
|                 <core-context-menu-item [hidden]="!loaded || !hasOffline || !isOnline" [priority]="400" | ||||
|                 <core-context-menu-item [hidden]="!loaded || !selectedDayHasOffline() || !isOnline" [priority]="400" | ||||
|                     [content]="'core.settings.synchronizenow' | translate" (action)="doRefresh(undefined, $event)" [iconAction]="syncIcon" | ||||
|                     [closeOnClick]="false"> | ||||
|                 </core-context-menu-item> | ||||
| @ -27,10 +27,11 @@ | ||||
|         <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> | ||||
|     </ion-refresher> | ||||
| 
 | ||||
|     <core-loading [hideUntil]="loaded"> | ||||
|         <!-- Period name and arrows to navigate. --> | ||||
|         <ion-grid class="ion-no-padding safe-area-padding"> | ||||
|             <ion-row class="ion-align-items-center"> | ||||
|             <ion-col class="ion-text-start" *ngIf="currentMoment"> | ||||
|                 <ion-col class="ion-text-start"> | ||||
|                     <ion-button fill="clear" (click)="loadPrevious()" [attr.aria-label]="'addon.calendar.dayprev' | translate"> | ||||
|                         <ion-icon name="fas-chevron-left" slot="icon-only" aria-hidden="true"></ion-icon> | ||||
|                     </ion-button> | ||||
| @ -38,7 +39,7 @@ | ||||
|                 <ion-col class="ion-text-center addon-calendar-period"> | ||||
|                     <h3>{{ periodName }}</h3> | ||||
|                 </ion-col> | ||||
|             <ion-col class="ion-text-end" *ngIf="currentMoment"> | ||||
|                 <ion-col class="ion-text-end"> | ||||
|                     <ion-button fill="clear" (click)="loadNext()" [attr.aria-label]="'addon.calendar.daynext' | translate"> | ||||
|                         <ion-icon name="fas-chevron-right" slot="icon-only" aria-hidden="true"></ion-icon> | ||||
|                     </ion-button> | ||||
| @ -46,27 +47,31 @@ | ||||
|             </ion-row> | ||||
|         </ion-grid> | ||||
| 
 | ||||
|     <core-loading [hideUntil]="loaded" class="safe-area-padding"> | ||||
|         <core-swipe-slides [manager]="manager"> | ||||
|             <ng-template let-day="item"> | ||||
|                 <core-loading [hideUntil]="day.loaded" class="safe-area-padding"> | ||||
|                     <!-- There is data to be synchronized --> | ||||
|         <ion-card class="core-warning-card" *ngIf="hasOffline"> | ||||
|                     <ion-card class="core-warning-card" *ngIf="day.hasOffline"> | ||||
|                         <ion-item> | ||||
|                             <ion-icon name="fas-exclamation-triangle" slot="start" aria-hidden="true"></ion-icon> | ||||
|                             <ion-label>{{ 'core.hasdatatosync' | translate:{$a: 'core.day' | translate} }}</ion-label> | ||||
|                         </ion-item> | ||||
|                     </ion-card> | ||||
| 
 | ||||
|         <core-empty-box *ngIf="!filteredEvents || !filteredEvents.length" icon="fas-calendar" inline="true" | ||||
|                     <core-empty-box *ngIf="!day.filteredEvents || !day.filteredEvents.length" icon="fas-calendar" inline="true" | ||||
|                         [message]="'addon.calendar.noevents' | translate"> | ||||
|                     </core-empty-box> | ||||
| 
 | ||||
|         <ion-list *ngIf="filteredEvents && filteredEvents.length" class="ion-no-margin"> | ||||
|             <ng-container *ngFor="let event of filteredEvents"> | ||||
|                 <ion-item class="addon-calendar-event ion-text-wrap" [attr.aria-label]="event.name" (click)="gotoEvent(event.id)" | ||||
|                     [class.item-dimmed]="event.ispast" [ngClass]="['addon-calendar-eventtype-'+event.eventtype]" button detail="true"> | ||||
|                     <ion-list *ngIf="day.filteredEvents && day.filteredEvents.length" class="ion-no-margin"> | ||||
|                         <ng-container *ngFor="let event of day.filteredEvents"> | ||||
|                             <ion-item class="addon-calendar-event ion-text-wrap" [attr.aria-label]="event.name" | ||||
|                                 (click)="gotoEvent(event.id)" [class.item-dimmed]="event.ispast" | ||||
|                                 [ngClass]="['addon-calendar-eventtype-'+event.eventtype]" button detail="true"> | ||||
|                                 <core-mod-icon *ngIf="event.moduleIcon" [modicon]="event.moduleIcon" slot="start" [showAlt]="false" | ||||
|                                     [modname]="event.modname" [componentId]="event.instance"> | ||||
|                                 </core-mod-icon> | ||||
|                     <ion-icon *ngIf="event.eventIcon && !event.moduleIcon" [name]="event.eventIcon" slot="start" aria-hidden="true"> | ||||
|                                 <ion-icon *ngIf="event.eventIcon && !event.moduleIcon" [name]="event.eventIcon" slot="start" | ||||
|                                     aria-hidden="true"> | ||||
|                                 </ion-icon> | ||||
|                                 <ion-label> | ||||
|                                     <!-- Add the icon title so accessibility tools read it. --> | ||||
| @ -92,6 +97,9 @@ | ||||
|                         </ng-container> | ||||
|                     </ion-list> | ||||
|                 </core-loading> | ||||
|             </ng-template> | ||||
|         </core-swipe-slides> | ||||
|     </core-loading> | ||||
| 
 | ||||
|     <!-- Create a calendar event. --> | ||||
|     <ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end" *ngIf="canCreate && loaded"> | ||||
|  | ||||
| @ -12,7 +12,7 @@ | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component, OnInit, OnDestroy } from '@angular/core'; | ||||
| import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core'; | ||||
| import { IonRefresher } from '@ionic/angular'; | ||||
| import { CoreApp } from '@services/app'; | ||||
| import { CoreEventObserver, CoreEvents } from '@singletons/events'; | ||||
| @ -40,6 +40,12 @@ import { Params } from '@angular/router'; | ||||
| import { Subscription } from 'rxjs'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { CoreConstants } from '@/core/constants'; | ||||
| import { CoreSwipeSlidesDynamicItemsManager } from '@classes/items-management/swipe-slides-dynamic-items-manager'; | ||||
| import { CoreSwipeSlidesComponent } from '@components/swipe-slides/swipe-slides'; | ||||
| import { | ||||
|     CoreSwipeSlidesDynamicItem, | ||||
|     CoreSwipeSlidesDynamicItemsManagerSource, | ||||
| } from '@classes/items-management/swipe-slides-dynamic-items-manager-source'; | ||||
| 
 | ||||
| /** | ||||
|  * Page that displays the calendar events for a certain day. | ||||
| @ -51,20 +57,9 @@ import { CoreConstants } from '@/core/constants'; | ||||
| }) | ||||
| 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.
 | ||||
|     @ViewChild(CoreSwipeSlidesComponent) slides?: CoreSwipeSlidesComponent<PreloadedDay>; | ||||
| 
 | ||||
|     protected offlineEditedEventsIds: number[] = []; // IDs of events edited in offline.
 | ||||
|     protected deletedEvents: number[] = []; // Events deleted in offline.
 | ||||
|     protected timeFormat?: string; | ||||
|     protected currentTime!: number; | ||||
|     protected currentSiteId: string; | ||||
| 
 | ||||
|     // Observers.
 | ||||
|     protected newEventObserver: CoreEventObserver; | ||||
| @ -77,18 +72,13 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { | ||||
|     protected onlineObserver: Subscription; | ||||
|     protected obsDefaultTimeChange?: CoreEventObserver; | ||||
|     protected filterChangedObserver: CoreEventObserver; | ||||
|     protected managerUnsubscribe?: () => void; | ||||
| 
 | ||||
|     periodName?: string; | ||||
|     filteredEvents: AddonCalendarEventToDisplay [] = []; | ||||
|     canCreate = false; | ||||
|     courses: Partial<CoreEnrolledCourseData>[] = []; | ||||
|     manager?: CoreSwipeSlidesDynamicItemsManager<PreloadedDay, AddonCalendarDaySlidesItemsManagerSource>; | ||||
|     loaded = false; | ||||
|     hasOffline = false; | ||||
|     isOnline = false; | ||||
|     syncIcon = CoreConstants.ICON_LOADING; | ||||
|     isCurrentDay = false; | ||||
|     isPastDay = false; | ||||
|     currentMoment!: moment.Moment; | ||||
|     filter: AddonCalendarFilter = { | ||||
|         filtered: false, | ||||
|         courseId: undefined, | ||||
| @ -106,7 +96,9 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { | ||||
|         if (CoreLocalNotifications.isAvailable()) { | ||||
|             // Re-schedule events if default time changes.
 | ||||
|             this.obsDefaultTimeChange = CoreEvents.on(AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_CHANGED, () => { | ||||
|                 AddonCalendar.scheduleEventsNotifications(this.onlineEvents); | ||||
|                 this.manager?.getSource().getItems()?.forEach(day => { | ||||
|                     AddonCalendar.scheduleEventsNotifications(day.onlineEvents || []); | ||||
|                 }); | ||||
|             }, this.currentSiteId); | ||||
|         } | ||||
| 
 | ||||
| @ -115,7 +107,7 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { | ||||
|             AddonCalendarProvider.NEW_EVENT_EVENT, | ||||
|             (data) => { | ||||
|                 if (data && data.eventId) { | ||||
|                     this.loaded = false; | ||||
|                     this.manager?.getSource().markAllItemsUnloaded(); | ||||
|                     this.refreshData(true, true); | ||||
|                 } | ||||
|             }, | ||||
| @ -124,7 +116,7 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { | ||||
| 
 | ||||
|         // 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.manager?.getSource().markAllItemsUnloaded(); | ||||
|             this.refreshData(true, true); | ||||
|         }, this.currentSiteId); | ||||
| 
 | ||||
| @ -133,7 +125,7 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { | ||||
|             AddonCalendarProvider.EDIT_EVENT_EVENT, | ||||
|             (data) => { | ||||
|                 if (data && data.eventId) { | ||||
|                     this.loaded = false; | ||||
|                     this.manager?.getSource().markAllItemsUnloaded(); | ||||
|                     this.refreshData(true, true); | ||||
|                 } | ||||
|             }, | ||||
| @ -142,14 +134,15 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { | ||||
| 
 | ||||
|         // Refresh data if calendar events are synchronized automatically.
 | ||||
|         this.syncObserver = CoreEvents.on(AddonCalendarSyncProvider.AUTO_SYNCED, () => { | ||||
|             this.loaded = false; | ||||
|             this.manager?.getSource().markAllItemsUnloaded(); | ||||
|             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) => { | ||||
|             if (data && (data.source != 'day' || data.year != this.year || data.month != this.month || data.day != this.day)) { | ||||
|                 this.loaded = false; | ||||
|             const selectedDay = this.manager?.getSelectedItem(); | ||||
|             if (data && (data.source != 'day' || !selectedDay || !data.moment || !selectedDay.moment.isSame(data.moment, 'day'))) { | ||||
|                 this.manager?.getSource().markAllItemsUnloaded(); | ||||
|                 this.refreshData(false, true); | ||||
|             } | ||||
|         }, this.currentSiteId); | ||||
| @ -160,10 +153,9 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { | ||||
|             (data) => { | ||||
|                 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); | ||||
|                     this.manager?.getSource().markAsDeleted(data.eventId, true); | ||||
|                 } else { | ||||
|                     this.loaded = false; | ||||
|                     this.manager?.getSource().markAllItemsUnloaded(); | ||||
|                     this.refreshData(false, true); | ||||
|                 } | ||||
|             }, | ||||
| @ -179,26 +171,7 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { | ||||
|                 } | ||||
| 
 | ||||
|                 // 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.manager?.getSource().markAsDeleted(data.eventId, false); | ||||
|             }, | ||||
|             this.currentSiteId, | ||||
|         ); | ||||
| @ -209,9 +182,9 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { | ||||
|                 this.filter = data; | ||||
| 
 | ||||
|                 // Course viewed has changed, check if the user can create events for this course calendar.
 | ||||
|                 this.canCreate = await AddonCalendarHelper.canEditEvents(this.filter.courseId); | ||||
|                 await this.manager?.getSource().loadCanCreate(this.filter.courseId); | ||||
| 
 | ||||
|                 this.filterEvents(); | ||||
|                 this.manager?.getSource().filterAllDayEvents(this.filter); | ||||
|             }, | ||||
|         ); | ||||
| 
 | ||||
| @ -240,17 +213,30 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { | ||||
| 
 | ||||
|         this.filter.filtered = typeof this.filter.courseId != 'undefined' || types.some((name) => !this.filter[name]); | ||||
| 
 | ||||
|         const now = new Date(); | ||||
|         this.year = CoreNavigator.getRouteNumberParam('year') || now.getFullYear(); | ||||
|         this.month = CoreNavigator.getRouteNumberParam('month') || (now.getMonth() + 1); | ||||
|         this.day = CoreNavigator.getRouteNumberParam('day') || now.getDate(); | ||||
| 
 | ||||
|         this.calculateCurrentMoment(); | ||||
|         this.calculateIsCurrentDay(); | ||||
|         const month = CoreNavigator.getRouteNumberParam('month'); | ||||
|         const source = new AddonCalendarDaySlidesItemsManagerSource(this, moment({ | ||||
|             year: CoreNavigator.getRouteNumberParam('year'), | ||||
|             month: month ? month - 1 : undefined, | ||||
|             date: CoreNavigator.getRouteNumberParam('day'), | ||||
|         })); | ||||
|         this.manager = new CoreSwipeSlidesDynamicItemsManager(source); | ||||
|         this.managerUnsubscribe = this.manager.addListener({ | ||||
|             onSelectedItemUpdated: (item) => { | ||||
|                 this.onDayViewed(item); | ||||
|             }, | ||||
|         }); | ||||
| 
 | ||||
|         this.fetchData(true); | ||||
|     } | ||||
| 
 | ||||
|     get canCreate(): boolean { | ||||
|         return this.manager?.getSource().canCreate || false; | ||||
|     } | ||||
| 
 | ||||
|     get timeFormat(): string { | ||||
|         return this.manager?.getSource().timeFormat || 'core.strftimetime'; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Fetch all the data required for the view. | ||||
|      * | ||||
| @ -259,7 +245,6 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async fetchData(sync?: boolean): Promise<void> { | ||||
| 
 | ||||
|         this.syncIcon = CoreConstants.ICON_LOADING; | ||||
|         this.isOnline = CoreApp.isOnline(); | ||||
| 
 | ||||
| @ -268,53 +253,9 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { | ||||
|         } | ||||
| 
 | ||||
|         try { | ||||
|             const promises: Promise<void>[] = []; | ||||
|             await this.manager?.getSource().fetchData(this.filter.courseId); | ||||
| 
 | ||||
|             // Load courses for the popover.
 | ||||
|             promises.push(CoreCoursesHelper.getCoursesForPopover(this.filter.courseId).then((data) => { | ||||
|                 this.courses = data.courses; | ||||
| 
 | ||||
|                 return; | ||||
|             })); | ||||
| 
 | ||||
|             // Get categories.
 | ||||
|             promises.push(this.loadCategories()); | ||||
| 
 | ||||
|             // Get offline events.
 | ||||
|             promises.push(AddonCalendarOffline.getAllEditedEvents().then((offlineEvents) => { | ||||
|                 // Classify them by month & day.
 | ||||
|                 this.offlineEvents = AddonCalendarHelper.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.getAllDeletedEventsIds().then((ids) => { | ||||
|                 this.deletedEvents = ids; | ||||
| 
 | ||||
|                 return; | ||||
|             })); | ||||
| 
 | ||||
|             // Check if user can create events.
 | ||||
|             promises.push(AddonCalendarHelper.canEditEvents(this.filter.courseId).then((canEdit) => { | ||||
|                 this.canCreate = canEdit; | ||||
| 
 | ||||
|                 return; | ||||
|             })); | ||||
| 
 | ||||
|             // Get user preferences.
 | ||||
|             promises.push(AddonCalendar.getCalendarTimeFormat().then((value) => { | ||||
|                 this.timeFormat = value; | ||||
| 
 | ||||
|                 return; | ||||
|             })); | ||||
| 
 | ||||
|             await Promise.all(promises); | ||||
| 
 | ||||
|             await this.fetchEvents(); | ||||
|             await this.manager?.getSource().load(this.manager?.getSelectedItem()); | ||||
|         } catch (error) { | ||||
|             CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); | ||||
|         } | ||||
| @ -324,104 +265,15 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Fetch the events for current day. | ||||
|      * Update data related to day being viewed. | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      * @param day Day viewed. | ||||
|      */ | ||||
|     async fetchEvents(): Promise<void> { | ||||
|         let result: AddonCalendarCalendarDay; | ||||
|         try { | ||||
|             // Don't pass courseId and categoryId, we'll filter them locally.
 | ||||
|             result = await AddonCalendar.getDayEvents(this.year, this.month, this.day); | ||||
|             this.onlineEvents = await Promise.all(result.events.map((event) => AddonCalendarHelper.formatEventData(event))); | ||||
|         } catch (error) { | ||||
|             if (CoreApp.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.
 | ||||
|     onDayViewed(day: DayBasicData): void { | ||||
|         this.periodName = CoreTimeUtils.userDate( | ||||
|             new Date(this.year, this.month - 1, this.day).getTime(), | ||||
|             day.moment.unix() * 1000, | ||||
|             'core.strftimedaydate', | ||||
|         ); | ||||
| 
 | ||||
|         // Schedule notifications for the events retrieved (only future events will be scheduled).
 | ||||
|         AddonCalendar.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.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.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.sortEvents(result.concat(dayOfflineEvents)); | ||||
|         } | ||||
| 
 | ||||
|         return result; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Filter events based on the filter popover. | ||||
|      */ | ||||
|     protected filterEvents(): void { | ||||
|         this.filteredEvents = AddonCalendarHelper.getFilteredEvents(this.events, this.filter, this.categories); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -452,37 +304,12 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { | ||||
|     async refreshData(sync?: boolean, afterChange?: boolean): Promise<void> { | ||||
|         this.syncIcon = CoreConstants.ICON_LOADING; | ||||
| 
 | ||||
|         const promises: Promise<void>[] = []; | ||||
|         const selectedDay = this.manager?.getSelectedItem() || null; | ||||
| 
 | ||||
|         // Don't invalidate day events after a change, it has already been handled.
 | ||||
|         if (!afterChange) { | ||||
|             promises.push(AddonCalendar.invalidateDayEvents(this.year, this.month, this.day)); | ||||
|         } | ||||
|         promises.push(AddonCalendar.invalidateAllowedEventTypes()); | ||||
|         promises.push(CoreCourses.invalidateCategories(0, true)); | ||||
|         promises.push(AddonCalendar.invalidateTimeFormat()); | ||||
|         await this.manager?.getSource().invalidateContent(selectedDay, !afterChange); | ||||
| 
 | ||||
|         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<void> { | ||||
|         try { | ||||
|             const cats = await CoreCourses.getCategories(0, true); | ||||
|             this.categories = {}; | ||||
| 
 | ||||
|             // Index categories by ID.
 | ||||
|             cats.forEach((category) => { | ||||
|                 this.categories[category.id] = category; | ||||
|             }); | ||||
|         } catch { | ||||
|             // Ignore errors.
 | ||||
|         } | ||||
|         await this.fetchData(sync); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -501,11 +328,11 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { | ||||
| 
 | ||||
|             if (result.updated) { | ||||
|                 // Trigger a manual sync event.
 | ||||
|                 const selectedDay = this.manager?.getSelectedItem(); | ||||
|                 result.source = 'day'; | ||||
|                 result.day = this.day; | ||||
|                 result.month = this.month; | ||||
|                 result.year = this.year; | ||||
|                 result.moment = selectedDay?.moment; | ||||
| 
 | ||||
|                 this.manager?.getSource().markAllItemsUnloaded(); | ||||
|                 CoreEvents.trigger(AddonCalendarSyncProvider.MANUAL_SYNCED, result, this.currentSiteId); | ||||
|             } | ||||
|         } catch (error) { | ||||
| @ -515,6 +342,13 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Check whether selected day is current day. | ||||
|      */ | ||||
|     selectedDayIsCurrent(): boolean { | ||||
|         return !!this.manager?.getSelectedItem()?.isCurrentDay; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Navigate to a particular event. | ||||
|      * | ||||
| @ -533,7 +367,7 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { | ||||
|         await CoreDomUtils.openPopover({ | ||||
|             component: AddonCalendarFilterPopoverComponent, | ||||
|             componentProps: { | ||||
|                 courses: this.courses, | ||||
|                 courses: this.manager?.getSource().courses, | ||||
|                 filter: this.filter, | ||||
|             }, | ||||
|             event, | ||||
| @ -551,7 +385,11 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { | ||||
|         if (!eventId) { | ||||
|             // It's a new event, set the time.
 | ||||
|             eventId = 0; | ||||
|             params.timestamp = moment().year(this.year).month(this.month - 1).date(this.day).unix() * 1000; | ||||
| 
 | ||||
|             const selectedDay = this.manager?.getSelectedItem(); | ||||
|             if (selectedDay) { | ||||
|                 params.timestamp = selectedDay.moment.unix() * 1000; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         if (this.filter.courseId) { | ||||
| @ -562,140 +400,55 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Calculate current moment. | ||||
|      * Check whether selected day has offline data. | ||||
|      * | ||||
|      * @return Whether selected day has offline data. | ||||
|      */ | ||||
|     calculateCurrentMoment(): void { | ||||
|         this.currentMoment = moment().year(this.year).month(this.month - 1).date(this.day); | ||||
|     } | ||||
|     selectedDayHasOffline(): boolean { | ||||
|         const selectedDay = this.manager?.getSelectedItem(); | ||||
| 
 | ||||
|     /** | ||||
|      * Check if user is viewing the current day. | ||||
|      */ | ||||
|     calculateIsCurrentDay(): void { | ||||
|         const now = new Date(); | ||||
| 
 | ||||
|         this.currentTime = CoreTimeUtils.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()); | ||||
|         return !!(selectedDay?.hasOffline); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Go to current day. | ||||
|      */ | ||||
|     async goToCurrentDay(): Promise<void> { | ||||
|         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(); | ||||
|         const manager = this.manager; | ||||
|         const slides = this.slides; | ||||
|         if (!manager || !slides) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const currentDay = { | ||||
|             moment: moment(), | ||||
|         }; | ||||
|         this.loaded = false; | ||||
| 
 | ||||
|         try { | ||||
|             await this.fetchEvents(); | ||||
|             // Make sure the day is loaded.
 | ||||
|             await manager.getSource().loadItem(currentDay); | ||||
| 
 | ||||
|             this.isCurrentDay = true; | ||||
|             slides.slideToItem(currentDay); | ||||
|         } catch (error) { | ||||
|             CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); | ||||
| 
 | ||||
|             this.year = initialYear; | ||||
|             this.month = initialMonth; | ||||
|             this.day = initialDay; | ||||
|             this.calculateCurrentMoment(); | ||||
|         } | ||||
| 
 | ||||
|         } finally { | ||||
|             this.loaded = true; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load next day. | ||||
|      */ | ||||
|     async loadNext(): Promise<void> { | ||||
|         this.increaseDay(); | ||||
| 
 | ||||
|         this.loaded = false; | ||||
| 
 | ||||
|         try { | ||||
|             await this.fetchEvents(); | ||||
|         } catch (error) { | ||||
|             CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); | ||||
|             this.decreaseDay(); | ||||
|         } | ||||
|         this.loaded = true; | ||||
|         this.slides?.slideNext(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load previous day. | ||||
|      */ | ||||
|     async loadPrevious(): Promise<void> { | ||||
|         this.decreaseDay(); | ||||
| 
 | ||||
|         this.loaded = false; | ||||
| 
 | ||||
|         try { | ||||
|             await this.fetchEvents(); | ||||
|         } catch (error) { | ||||
|             CoreDomUtils.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; | ||||
|         this.slides?.slidePrev(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -712,6 +465,370 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { | ||||
|         this.onlineObserver?.unsubscribe(); | ||||
|         this.filterChangedObserver?.off(); | ||||
|         this.obsDefaultTimeChange?.off(); | ||||
|         this.managerUnsubscribe && this.managerUnsubscribe(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Basic data to identify a day. | ||||
|  */ | ||||
| type DayBasicData = { | ||||
|     moment: moment.Moment; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Preloaded month. | ||||
|  */ | ||||
| type PreloadedDay = DayBasicData & CoreSwipeSlidesDynamicItem & { | ||||
|     events?: AddonCalendarEventToDisplay[]; // Events (both online and offline).
 | ||||
|     onlineEvents?: AddonCalendarEventToDisplay[]; | ||||
|     filteredEvents?: AddonCalendarEventToDisplay[]; | ||||
|     isCurrentDay?: boolean; | ||||
|     isPastDay?: boolean; | ||||
|     hasOffline?: boolean; // Whether the day has offline data.
 | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Helper to manage swiping within days. | ||||
|  */ | ||||
| class AddonCalendarDaySlidesItemsManagerSource extends CoreSwipeSlidesDynamicItemsManagerSource<PreloadedDay> { | ||||
| 
 | ||||
|     courses: Partial<CoreEnrolledCourseData>[] = []; | ||||
|     // Offline events classified in month & day.
 | ||||
|     offlineEvents: Record<string, Record<number, AddonCalendarEventToDisplay[]>> = {}; | ||||
|     offlineEditedEventsIds: number[] = []; // IDs of events edited in offline.
 | ||||
|     categories?: { [id: number]: CoreCategoryData }; | ||||
|     deletedEvents?: Set<number>; // Events deleted in offline.
 | ||||
|     timeFormat?: string; | ||||
|     canCreate = false; | ||||
| 
 | ||||
|     protected dayPage: AddonCalendarDayPage; | ||||
| 
 | ||||
|     constructor(page: AddonCalendarDayPage, initialMoment: moment.Moment) { | ||||
|         super({ moment: initialMoment }); | ||||
| 
 | ||||
|         this.dayPage = page; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Fetch data. | ||||
|      * | ||||
|      * @param courseId Current selected course id (if any). | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async fetchData(courseId?: number): Promise<void> { | ||||
|         await Promise.all([ | ||||
|             this.loadCourses(courseId), | ||||
|             this.loadCanCreate(courseId), | ||||
|             this.loadCategories(), | ||||
|             this.loadOfflineEvents(), | ||||
|             this.loadOfflineDeletedEvents(), | ||||
|             this.loadTimeFormat(), | ||||
|         ]); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Filter all loaded days events based on the filter popover. | ||||
|      * | ||||
|      * @param filter Filter to apply. | ||||
|      */ | ||||
|     filterAllDayEvents(filter: AddonCalendarFilter): void { | ||||
|         this.getItems()?.forEach(day => this.filterEvents(day, filter)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Filter events of a certain day based on the filter popover. | ||||
|      * | ||||
|      * @param day Day with the events. | ||||
|      * @param filter Filter to apply. | ||||
|      */ | ||||
|     filterEvents(day: PreloadedDay, filter: AddonCalendarFilter): void { | ||||
|         day.filteredEvents = AddonCalendarHelper.getFilteredEvents(day.events || [], filter, this.categories || {}); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load courses. | ||||
|      * | ||||
|      * @param courseId Current selected course id (if any). | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async loadCourses(courseId?: number): Promise<void> { | ||||
|         const data = await CoreCoursesHelper.getCoursesForPopover(courseId); | ||||
| 
 | ||||
|         this.courses = data.courses; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load whether user can create events. | ||||
|      * | ||||
|      * @param courseId Current selected course id (if any). | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async loadCanCreate(courseId?: number): Promise<void> { | ||||
|         this.canCreate = await AddonCalendarHelper.canEditEvents(courseId); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load categories to be able to filter events. | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async loadCategories(): Promise<void> { | ||||
|         if (this.categories) { | ||||
|             // Already retrieved, stop.
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         try { | ||||
|             const categories = await CoreCourses.getCategories(0, true); | ||||
| 
 | ||||
|             // Index categories by ID.
 | ||||
|             this.categories = CoreUtils.arrayToObject(categories, 'id'); | ||||
|         } catch { | ||||
|             // Ignore errors.
 | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load events created or edited in offline. | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async loadOfflineEvents(): Promise<void> { | ||||
|         // Get offline events.
 | ||||
|         const events = await AddonCalendarOffline.getAllEditedEvents(); | ||||
| 
 | ||||
|         // Classify them by month & day.
 | ||||
|         this.offlineEvents = AddonCalendarHelper.classifyIntoMonths(events); | ||||
| 
 | ||||
|         // Get the IDs of events edited in offline.
 | ||||
|         this.offlineEditedEventsIds = events.filter((event) => event.id > 0).map((event) => event.id); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load events deleted in offline. | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async loadOfflineDeletedEvents(): Promise<void> { | ||||
|         const deletedEventsIds = await AddonCalendarOffline.getAllDeletedEventsIds(); | ||||
| 
 | ||||
|         this.deletedEvents = new Set(deletedEventsIds); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load time format. | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async loadTimeFormat(): Promise<void> { | ||||
|         this.timeFormat = await AddonCalendar.getCalendarTimeFormat(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getItemId(item: DayBasicData): string | number { | ||||
|         return AddonCalendarHelper.getDayId(item.moment); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getPreviousItem(item: DayBasicData): DayBasicData | null { | ||||
|         return { | ||||
|             moment: item.moment.clone().subtract(1, 'day'), | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getNextItem(item: DayBasicData): DayBasicData | null { | ||||
|         return { | ||||
|             moment: item.moment.clone().add(1, 'day'), | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     async loadItemData(day: DayBasicData, preload = false): Promise<PreloadedDay | null> { | ||||
|         const preloadedDay: PreloadedDay = { | ||||
|             ...day, | ||||
|             hasOffline: false, | ||||
|             events: [], | ||||
|             onlineEvents: [], | ||||
|             filteredEvents: [], | ||||
|             isCurrentDay: day.moment.isSame(moment(), 'day'), | ||||
|             isPastDay: day.moment.isBefore(moment(), 'day'), | ||||
|         }; | ||||
| 
 | ||||
|         if (preload) { | ||||
|             return preloadedDay; | ||||
|         } | ||||
| 
 | ||||
|         let result: AddonCalendarCalendarDay; | ||||
| 
 | ||||
|         try { | ||||
|             // Don't pass courseId and categoryId, we'll filter them locally.
 | ||||
|             result = await AddonCalendar.getDayEvents(day.moment.year(), day.moment.month() + 1, day.moment.date()); | ||||
|             preloadedDay.onlineEvents = await Promise.all( | ||||
|                 result.events.map((event) => AddonCalendarHelper.formatEventData(event)), | ||||
|             ); | ||||
|         } catch (error) { | ||||
|             // Allow navigating to non-cached days in offline (behave as if using emergency cache).
 | ||||
|             if (CoreApp.isOnline()) { | ||||
|                 throw error; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // Schedule notifications for the events retrieved (only future events will be scheduled).
 | ||||
|         AddonCalendar.scheduleEventsNotifications(preloadedDay.onlineEvents || []); | ||||
| 
 | ||||
|         // Merge the online events with offline data.
 | ||||
|         preloadedDay.events = this.mergeEvents(preloadedDay); | ||||
| 
 | ||||
|         // Filter events by course.
 | ||||
|         this.filterEvents(preloadedDay, this.dayPage.filter); | ||||
| 
 | ||||
|         // Re-calculate the formatted time so it uses the device date.
 | ||||
|         const dayTime = day.moment.unix() * 1000; | ||||
|         const currentTime = CoreTimeUtils.timestamp(); | ||||
| 
 | ||||
|         const promises = preloadedDay.events.map(async (event) => { | ||||
|             event.ispast = preloadedDay.isPastDay || (preloadedDay.isCurrentDay && this.isEventPast(event, currentTime)); | ||||
|             event.formattedtime = await AddonCalendar.formatEventTime(event, this.dayPage.timeFormat, true, dayTime); | ||||
|         }); | ||||
| 
 | ||||
|         await Promise.all(promises); | ||||
| 
 | ||||
|         return preloadedDay; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Returns if the event is in the past or not. | ||||
|      * | ||||
|      * @param event Event object. | ||||
|      * @param currentTime Current time. | ||||
|      * @return True if it's in the past. | ||||
|      */ | ||||
|     isEventPast(event: AddonCalendarEventToDisplay, currentTime: number): boolean { | ||||
|         return (event.timestart + event.timeduration) < currentTime; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Merge online events with the offline events of that period. | ||||
|      * | ||||
|      * @param day Day with the events. | ||||
|      * @return Merged events. | ||||
|      */ | ||||
|     mergeEvents(day: PreloadedDay): AddonCalendarEventToDisplay[] { | ||||
|         day.hasOffline = false; | ||||
| 
 | ||||
|         if (!Object.keys(this.offlineEvents).length && !this.deletedEvents?.size) { | ||||
|             // No offline events, nothing to merge.
 | ||||
|             return day.onlineEvents || []; | ||||
|         } | ||||
| 
 | ||||
|         const monthOfflineEvents = this.offlineEvents[AddonCalendarHelper.getMonthId(day.moment)]; | ||||
|         const dayOfflineEvents = monthOfflineEvents && monthOfflineEvents[day.moment.date()]; | ||||
|         let result = day.onlineEvents || []; | ||||
| 
 | ||||
|         if (this.deletedEvents?.size) { | ||||
|             // Mark as deleted the events that were deleted in offline.
 | ||||
|             result.forEach((event) => { | ||||
|                 event.deleted = this.deletedEvents?.has(event.id); | ||||
| 
 | ||||
|                 if (event.deleted) { | ||||
|                     day.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 != day.onlineEvents?.length) { | ||||
|                 day.hasOffline = true; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         if (dayOfflineEvents && dayOfflineEvents.length) { | ||||
|             // Add the offline events (either new or edited).
 | ||||
|             day.hasOffline = true; | ||||
|             result = AddonCalendarHelper.sortEvents(result.concat(dayOfflineEvents)); | ||||
|         } | ||||
| 
 | ||||
|         return result; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Invalidate content. | ||||
|      * | ||||
|      * @param selectedDay The current selected day. | ||||
|      * @param invalidateDayEvents Whether to invalidate selected day events. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async invalidateContent(selectedDay: PreloadedDay | null, invalidateDayEvents?: boolean): Promise<void> { | ||||
|         const promises: Promise<void>[] = []; | ||||
| 
 | ||||
|         if (invalidateDayEvents && selectedDay) { | ||||
|             promises.push(AddonCalendar.invalidateDayEvents( | ||||
|                 selectedDay.moment.year(), | ||||
|                 selectedDay.moment.month() + 1, | ||||
|                 selectedDay.moment.date(), | ||||
|             )); | ||||
|         } | ||||
|         promises.push(AddonCalendar.invalidateAllowedEventTypes()); | ||||
|         promises.push(CoreCourses.invalidateCategories(0, true)); | ||||
|         promises.push(AddonCalendar.invalidateTimeFormat()); | ||||
| 
 | ||||
|         this.categories = undefined; // Get categories again.
 | ||||
| 
 | ||||
|         if (selectedDay) { | ||||
|             selectedDay.dirty = true; | ||||
|         } | ||||
| 
 | ||||
|         await Promise.all(promises); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Find an event and mark it as deleted. | ||||
|      * | ||||
|      * @param eventId Event ID. | ||||
|      * @param deleted Whether to mark it as deleted or not. | ||||
|      */ | ||||
|     markAsDeleted(eventId: number, deleted: boolean): void { | ||||
|         // Mark the event as deleted or not.
 | ||||
|         this.getItems()?.some(day => { | ||||
|             const event = day.onlineEvents?.find((event) => event.id == eventId); | ||||
| 
 | ||||
|             if (!event) { | ||||
|                 return false; | ||||
|             } | ||||
| 
 | ||||
|             event.deleted = deleted; | ||||
| 
 | ||||
|             if (deleted) { | ||||
|                 day.hasOffline = true; | ||||
|             } else { | ||||
|                 // Re-calculate "hasOffline".
 | ||||
|                 day.hasOffline = day.events?.length != day.onlineEvents?.length || | ||||
|                     day.events?.some((event) => event.deleted || event.offline); | ||||
|             } | ||||
| 
 | ||||
|             return true; | ||||
|         }); | ||||
| 
 | ||||
|         // Add it or remove it from the list of deleted events.
 | ||||
|         if (deleted) { | ||||
|             this.deletedEvents?.add(eventId); | ||||
|         } else { | ||||
|             this.deletedEvents?.delete(eventId); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -6,4 +6,8 @@ | ||||
|             font-size: 1.2rem; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     core-swipe-slides { | ||||
|         --swipe-slides-min-height: calc(100% - 52px); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -92,7 +92,7 @@ export class AddonCalendarIndexPage implements OnInit, OnDestroy { | ||||
|             (data) => { | ||||
|                 if (data && data.eventId) { | ||||
|                     this.loaded = false; | ||||
|                     this.refreshData(true, false); | ||||
|                     this.refreshData(true, false, true); | ||||
|                 } | ||||
|             }, | ||||
|             this.currentSiteId, | ||||
| @ -101,7 +101,7 @@ export class AddonCalendarIndexPage implements OnInit, OnDestroy { | ||||
|         // 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.refreshData(true, false, true); | ||||
|         }, this.currentSiteId); | ||||
| 
 | ||||
|         // Listen for events edited. When an event is edited, reload the data.
 | ||||
| @ -110,7 +110,7 @@ export class AddonCalendarIndexPage implements OnInit, OnDestroy { | ||||
|             (data) => { | ||||
|                 if (data && data.eventId) { | ||||
|                     this.loaded = false; | ||||
|                     this.refreshData(true, false); | ||||
|                     this.refreshData(true, false, true); | ||||
|                 } | ||||
|             }, | ||||
|             this.currentSiteId, | ||||
| @ -119,21 +119,21 @@ export class AddonCalendarIndexPage implements OnInit, OnDestroy { | ||||
|         // Refresh data if calendar events are synchronized automatically.
 | ||||
|         this.syncObserver = CoreEvents.on(AddonCalendarSyncProvider.AUTO_SYNCED, () => { | ||||
|             this.loaded = false; | ||||
|             this.refreshData(false, false); | ||||
|             this.refreshData(false, 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) => { | ||||
|             if (data && data.source != 'index') { | ||||
|                 this.loaded = false; | ||||
|                 this.refreshData(false, false); | ||||
|                 this.refreshData(false, false, true); | ||||
|             } | ||||
|         }, 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.refreshData(false, false, true); | ||||
|         }, this.currentSiteId); | ||||
| 
 | ||||
|         // Update the "hasOffline" property if an event deleted in offline is restored.
 | ||||
| @ -278,7 +278,7 @@ export class AddonCalendarIndexPage implements OnInit, OnDestroy { | ||||
|      * @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<void> { | ||||
|     async refreshData(sync = false, showErrors = false, afterChange = false): Promise<void> { | ||||
|         this.syncIcon = CoreConstants.ICON_LOADING; | ||||
| 
 | ||||
|         const promises: Promise<void>[] = []; | ||||
| @ -287,7 +287,7 @@ export class AddonCalendarIndexPage implements OnInit, OnDestroy { | ||||
| 
 | ||||
|         // Refresh the sub-component.
 | ||||
|         if (this.showCalendar && this.calendarComponent) { | ||||
|             promises.push(this.calendarComponent.refreshData()); | ||||
|             promises.push(this.calendarComponent.refreshData(afterChange)); | ||||
|         } else if (!this.showCalendar && this.upcomingEventsComponent) { | ||||
|             promises.push(this.upcomingEventsComponent.refreshData()); | ||||
|         } | ||||
|  | ||||
| @ -140,7 +140,7 @@ export class AddonCalendarHelperProvider { | ||||
| 
 | ||||
|             // Add the event to all the days it lasts.
 | ||||
|             while (!treatedDay.isAfter(endDay, 'day')) { | ||||
|                 const monthId = this.getMonthId(treatedDay.year(), treatedDay.month() + 1); | ||||
|                 const monthId = this.getMonthId(treatedDay); | ||||
|                 const day = treatedDay.date(); | ||||
| 
 | ||||
|                 if (!result[monthId]) { | ||||
| @ -364,14 +364,23 @@ export class AddonCalendarHelperProvider { | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the month "id" (year + month). | ||||
|      * Get the month "id". | ||||
|      * | ||||
|      * @param year Year. | ||||
|      * @param month Month. | ||||
|      * @param moment Month moment. | ||||
|      * @return The "id". | ||||
|      */ | ||||
|     getMonthId(year: number, month: number): string { | ||||
|         return year + '#' + month; | ||||
|     getMonthId(moment: moment.Moment): string { | ||||
|         return `${moment.year()}#${moment.month() + 1}`; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the day "id". | ||||
|      * | ||||
|      * @param day Day moment. | ||||
|      * @return The "id". | ||||
|      */ | ||||
|     getDayId(moment: moment.Moment): string { | ||||
|         return `${this.getMonthId(moment)}#${moment.date()}`; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -650,7 +659,7 @@ export class AddonCalendarHelperProvider { | ||||
|             fetchTimestarts.map((fetchTime) => { | ||||
|                 const day = moment(new Date(fetchTime * 1000)); | ||||
| 
 | ||||
|                 const monthId = this.getMonthId(day.year(), day.month() + 1); | ||||
|                 const monthId = this.getMonthId(day); | ||||
|                 if (!treatedMonths[monthId]) { | ||||
|                     // Month not refetch or invalidated already, do it now.
 | ||||
|                     treatedMonths[monthId] = true; | ||||
| @ -686,7 +695,7 @@ export class AddonCalendarHelperProvider { | ||||
|             invalidateTimestarts.map((fetchTime) => { | ||||
|                 const day = moment(new Date(fetchTime * 1000)); | ||||
| 
 | ||||
|                 const monthId = this.getMonthId(day.year(), day.month() + 1); | ||||
|                 const monthId = this.getMonthId(day); | ||||
|                 if (!treatedMonths[monthId]) { | ||||
|                     // Month not refetch or invalidated already, do it now.
 | ||||
|                     treatedMonths[monthId] = true; | ||||
|  | ||||
| @ -29,6 +29,7 @@ import { AddonCalendarHelper } from './calendar-helper'; | ||||
| import { makeSingleton, Translate } from '@singletons'; | ||||
| import { CoreSync } from '@services/sync'; | ||||
| import { CoreNetworkError } from '@classes/errors/network-error'; | ||||
| import moment from 'moment'; | ||||
| 
 | ||||
| /** | ||||
|  * Service to sync calendar. | ||||
| @ -307,9 +308,7 @@ export type AddonCalendarSyncEvents = { | ||||
|     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.
 | ||||
|     moment?: moment.Moment; // Added on day page.
 | ||||
| }; | ||||
| 
 | ||||
| export type AddonCalendarSyncInvalidateEvent = { | ||||
|  | ||||
| @ -13,7 +13,7 @@ | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Params } from '@angular/router'; | ||||
| import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; | ||||
| import { CoreRoutedItemsManagerSource } from '@classes/items-management/routed-items-manager-source'; | ||||
| import { CoreGroupInfo, CoreGroups } from '@services/groups'; | ||||
| import { CoreSites } from '@services/sites'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| @ -33,7 +33,7 @@ import { AddonModAssignSync, AddonModAssignSyncProvider } from '../services/assi | ||||
| /** | ||||
|  * Provides a collection of assignment submissions. | ||||
|  */ | ||||
| export class AddonModAssignSubmissionsSource extends CoreItemsManagerSource<AddonModAssignSubmissionForList> { | ||||
| export class AddonModAssignSubmissionsSource extends CoreRoutedItemsManagerSource<AddonModAssignSubmissionForList> { | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|  | ||||
| @ -13,8 +13,8 @@ | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component, OnDestroy, AfterViewInit, ViewChild } from '@angular/core'; | ||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||
| import { CoreListItemsManager } from '@classes/items-management/list-items-manager'; | ||||
| import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; | ||||
| import { CoreSplitViewComponent } from '@components/split-view/split-view'; | ||||
| import { IonRefresher } from '@ionic/angular'; | ||||
| import { CoreGroupInfo } from '@services/groups'; | ||||
| @ -86,7 +86,7 @@ export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestro | ||||
|             const courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); | ||||
|             const groupId = CoreNavigator.getRouteNumberParam('groupId') || 0; | ||||
|             const selectedStatus = CoreNavigator.getRouteParam('status'); | ||||
|             const submissionsSource = CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||
|             const submissionsSource = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( | ||||
|                 AddonModAssignSubmissionsSource, | ||||
|                 [courseId, moduleId, selectedStatus], | ||||
|             ); | ||||
|  | ||||
| @ -14,8 +14,8 @@ | ||||
| 
 | ||||
| import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; | ||||
| import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; | ||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||
| import { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; | ||||
| import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; | ||||
| import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager'; | ||||
| import { CoreCourse } from '@features/course/services/course'; | ||||
| import { CanLeave } from '@guards/can-leave'; | ||||
| import { IonRefresher } from '@ionic/angular'; | ||||
| @ -64,7 +64,7 @@ export class AddonModAssignSubmissionReviewPage implements OnInit, OnDestroy, Ca | ||||
|                 this.blindId = CoreNavigator.getRouteNumberParam('blindId', { params }); | ||||
|                 const groupId = CoreNavigator.getRequiredRouteNumberParam('groupId'); | ||||
|                 const selectedStatus = CoreNavigator.getRouteParam('selectedStatus'); | ||||
|                 const submissionsSource = CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||
|                 const submissionsSource = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( | ||||
|                     AddonModAssignSubmissionsSource, | ||||
|                     [this.courseId, this.moduleId, selectedStatus], | ||||
|                 ); | ||||
| @ -216,7 +216,7 @@ export class AddonModAssignSubmissionReviewPage implements OnInit, OnDestroy, Ca | ||||
| /** | ||||
|  * Helper to manage swiping within a collection of submissions. | ||||
|  */ | ||||
| class AddonModAssignSubmissionSwipeItemsManager extends CoreSwipeItemsManager { | ||||
| class AddonModAssignSubmissionSwipeItemsManager extends CoreSwipeNavigationItemsManager { | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|  | ||||
| @ -40,15 +40,18 @@ | ||||
|             previousTranslate="addon.mod_book.navprevtitle" nextTranslate="addon.mod_book.navnexttitle" (action)="changeChapter($event.id)"> | ||||
|         </core-navigation-bar> | ||||
| 
 | ||||
|         <core-swipe-slides [manager]="manager" [options]="slidesOpts"> | ||||
|             <ng-template let-chapter="item"> | ||||
|                 <div class="ion-padding"> | ||||
|             <core-format-text [component]="component" [componentId]="componentId" [text]="chapterContent" contextLevel="module" | ||||
|                     <core-format-text [component]="component" [componentId]="componentId" [text]="chapter.content" contextLevel="module" | ||||
|                         [contextInstanceId]="module.id" [courseId]="courseId"></core-format-text> | ||||
| 
 | ||||
|             <div class="ion-margin-top" *ngIf="tagsEnabled && tags?.length > 0"> | ||||
|                     <div class="ion-margin-top" *ngIf="chapter.tags?.length > 0"> | ||||
|                         <strong>{{ 'core.tag.tags' | translate }}: </strong> | ||||
|                 <core-tag-list [tags]="tags"></core-tag-list> | ||||
|                         <core-tag-list [tags]="chapter.tags"></core-tag-list> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </ng-template> | ||||
|         </core-swipe-slides> | ||||
|     </div> | ||||
| 
 | ||||
| </core-loading> | ||||
|  | ||||
| @ -12,8 +12,7 @@ | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component, Optional, Input, OnInit } from '@angular/core'; | ||||
| import { IonContent } from '@ionic/angular'; | ||||
| import { Component, Optional, Input, OnInit, ViewChild, ElementRef, OnDestroy } from '@angular/core'; | ||||
| import { CoreCourseModuleMainResourceComponent } from '@features/course/classes/main-resource-component'; | ||||
| import { | ||||
|     AddonModBookProvider, | ||||
| @ -29,8 +28,14 @@ import { CoreCourseContentsPage } from '@features/course/pages/contents/contents | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { CoreCourse } from '@features/course/services/course'; | ||||
| import { AddonModBookTocComponent } from '../toc/toc'; | ||||
| import { CoreConstants } from '@/core/constants'; | ||||
| import { CoreNavigationBarItem } from '@components/navigation-bar/navigation-bar'; | ||||
| import { CoreError } from '@classes/errors/error'; | ||||
| import { Translate } from '@singletons'; | ||||
| import { CoreSwipeSlidesComponent, CoreSwipeSlidesOptions } from '@components/swipe-slides/swipe-slides'; | ||||
| import { CoreSwipeSlidesItemsManagerSource } from '@classes/items-management/swipe-slides-items-manager-source'; | ||||
| import { CoreCourseModule } from '@features/course/services/course-helper'; | ||||
| import { CoreSwipeSlidesItemsManager } from '@classes/items-management/swipe-slides-items-manager'; | ||||
| import { CoreTextUtils } from '@services/utils/text'; | ||||
| 
 | ||||
| /** | ||||
|  * Component that displays a book. | ||||
| @ -39,29 +44,34 @@ import { CoreNavigationBarItem } from '@components/navigation-bar/navigation-bar | ||||
|     selector: 'addon-mod-book-index', | ||||
|     templateUrl: 'addon-mod-book-index.html', | ||||
| }) | ||||
| export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComponent implements OnInit { | ||||
| export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy { | ||||
| 
 | ||||
|     @ViewChild(CoreSwipeSlidesComponent) slides?: CoreSwipeSlidesComponent; | ||||
| 
 | ||||
|     @Input() initialChapterId?: number; // The initial chapter ID to load.
 | ||||
| 
 | ||||
|     component = AddonModBookProvider.COMPONENT; | ||||
|     chapterContent?: string; | ||||
|     tagsEnabled = false; | ||||
|     manager?: CoreSwipeSlidesItemsManager<LoadedChapter, AddonModBookSlidesItemsManagerSource>; | ||||
|     warning = ''; | ||||
|     tags?: CoreTagItem[]; | ||||
|     displayNavBar = true; | ||||
|     navigationItems: CoreNavigationBarItem<AddonModBookTocChapter>[] = []; | ||||
|     displayTitlesInNavBar = false; | ||||
|     slidesOpts: CoreSwipeSlidesOptions = { | ||||
|         autoHeight: true, | ||||
|         scrollOnChange: 'top', | ||||
|     }; | ||||
| 
 | ||||
|     protected chapters: AddonModBookTocChapter[] = []; | ||||
|     protected currentChapter?: number; | ||||
|     protected book?: AddonModBookBookWSData; | ||||
|     protected contentsMap: AddonModBookContentsMap = {}; | ||||
|     protected firstLoad = true; | ||||
|     protected element: HTMLElement; | ||||
|     protected managerUnsubscribe?: () => void; | ||||
| 
 | ||||
|     constructor( | ||||
|         protected content?: IonContent, | ||||
|         elementRef: ElementRef, | ||||
|         @Optional() courseContentsPage?: CoreCourseContentsPage, | ||||
|     ) { | ||||
|         super('AddonModBookIndexComponent', courseContentsPage); | ||||
| 
 | ||||
|         this.element = elementRef.nativeElement; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -70,21 +80,43 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp | ||||
|     async ngOnInit(): Promise<void> { | ||||
|         super.ngOnInit(); | ||||
| 
 | ||||
|         this.tagsEnabled = CoreTag.areTagsAvailableInSite(); | ||||
|         const source = new AddonModBookSlidesItemsManagerSource( | ||||
|             this.courseId, | ||||
|             this.module, | ||||
|             CoreTag.areTagsAvailableInSite(), | ||||
|             this.initialChapterId, | ||||
|         ); | ||||
|         this.manager = new CoreSwipeSlidesItemsManager(source); | ||||
|         this.managerUnsubscribe = this.manager.addListener({ | ||||
|             onSelectedItemUpdated: (item) => { | ||||
|                 this.onChapterViewed(item.id); | ||||
|             }, | ||||
|         }); | ||||
| 
 | ||||
|         this.loadContent(); | ||||
|     } | ||||
| 
 | ||||
|     get book(): AddonModBookBookWSData | undefined { | ||||
|         return this.manager?.getSource().book; | ||||
|     } | ||||
| 
 | ||||
|     get chapters(): AddonModBookTocChapter[] { | ||||
|         return this.manager?.getSource().chapters || []; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Show the TOC. | ||||
|      */ | ||||
|     async showToc(): Promise<void> { | ||||
|         // Create the toc modal.
 | ||||
|         const visibleChapter = this.manager?.getSelectedItem(); | ||||
| 
 | ||||
|         const modalData = await CoreDomUtils.openSideModal<number>({ | ||||
|             component: AddonModBookTocComponent, | ||||
|             componentProps: { | ||||
|                 moduleId: this.module.id, | ||||
|                 chapters: this.chapters, | ||||
|                 selected: this.currentChapter, | ||||
|                 selected: visibleChapter, | ||||
|                 courseId: this.courseId, | ||||
|                 book: this.book, | ||||
|             }, | ||||
| @ -102,11 +134,11 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     changeChapter(chapterId: number): void { | ||||
|         if (chapterId && chapterId != this.currentChapter) { | ||||
|             this.loaded = false; | ||||
|             this.refreshIcon = CoreConstants.ICON_LOADING; | ||||
|             this.loadChapter(chapterId, true); | ||||
|         if (!chapterId) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.slides?.slideToItem({ id: chapterId }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -114,8 +146,8 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp | ||||
|      * | ||||
|      * @return Resolved when done. | ||||
|      */ | ||||
|     protected invalidateContent(): Promise<void> { | ||||
|         return AddonModBook.invalidateContent(this.module.id, this.courseId); | ||||
|     protected async invalidateContent(): Promise<void> { | ||||
|         await this.manager?.getSource().invalidateContent(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -126,42 +158,29 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp | ||||
|      */ | ||||
|     protected async fetchContent(refresh = false): Promise<void> { | ||||
|         try { | ||||
|             const downloadResult = await this.downloadResourceIfNeeded(refresh); | ||||
| 
 | ||||
|             await this.loadBookData(); | ||||
| 
 | ||||
|             // Get contents. No need to refresh, it has been done in downloadResourceIfNeeded.
 | ||||
|             const contents = await CoreCourse.getModuleContents(this.module, this.courseId); | ||||
| 
 | ||||
|             this.contentsMap = AddonModBook.getContentsMap(contents); | ||||
|             this.chapters = AddonModBook.getTocList(contents); | ||||
| 
 | ||||
|             if (typeof this.currentChapter == 'undefined' && typeof this.initialChapterId != 'undefined' && this.chapters) { | ||||
|                 // Initial chapter set. Validate that the chapter exists.
 | ||||
|                 const chapter = this.chapters.find((chapter) => chapter.id == this.initialChapterId); | ||||
| 
 | ||||
|                 if (chapter) { | ||||
|                     this.currentChapter = this.initialChapterId; | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             if (this.currentChapter === undefined) { | ||||
|                 // Load the first chapter.
 | ||||
|                 this.currentChapter = AddonModBook.getFirstChapter(this.chapters); | ||||
|             } | ||||
| 
 | ||||
|             if (this.currentChapter === undefined) { | ||||
|             const source = this.manager?.getSource(); | ||||
|             if (!source) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             // Show chapter.
 | ||||
|             try { | ||||
|                 await this.loadChapter(this.currentChapter, refresh); | ||||
|             const downloadResult = await this.downloadResourceIfNeeded(refresh); | ||||
| 
 | ||||
|                 this.warning = downloadResult?.failed ? this.getErrorDownloadingSomeFilesMessage(downloadResult.error!) : ''; | ||||
|             } catch { | ||||
|                 // Ignore errors, they're handled inside the loadChapter function.
 | ||||
|             const book = await source.loadBookData(); | ||||
| 
 | ||||
|             if (book) { | ||||
|                 this.dataRetrieved.emit(book); | ||||
| 
 | ||||
|                 this.description = book.intro; | ||||
|                 this.displayNavBar = book.navstyle != AddonModBookNavStyle.TOC_ONLY; | ||||
|                 this.displayTitlesInNavBar = book.navstyle == AddonModBookNavStyle.TEXT; | ||||
|             } | ||||
| 
 | ||||
|             // Get contents. No need to refresh, it has been done in downloadResourceIfNeeded.
 | ||||
|             await source.loadContents(); | ||||
| 
 | ||||
|             await source.load(); | ||||
| 
 | ||||
|             this.warning = downloadResult?.failed ? this.getErrorDownloadingSomeFilesMessage(downloadResult.error || '') : ''; | ||||
|         } finally { | ||||
|             // Pass false because downloadResourceIfNeeded already invalidates and refresh data if refresh=true.
 | ||||
|             this.fillContextMenu(false); | ||||
| @ -169,43 +188,21 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load book data from WS. | ||||
|      * Update data related to chapter being viewed. | ||||
|      * | ||||
|      * @param chapterId Chapter viewed. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     protected async loadBookData(): Promise<void> { | ||||
|         this.book = await AddonModBook.getBook(this.courseId, this.module.id); | ||||
| 
 | ||||
|         this.dataRetrieved.emit(this.book); | ||||
| 
 | ||||
|         this.description = this.book.intro; | ||||
|         this.displayNavBar = this.book.navstyle != AddonModBookNavStyle.TOC_ONLY; | ||||
|         this.displayTitlesInNavBar = this.book.navstyle == AddonModBookNavStyle.TEXT; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load a book chapter. | ||||
|      * | ||||
|      * @param chapterId Chapter to load. | ||||
|      * @param logChapterId Whether chapter ID should be passed to the log view function. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     protected async loadChapter(chapterId: number, logChapterId: boolean): Promise<void> { | ||||
|         this.currentChapter = chapterId; | ||||
|         this.content?.scrollToTop(); | ||||
| 
 | ||||
|         try { | ||||
|             const content = await AddonModBook.getChapterContent(this.contentsMap, chapterId, this.module.id); | ||||
| 
 | ||||
|             this.tags = this.tagsEnabled ? this.contentsMap[this.currentChapter].tags : []; | ||||
| 
 | ||||
|             this.chapterContent = content; | ||||
|     protected async onChapterViewed(chapterId: number): Promise<void> { | ||||
|         // Don't log the chapter ID when the user has just opened the book.
 | ||||
|         const logChapterId = this.firstLoad; | ||||
|         this.firstLoad = false; | ||||
| 
 | ||||
|         if (this.displayNavBar) { | ||||
|             this.navigationItems = this.getNavigationItems(chapterId); | ||||
|         } | ||||
| 
 | ||||
|             // Chapter loaded, log view. We don't return the promise because we don't want to block the user for this.
 | ||||
|         // Chapter loaded, log view.
 | ||||
|         await CoreUtils.ignoreErrors(AddonModBook.logView( | ||||
|             this.module.instance!, | ||||
|             logChapterId ? chapterId : undefined, | ||||
| @ -219,14 +216,6 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp | ||||
|         if (isLastChapter) { | ||||
|             CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); | ||||
|         } | ||||
|         } catch (error) { | ||||
|             CoreDomUtils.showErrorModalDefault(error, 'addon.mod_book.errorchapter', true); | ||||
| 
 | ||||
|             throw error; | ||||
|         } finally { | ||||
|             this.loaded = true; | ||||
|             this.refreshIcon = CoreConstants.ICON_REFRESH; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -244,4 +233,104 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp | ||||
|         })); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     ngOnDestroy(): void { | ||||
|         super.ngOnDestroy(); | ||||
| 
 | ||||
|         this.managerUnsubscribe && this.managerUnsubscribe(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| type LoadedChapter = { | ||||
|     id: number; | ||||
|     content?: string; | ||||
|     tags?: CoreTagItem[]; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Helper to manage swiping within a collection of chapters. | ||||
|  */ | ||||
| class AddonModBookSlidesItemsManagerSource extends CoreSwipeSlidesItemsManagerSource<LoadedChapter> { | ||||
| 
 | ||||
|     readonly COURSE_ID: number; | ||||
|     readonly MODULE: CoreCourseModule; | ||||
|     readonly TAGS_ENABLED: boolean; | ||||
| 
 | ||||
|     book?: AddonModBookBookWSData; | ||||
|     chapters: AddonModBookTocChapter[] = []; | ||||
|     contentsMap: AddonModBookContentsMap = {}; | ||||
| 
 | ||||
|     constructor(courseId: number, module: CoreCourseModule, tagsEnabled: boolean, initialChapterId?: number) { | ||||
|         super(initialChapterId ? { id: initialChapterId } : undefined); | ||||
| 
 | ||||
|         this.COURSE_ID = courseId; | ||||
|         this.MODULE = module; | ||||
|         this.TAGS_ENABLED = tagsEnabled; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getItemId(item: LoadedChapter): string | number { | ||||
|         return item.id; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load book data from WS. | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async loadBookData(): Promise<AddonModBookBookWSData> { | ||||
|         this.book = await AddonModBook.getBook(this.COURSE_ID, this.MODULE.id); | ||||
| 
 | ||||
|         return this.book; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load module contents. | ||||
|      */ | ||||
|     async loadContents(): Promise<void> { | ||||
|         const contents = await CoreCourse.getModuleContents(this.MODULE, this.COURSE_ID); | ||||
| 
 | ||||
|         this.contentsMap = AddonModBook.getContentsMap(contents); | ||||
|         this.chapters = AddonModBook.getTocList(contents); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected async loadItems(): Promise<LoadedChapter[]> { | ||||
|         try { | ||||
|             const newChapters = await Promise.all(this.chapters.map(async (chapter) => { | ||||
|                 const content = await AddonModBook.getChapterContent(this.contentsMap, chapter.id, this.MODULE.id); | ||||
| 
 | ||||
|                 return { | ||||
|                     id: chapter.id, | ||||
|                     content, | ||||
|                     tags: this.TAGS_ENABLED ? this.contentsMap[chapter.id].tags : [], | ||||
|                 }; | ||||
|             })); | ||||
| 
 | ||||
|             return newChapters; | ||||
|         } catch (error) { | ||||
|             if (!CoreTextUtils.getErrorMessageFromError(error)) { | ||||
|                 throw new CoreError(Translate.instant('addon.mod_book.errorchapter')); | ||||
|             } | ||||
| 
 | ||||
|             throw error; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Perform the invalidate content function. | ||||
|      * | ||||
|      * @return Resolved when done. | ||||
|      */ | ||||
|     invalidateContent(): Promise<void> { | ||||
|         return AddonModBook.invalidateContent(this.MODULE.id, this.COURSE_ID); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -13,7 +13,7 @@ | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Params } from '@angular/router'; | ||||
| import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; | ||||
| import { CoreRoutedItemsManagerSource } from '@classes/items-management/routed-items-manager-source'; | ||||
| import { CoreUser } from '@features/user/services/user'; | ||||
| import { | ||||
|     AddonModForum, | ||||
| @ -24,7 +24,7 @@ import { | ||||
| } from '../services/forum'; | ||||
| import { AddonModForumOffline, AddonModForumOfflineDiscussion } from '../services/forum-offline'; | ||||
| 
 | ||||
| export class AddonModForumDiscussionsSource extends CoreItemsManagerSource<AddonModForumDiscussionItem> { | ||||
| export class AddonModForumDiscussionsSource extends CoreRoutedItemsManagerSource<AddonModForumDiscussionItem> { | ||||
| 
 | ||||
|     static readonly NEW_DISCUSSION: AddonModForumNewDiscussionForm = { newDiscussion: true }; | ||||
| 
 | ||||
|  | ||||
| @ -12,14 +12,14 @@ | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; | ||||
| import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager'; | ||||
| import { AddonModForumDiscussionItem, AddonModForumDiscussionsSource } from './forum-discussions-source'; | ||||
| 
 | ||||
| /** | ||||
|  * Helper to manage swiping within a collection of discussions. | ||||
|  */ | ||||
| export class AddonModForumDiscussionsSwipeManager | ||||
|     extends CoreSwipeItemsManager<AddonModForumDiscussionItem, AddonModForumDiscussionsSource> { | ||||
|     extends CoreSwipeNavigationItemsManager<AddonModForumDiscussionItem, AddonModForumDiscussionsSource> { | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|  | ||||
| @ -57,7 +57,7 @@ import { CoreRatingOffline } from '@features/rating/services/rating-offline'; | ||||
| import { ContextLevel } from '@/core/constants'; | ||||
| import { AddonModForumDiscussionItem, AddonModForumDiscussionsSource } from '../../classes/forum-discussions-source'; | ||||
| import { CoreListItemsManager } from '@classes/items-management/list-items-manager'; | ||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||
| import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; | ||||
| 
 | ||||
| /** | ||||
|  * Component that displays a forum entry page. | ||||
| @ -149,7 +149,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | ||||
|         await super.ngOnInit(); | ||||
| 
 | ||||
|         // Initialize discussions manager.
 | ||||
|         const source = CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||
|         const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( | ||||
|             AddonModForumDiscussionsSource, | ||||
|             [this.courseId, this.module.id, this.courseContentsPage ? `${AddonModForumModuleHandlerService.PAGE_NAME}/` : ''], | ||||
|         ); | ||||
|  | ||||
| @ -15,7 +15,7 @@ | ||||
| import { ContextLevel, CoreConstants } from '@/core/constants'; | ||||
| import { Component, OnDestroy, ViewChild, OnInit, AfterViewInit, ElementRef, Optional } from '@angular/core'; | ||||
| import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; | ||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||
| import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; | ||||
| import { CoreSplitViewComponent } from '@components/split-view/split-view'; | ||||
| import { CoreFileUploader } from '@features/fileuploader/services/fileuploader'; | ||||
| import { CoreRatingInfo, CoreRatingProvider } from '@features/rating/services/rating'; | ||||
| @ -145,7 +145,7 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes | ||||
| 
 | ||||
|             if (this.courseId && this.cmId && (routeData.swipeEnabled ?? true)) { | ||||
|                 this.discussions = new AddonModForumDiscussionDiscussionsSwipeManager( | ||||
|                     CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||
|                     CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( | ||||
|                         AddonModForumDiscussionsSource, | ||||
|                         [this.courseId, this.cmId, routeData.discussionsPathPrefix ?? ''], | ||||
|                     ), | ||||
|  | ||||
| @ -42,8 +42,8 @@ import { CoreSplitViewComponent } from '@components/split-view/split-view'; | ||||
| import { CoreForms } from '@singletons/form'; | ||||
| import { AddonModForumDiscussionsSwipeManager } from '../../classes/forum-discussions-swipe-manager'; | ||||
| import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; | ||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||
| import { AddonModForumDiscussionsSource } from '../../classes/forum-discussions-source'; | ||||
| import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; | ||||
| 
 | ||||
| type NewDiscussionData = { | ||||
|     subject: string; | ||||
| @ -117,7 +117,7 @@ export class AddonModForumNewDiscussionPage implements OnInit, OnDestroy, CanLea | ||||
|             this.timeCreated = CoreNavigator.getRequiredRouteNumberParam('timeCreated'); | ||||
| 
 | ||||
|             if (this.timeCreated !== 0 && (routeData.swipeEnabled ?? true)) { | ||||
|                 const source = CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||
|                 const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( | ||||
|                     AddonModForumDiscussionsSource, | ||||
|                     [this.courseId, this.cmId, routeData.discussionsPathPrefix ?? ''], | ||||
|                 ); | ||||
|  | ||||
| @ -13,7 +13,7 @@ | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Params } from '@angular/router'; | ||||
| import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; | ||||
| import { CoreRoutedItemsManagerSource } from '@classes/items-management/routed-items-manager-source'; | ||||
| import { | ||||
|     AddonModGlossary, | ||||
|     AddonModGlossaryEntry, | ||||
| @ -27,7 +27,7 @@ import { AddonModGlossaryOffline, AddonModGlossaryOfflineEntry } from '../servic | ||||
| /** | ||||
|  * Provides a collection of glossary entries. | ||||
|  */ | ||||
| export class AddonModGlossaryEntriesSource extends CoreItemsManagerSource<AddonModGlossaryEntryItem> { | ||||
| export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource<AddonModGlossaryEntryItem> { | ||||
| 
 | ||||
|     static readonly NEW_ENTRY: AddonModGlossaryNewEntryForm = { newEntry: true }; | ||||
| 
 | ||||
|  | ||||
| @ -12,14 +12,14 @@ | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; | ||||
| import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager'; | ||||
| import { AddonModGlossaryEntriesSource, AddonModGlossaryEntryItem } from './glossary-entries-source'; | ||||
| 
 | ||||
| /** | ||||
|  * Helper to manage swiping within a collection of glossary entries. | ||||
|  */ | ||||
| export abstract class AddonModGlossaryEntriesSwipeManager | ||||
|     extends CoreSwipeItemsManager<AddonModGlossaryEntryItem, AddonModGlossaryEntriesSource> { | ||||
|     extends CoreSwipeNavigationItemsManager<AddonModGlossaryEntryItem, AddonModGlossaryEntriesSource> { | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|  | ||||
| @ -15,8 +15,8 @@ | ||||
| import { ContextLevel } from '@/core/constants'; | ||||
| import { AfterViewInit, Component, OnDestroy, OnInit, Optional, ViewChild } from '@angular/core'; | ||||
| import { ActivatedRoute } from '@angular/router'; | ||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||
| import { CoreListItemsManager } from '@classes/items-management/list-items-manager'; | ||||
| import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; | ||||
| import { CoreSplitViewComponent } from '@components/split-view/split-view'; | ||||
| import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; | ||||
| import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; | ||||
| @ -113,7 +113,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | ||||
|         await super.ngOnInit(); | ||||
| 
 | ||||
|         // Initialize entries manager.
 | ||||
|         const source = CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||
|         const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( | ||||
|             AddonModGlossaryEntriesSource, | ||||
|             [this.courseId, this.module.id, this.courseContentsPage ? `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` : ''], | ||||
|         ); | ||||
|  | ||||
| @ -16,7 +16,7 @@ import { Component, OnInit, ViewChild, ElementRef, Optional, OnDestroy } from '@ | ||||
| import { FormControl } from '@angular/forms'; | ||||
| import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; | ||||
| import { CoreError } from '@classes/errors/error'; | ||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||
| import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; | ||||
| import { CoreSplitViewComponent } from '@components/split-view/split-view'; | ||||
| import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; | ||||
| import { CanLeave } from '@guards/can-leave'; | ||||
| @ -101,7 +101,7 @@ export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave { | ||||
|             this.editorExtraParams.timecreated = this.timecreated; | ||||
| 
 | ||||
|             if (this.timecreated !== 0 && (routeData.swipeEnabled ?? true)) { | ||||
|                 const source = CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||
|                 const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( | ||||
|                     AddonModGlossaryEntriesSource, | ||||
|                     [this.courseId, this.cmId, routeData.glossaryPathPrefix ?? ''], | ||||
|                 ); | ||||
|  | ||||
| @ -14,7 +14,7 @@ | ||||
| 
 | ||||
| import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; | ||||
| import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; | ||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||
| import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; | ||||
| import { CoreCommentsCommentsComponent } from '@features/comments/components/comments/comments'; | ||||
| import { CoreComments } from '@features/comments/services/comments'; | ||||
| import { CoreRatingInfo } from '@features/rating/services/rating'; | ||||
| @ -73,7 +73,7 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy { | ||||
| 
 | ||||
|             if (routeData.swipeEnabled ?? true) { | ||||
|                 const cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); | ||||
|                 const source = CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||
|                 const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( | ||||
|                     AddonModGlossaryEntriesSource, | ||||
|                     [this.courseId, cmId, routeData.glossaryPathPrefix ?? ''], | ||||
|                 ); | ||||
|  | ||||
| @ -12,13 +12,11 @@ | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Params } from '@angular/router'; | ||||
| 
 | ||||
| /** | ||||
|  * Updates listener. | ||||
|  */ | ||||
| export interface CoreItemsListSourceListener<Item> { | ||||
|     onItemsUpdated?(items: Item[], hasMoreItems: boolean): void; | ||||
| export interface CoreItemsManagerSourceListener<Item> { | ||||
|     onItemsUpdated?(items: Item[]): void; | ||||
|     onReset?(): void; | ||||
| } | ||||
| 
 | ||||
| @ -27,37 +25,41 @@ export interface CoreItemsListSourceListener<Item> { | ||||
|  */ | ||||
| export abstract class CoreItemsManagerSource<Item = unknown> { | ||||
| 
 | ||||
|     /** | ||||
|      * Get a string to identify instances constructed with the given arguments as being reusable. | ||||
|      * | ||||
|      * @param args Constructor arguments. | ||||
|      * @returns Id. | ||||
|      */ | ||||
|     static getSourceId(...args: unknown[]): string { | ||||
|         return args.map(argument => String(argument)).join('-'); | ||||
|     protected items: Item[] | null = null; | ||||
|     protected listeners: CoreItemsManagerSourceListener<Item>[] = []; | ||||
|     protected dirty = false; | ||||
|     protected loaded = false; | ||||
|     protected loadedPromise: Promise<void>; | ||||
|     protected resolveLoaded!: () => void; | ||||
| 
 | ||||
|     constructor() { | ||||
|         this.loadedPromise = new Promise(resolve => this.resolveLoaded = resolve); | ||||
|     } | ||||
| 
 | ||||
|     protected items: Item[] | null = null; | ||||
|     protected hasMoreItems = true; | ||||
|     protected listeners: CoreItemsListSourceListener<Item>[] = []; | ||||
|     protected dirty = false; | ||||
| 
 | ||||
|     /** | ||||
|      * Check whether any page has been loaded. | ||||
|      * Check whether data is loaded. | ||||
|      * | ||||
|      * @returns Whether any page has been loaded. | ||||
|      * @returns Whether data is loaded. | ||||
|      */ | ||||
|     isLoaded(): boolean { | ||||
|         return this.items !== null; | ||||
|         return this.loaded; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Check whether there are more pages to be loaded. | ||||
|      * Return a promise that is resolved when the data is loaded. | ||||
|      * | ||||
|      * @return Whether there are more pages to be loaded. | ||||
|      * @return Promise. | ||||
|      */ | ||||
|     isCompleted(): boolean { | ||||
|         return !this.hasMoreItems; | ||||
|     waitForLoaded(): Promise<void> { | ||||
|         return this.loadedPromise; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Mark the source as initialized. | ||||
|      */ | ||||
|     protected setLoaded(): void { | ||||
|         this.loaded = true; | ||||
|         this.resolveLoaded(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -80,42 +82,28 @@ export abstract class CoreItemsManagerSource<Item = unknown> { | ||||
|         return this.items; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the count of pages that have been loaded. | ||||
|      * | ||||
|      * @returns Pages loaded. | ||||
|      */ | ||||
|     getPagesLoaded(): number { | ||||
|         if (this.items === null) { | ||||
|             return 0; | ||||
|         } | ||||
| 
 | ||||
|         const pageLength = this.getPageLength(); | ||||
|         if (pageLength === null) { | ||||
|             return 1; | ||||
|         } | ||||
| 
 | ||||
|         return Math.ceil(this.items.length / pageLength); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Reset collection data. | ||||
|      */ | ||||
|     reset(): void { | ||||
|         this.items = null; | ||||
|         this.hasMoreItems = true; | ||||
|         this.dirty = false; | ||||
| 
 | ||||
|         this.listeners.forEach(listener => listener.onReset?.call(listener)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load items. | ||||
|      */ | ||||
|     abstract load(): Promise<void>; | ||||
| 
 | ||||
|     /** | ||||
|      * Register a listener. | ||||
|      * | ||||
|      * @param listener Listener. | ||||
|      * @returns Unsubscribe function. | ||||
|      */ | ||||
|     addListener(listener: CoreItemsListSourceListener<Item>): () => void { | ||||
|     addListener(listener: CoreItemsManagerSourceListener<Item>): () => void { | ||||
|         this.listeners.push(listener); | ||||
| 
 | ||||
|         return () => this.removeListener(listener); | ||||
| @ -126,7 +114,7 @@ export abstract class CoreItemsManagerSource<Item = unknown> { | ||||
|      * | ||||
|      * @param listener Listener. | ||||
|      */ | ||||
|     removeListener(listener: CoreItemsListSourceListener<Item>): void { | ||||
|     removeListener(listener: CoreItemsManagerSourceListener<Item>): void { | ||||
|         const index = this.listeners.indexOf(listener); | ||||
| 
 | ||||
|         if (index === -1) { | ||||
| @ -136,85 +124,23 @@ export abstract class CoreItemsManagerSource<Item = unknown> { | ||||
|         this.listeners.splice(index, 1); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Reload the collection, this resets the data to the first page. | ||||
|      */ | ||||
|     async reload(): Promise<void> { | ||||
|         const { items, hasMoreItems } = await this.loadPageItems(0); | ||||
| 
 | ||||
|         this.dirty = false; | ||||
|         this.setItems(items, hasMoreItems ?? false); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load more items, if any. | ||||
|      */ | ||||
|     async load(): Promise<void> { | ||||
|         if (this.dirty) { | ||||
|             const { items, hasMoreItems } = await this.loadPageItems(0); | ||||
| 
 | ||||
|             this.dirty = false; | ||||
|             this.setItems(items, hasMoreItems ?? false); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (!this.hasMoreItems) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const { items, hasMoreItems } = await this.loadPageItems(this.getPagesLoaded()); | ||||
| 
 | ||||
|         this.setItems((this.items ?? []).concat(items), hasMoreItems ?? false); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the query parameters to use when navigating to an item page. | ||||
|      * | ||||
|      * @param item Item. | ||||
|      * @return Query parameters to use when navigating to the item page. | ||||
|      */ | ||||
|     // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | ||||
|     getItemQueryParams(item: Item): Params { | ||||
|         return {}; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the path to use when navigating to an item page. | ||||
|      * | ||||
|      * @param item Item. | ||||
|      * @return Path to use when navigating to the item page. | ||||
|      */ | ||||
|     abstract getItemPath(item: Item): string; | ||||
| 
 | ||||
|     /** | ||||
|      * Load page items. | ||||
|      * | ||||
|      * @param page Page number (starting at 0). | ||||
|      * @return Page items data. | ||||
|      */ | ||||
|     protected abstract loadPageItems(page: number): Promise<{ items: Item[]; hasMoreItems?: boolean }>; | ||||
| 
 | ||||
|     /** | ||||
|      * Get the length of each page in the collection. | ||||
|      * | ||||
|      * @return Page length; null for collections that don't support pagination. | ||||
|      */ | ||||
|     protected getPageLength(): number | null { | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Update the collection items. | ||||
|      * | ||||
|      * @param items Items. | ||||
|      * @param hasMoreItems Whether there are more pages to be loaded. | ||||
|      */ | ||||
|     protected setItems(items: Item[], hasMoreItems: boolean): void { | ||||
|     protected setItems(items: Item[]): void { | ||||
|         this.items = items; | ||||
|         this.hasMoreItems = hasMoreItems; | ||||
|         this.setLoaded(); | ||||
| 
 | ||||
|         this.listeners.forEach(listener => listener.onItemsUpdated?.call(listener, items, hasMoreItems)); | ||||
|         this.notifyItemsUpdated(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Notify that items have been updated. | ||||
|      */ | ||||
|     protected notifyItemsUpdated(): void { | ||||
|         this.listeners.forEach(listener => listener.onItemsUpdated?.call(listener, this.items)); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -12,20 +12,27 @@ | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; | ||||
| import { CoreNavigationOptions, CoreNavigator } from '@services/navigator'; | ||||
| 
 | ||||
| import { CoreItemsManagerSource } from './items-manager-source'; | ||||
| import { CoreItemsManagerSourcesTracker } from './items-manager-sources-tracker'; | ||||
| 
 | ||||
| /** | ||||
|  * Listeners. | ||||
|  */ | ||||
| export interface CoreItemsanagerListener<Item> { | ||||
|     onSelectedItemUpdated?(item: Item): void; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Helper to manage a collection of items in a page. | ||||
|  */ | ||||
| export abstract class CoreItemsManager<Item = unknown, Source extends CoreItemsManagerSource<Item> = CoreItemsManagerSource<Item>> { | ||||
| export abstract class CoreItemsManager< | ||||
|     Item = unknown, | ||||
|     Source extends CoreItemsManagerSource<Item> = CoreItemsManagerSource<Item>, | ||||
| > { | ||||
| 
 | ||||
|     protected source?: { instance: Source; unsubscribe: () => void }; | ||||
|     protected itemsMap: Record<string, Item> | null = null; | ||||
|     protected selectedItem: Item | null = null; | ||||
|     protected listeners: CoreItemsanagerListener<Item>[] = []; | ||||
| 
 | ||||
|     constructor(source: Source) { | ||||
|         this.setSource(source); | ||||
| @ -51,8 +58,6 @@ export abstract class CoreItemsManager<Item = unknown, Source extends CoreItemsM | ||||
|      */ | ||||
|     setSource(newSource: Source | null): void { | ||||
|         if (this.source) { | ||||
|             CoreItemsManagerSourcesTracker.removeReference(this.source.instance, this); | ||||
| 
 | ||||
|             this.source.unsubscribe(); | ||||
|             delete this.source; | ||||
| 
 | ||||
| @ -60,8 +65,6 @@ export abstract class CoreItemsManager<Item = unknown, Source extends CoreItemsM | ||||
|         } | ||||
| 
 | ||||
|         if (newSource) { | ||||
|             CoreItemsManagerSourcesTracker.addReference(newSource, this); | ||||
| 
 | ||||
|             this.source = { | ||||
|                 instance: newSource, | ||||
|                 unsubscribe: newSource.addListener({ | ||||
| @ -86,79 +89,50 @@ export abstract class CoreItemsManager<Item = unknown, Source extends CoreItemsM | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get page route. | ||||
|      * Get selected item. | ||||
|      * | ||||
|      * @returns Current page route, if any. | ||||
|      * @return Selected item, null if none. | ||||
|      */ | ||||
|     protected abstract getCurrentPageRoute(): ActivatedRoute | null; | ||||
| 
 | ||||
|     /** | ||||
|      * Get the path of the selected item given the current route. | ||||
|      * | ||||
|      * @param route Page route. | ||||
|      * @return Path of the selected item in the given route. | ||||
|      */ | ||||
|     protected abstract getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null; | ||||
| 
 | ||||
|     /** | ||||
|      * Get the path of the selected item. | ||||
|      * | ||||
|      * @param route Page route, if any. | ||||
|      * @return Path of the selected item. | ||||
|      */ | ||||
|     protected getSelectedItemPath(route?: ActivatedRouteSnapshot | null): string | null { | ||||
|         if (!route) { | ||||
|             return null; | ||||
|         } | ||||
| 
 | ||||
|         return this.getSelectedItemPathFromRoute(route); | ||||
|     getSelectedItem(): Item | null { | ||||
|         return this.selectedItem; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Update the selected item given the current route. | ||||
|      * Set selected item. | ||||
|      * | ||||
|      * @param route Current route. | ||||
|      * @param item Item, null if none. | ||||
|      */ | ||||
|     protected updateSelectedItem(route: ActivatedRouteSnapshot | null = null): void { | ||||
|         route = route ?? this.getCurrentPageRoute()?.snapshot ?? null; | ||||
|     setSelectedItem(item: Item | null): void { | ||||
|         this.selectedItem = item; | ||||
| 
 | ||||
|         const selectedItemPath = this.getSelectedItemPath(route); | ||||
| 
 | ||||
|         this.selectedItem = selectedItemPath | ||||
|             ? this.itemsMap?.[selectedItemPath] ?? null | ||||
|             : null; | ||||
|         this.listeners.forEach(listener => listener.onSelectedItemUpdated?.call(listener, item)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Navigate to an item in the collection. | ||||
|      * Register a listener. | ||||
|      * | ||||
|      * @param item Item. | ||||
|      * @param options Navigation options. | ||||
|      * @param listener Listener. | ||||
|      * @returns Unsubscribe function. | ||||
|      */ | ||||
|     protected async navigateToItem( | ||||
|         item: Item, | ||||
|         options: Pick<CoreNavigationOptions, 'reset' | 'replace' | 'animationDirection'> = {}, | ||||
|     ): Promise<void> { | ||||
|         // Get current route in the page.
 | ||||
|         const route = this.getCurrentPageRoute(); | ||||
|     addListener(listener: CoreItemsanagerListener<Item>): () => void { | ||||
|         this.listeners.push(listener); | ||||
| 
 | ||||
|         if (route === null) { | ||||
|         return () => this.removeListener(listener); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Remove a listener. | ||||
|      * | ||||
|      * @param listener Listener. | ||||
|      */ | ||||
|     removeListener(listener: CoreItemsanagerListener<Item>): void { | ||||
|         const index = this.listeners.indexOf(listener); | ||||
| 
 | ||||
|         if (index === -1) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         // If this item is already selected, do nothing.
 | ||||
|         const itemPath = this.getSource().getItemPath(item); | ||||
|         const selectedItemPath = this.getSelectedItemPath(route.snapshot); | ||||
| 
 | ||||
|         if (selectedItemPath === itemPath) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         // Navigate to item.
 | ||||
|         const params = this.getSource().getItemQueryParams(item); | ||||
|         const pathPrefix = selectedItemPath ? selectedItemPath.split('/').fill('../').join('') : ''; | ||||
| 
 | ||||
|         await CoreNavigator.navigate(pathPrefix + itemPath, { params, ...options }); | ||||
|         this.listeners.splice(index, 1); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -168,12 +142,10 @@ export abstract class CoreItemsManager<Item = unknown, Source extends CoreItemsM | ||||
|      */ | ||||
|     protected onSourceItemsUpdated(items: Item[]): void { | ||||
|         this.itemsMap = items.reduce((map, item) => { | ||||
|             map[this.getSource().getItemPath(item)] = item; | ||||
|             map[this.getItemId(item)] = item; | ||||
| 
 | ||||
|             return map; | ||||
|         }, {}); | ||||
| 
 | ||||
|         this.updateSelectedItem(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -184,4 +156,22 @@ export abstract class CoreItemsManager<Item = unknown, Source extends CoreItemsM | ||||
|         this.selectedItem = null; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get item by ID. | ||||
|      * | ||||
|      * @param id ID | ||||
|      * @return Item, null if not found. | ||||
|      */ | ||||
|     getItemById(id: string | number): Item | null { | ||||
|         return this.itemsMap?.[id] ?? null; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get an ID to identify an item. | ||||
|      * | ||||
|      * @param item Data about the item. | ||||
|      * @return Item ID. | ||||
|      */ | ||||
|     abstract getItemId(item: Item): string | number; | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -20,16 +20,16 @@ import { CoreNavigator } from '@services/navigator'; | ||||
| import { CoreScreen } from '@services/screen'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| 
 | ||||
| import { CoreItemsManager } from './items-manager'; | ||||
| import { CoreItemsManagerSource } from './items-manager-source'; | ||||
| import { CoreRoutedItemsManagerSource } from './routed-items-manager-source'; | ||||
| import { CoreRoutedItemsManager } from './routed-items-manager'; | ||||
| 
 | ||||
| /** | ||||
|  * Helper class to manage the state and routing of a list of items in a page. | ||||
|  */ | ||||
| export class CoreListItemsManager< | ||||
|     Item = unknown, | ||||
|     Source extends CoreItemsManagerSource<Item> = CoreItemsManagerSource<Item> | ||||
| > extends CoreItemsManager<Item, Source> { | ||||
|     Source extends CoreRoutedItemsManagerSource<Item> = CoreRoutedItemsManagerSource<Item> | ||||
| > extends CoreRoutedItemsManager<Item, Source> { | ||||
| 
 | ||||
|     protected pageRouteLocator?: unknown | ActivatedRoute; | ||||
|     protected splitView?: CoreSplitViewComponent; | ||||
|  | ||||
							
								
								
									
										150
									
								
								src/core/classes/items-management/routed-items-manager-source.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								src/core/classes/items-management/routed-items-manager-source.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,150 @@ | ||||
| // (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 { Params } from '@angular/router'; | ||||
| import { CoreItemsManagerSource } from './items-manager-source'; | ||||
| 
 | ||||
| /** | ||||
|  * Routed items collection source data. | ||||
|  */ | ||||
| export abstract class CoreRoutedItemsManagerSource<Item = unknown> extends CoreItemsManagerSource<Item> { | ||||
| 
 | ||||
|     protected hasMoreItems = true; | ||||
| 
 | ||||
|     /** | ||||
|      * Get a string to identify instances constructed with the given arguments as being reusable. | ||||
|      * | ||||
|      * @param args Constructor arguments. | ||||
|      * @returns Id. | ||||
|      */ | ||||
|     static getSourceId(...args: unknown[]): string { | ||||
|         return args.map(argument => String(argument)).join('-'); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Check whether there are more pages to be loaded. | ||||
|      * | ||||
|      * @return Whether there are more pages to be loaded. | ||||
|      */ | ||||
|     isCompleted(): boolean { | ||||
|         return !this.hasMoreItems; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the count of pages that have been loaded. | ||||
|      * | ||||
|      * @returns Pages loaded. | ||||
|      */ | ||||
|     getPagesLoaded(): number { | ||||
|         if (this.items === null) { | ||||
|             return 0; | ||||
|         } | ||||
| 
 | ||||
|         const pageLength = this.getPageLength(); | ||||
|         if (pageLength === null) { | ||||
|             return 1; | ||||
|         } | ||||
| 
 | ||||
|         return Math.ceil(this.items.length / pageLength); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Reset collection data. | ||||
|      */ | ||||
|     reset(): void { | ||||
|         this.hasMoreItems = true; | ||||
| 
 | ||||
|         super.reset(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Reload the collection, this resets the data to the first page. | ||||
|      */ | ||||
|     async reload(): Promise<void> { | ||||
|         this.dirty = true; | ||||
| 
 | ||||
|         await this.load(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load more items, if any. | ||||
|      */ | ||||
|     async load(): Promise<void> { | ||||
|         if (this.dirty) { | ||||
|             const { items, hasMoreItems } = await this.loadPageItems(0); | ||||
| 
 | ||||
|             this.dirty = false; | ||||
|             this.setItems(items, hasMoreItems ?? false); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (!this.hasMoreItems) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const { items, hasMoreItems } = await this.loadPageItems(this.getPagesLoaded()); | ||||
| 
 | ||||
|         this.setItems((this.items ?? []).concat(items), hasMoreItems ?? false); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load page items. | ||||
|      * | ||||
|      * @param page Page number (starting at 0). | ||||
|      * @return Page items data. | ||||
|      */ | ||||
|     protected abstract loadPageItems(page: number): Promise<{ items: Item[]; hasMoreItems?: boolean }>; | ||||
| 
 | ||||
|     /** | ||||
|      * Get the length of each page in the collection. | ||||
|      * | ||||
|      * @return Page length; null for collections that don't support pagination. | ||||
|      */ | ||||
|     protected getPageLength(): number | null { | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Update the collection items. | ||||
|      * | ||||
|      * @param items Items. | ||||
|      * @param hasMoreItems Whether there are more pages to be loaded. | ||||
|      */ | ||||
|     protected setItems(items: Item[], hasMoreItems = false): void { | ||||
|         this.hasMoreItems = hasMoreItems; | ||||
| 
 | ||||
|         super.setItems(items); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the query parameters to use when navigating to an item page. | ||||
|      * | ||||
|      * @param item Item. | ||||
|      * @return Query parameters to use when navigating to the item page. | ||||
|      */ | ||||
|     // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | ||||
|     getItemQueryParams(item: Item): Params { | ||||
|         return {}; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the path to use when navigating to an item page. | ||||
|      * | ||||
|      * @param item Item. | ||||
|      * @return Path to use when navigating to the item page. | ||||
|      */ | ||||
|     abstract getItemPath(item: Item): string; | ||||
| 
 | ||||
| } | ||||
| @ -12,23 +12,23 @@ | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { CoreItemsManagerSource } from './items-manager-source'; | ||||
| import { CoreRoutedItemsManagerSource } from './routed-items-manager-source'; | ||||
| 
 | ||||
| type SourceConstructor<T extends CoreItemsManagerSource = CoreItemsManagerSource> = { | ||||
| type SourceConstructor<T extends CoreRoutedItemsManagerSource = CoreRoutedItemsManagerSource> = { | ||||
|     getSourceId(...args: unknown[]): string; | ||||
|     new (...args: unknown[]): T; | ||||
| }; | ||||
| type SourceConstuctorInstance<T> = T extends { new(...args: unknown[]): infer P } ? P : never; | ||||
| type InstanceTracking = { instance: CoreItemsManagerSource; references: unknown[] }; | ||||
| type InstanceTracking = { instance: CoreRoutedItemsManagerSource; references: unknown[] }; | ||||
| type Instances = Record<string, InstanceTracking>; | ||||
| 
 | ||||
| /** | ||||
|  * Tracks CoreItemsManagerSource instances to reuse between pages. | ||||
|  * Tracks CoreRoutedItemsManagerSource instances to reuse between pages. | ||||
|  */ | ||||
| export class CoreItemsManagerSourcesTracker { | ||||
| export class CoreRoutedItemsManagerSourcesTracker { | ||||
| 
 | ||||
|     private static instances: WeakMap<SourceConstructor, Instances> = new WeakMap(); | ||||
|     private static instanceIds: WeakMap<CoreItemsManagerSource, string> = new WeakMap(); | ||||
|     private static instanceIds: WeakMap<CoreRoutedItemsManagerSource, string> = new WeakMap(); | ||||
| 
 | ||||
|     /** | ||||
|      * Create an instance of the given source or retrieve one if it's already in use. | ||||
| @ -37,7 +37,7 @@ export class CoreItemsManagerSourcesTracker { | ||||
|      * @param constructorArguments Arguments to create a new instance, used to find out if an instance already exists. | ||||
|      * @returns Source. | ||||
|      */ | ||||
|     static getOrCreateSource<T extends CoreItemsManagerSource, C extends SourceConstructor<T>>( | ||||
|     static getOrCreateSource<T extends CoreRoutedItemsManagerSource, C extends SourceConstructor<T>>( | ||||
|         constructor: C, | ||||
|         constructorArguments: ConstructorParameters<C>, | ||||
|     ): SourceConstuctorInstance<C> { | ||||
| @ -54,7 +54,7 @@ export class CoreItemsManagerSourcesTracker { | ||||
|      * @param source Source. | ||||
|      * @param reference Object referncing this source. | ||||
|      */ | ||||
|     static addReference(source: CoreItemsManagerSource, reference: unknown): void { | ||||
|     static addReference(source: CoreRoutedItemsManagerSource, reference: unknown): void { | ||||
|         const constructorInstances = this.getConstructorInstances(source.constructor as SourceConstructor); | ||||
|         const instanceId = this.instanceIds.get(source); | ||||
| 
 | ||||
| @ -78,7 +78,7 @@ export class CoreItemsManagerSourcesTracker { | ||||
|      * @param source Source. | ||||
|      * @param reference Object that was referncing this source. | ||||
|      */ | ||||
|     static removeReference(source: CoreItemsManagerSource, reference: unknown): void { | ||||
|     static removeReference(source: CoreRoutedItemsManagerSource, reference: unknown): void { | ||||
|         const constructorInstances = this.instances.get(source.constructor as SourceConstructor); | ||||
|         const instanceId = this.instanceIds.get(source); | ||||
|         const index = constructorInstances?.[instanceId ?? '']?.references.indexOf(reference) ?? -1; | ||||
| @ -127,7 +127,7 @@ export class CoreItemsManagerSourcesTracker { | ||||
|      * @param constructorArguments Source constructor arguments. | ||||
|      * @returns Source instance. | ||||
|      */ | ||||
|     private static createInstance<T extends CoreItemsManagerSource>( | ||||
|     private static createInstance<T extends CoreRoutedItemsManagerSource>( | ||||
|         id: string, | ||||
|         constructor: SourceConstructor<T>, | ||||
|         constructorArguments: ConstructorParameters<SourceConstructor<T>>, | ||||
							
								
								
									
										138
									
								
								src/core/classes/items-management/routed-items-manager.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								src/core/classes/items-management/routed-items-manager.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,138 @@ | ||||
| // (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 { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; | ||||
| import { CoreNavigationOptions, CoreNavigator } from '@services/navigator'; | ||||
| import { CoreItemsManager } from './items-manager'; | ||||
| 
 | ||||
| import { CoreRoutedItemsManagerSource } from './routed-items-manager-source'; | ||||
| import { CoreRoutedItemsManagerSourcesTracker } from './routed-items-manager-sources-tracker'; | ||||
| 
 | ||||
| /** | ||||
|  * Helper to manage a collection of items in a page. | ||||
|  */ | ||||
| export abstract class CoreRoutedItemsManager< | ||||
|     Item = unknown, | ||||
|     Source extends CoreRoutedItemsManagerSource<Item> = CoreRoutedItemsManagerSource<Item>, | ||||
| > extends CoreItemsManager<Item, Source> { | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     setSource(newSource: Source | null): void { | ||||
|         if (this.source) { | ||||
|             CoreRoutedItemsManagerSourcesTracker.removeReference(this.source.instance, this); | ||||
|         } | ||||
| 
 | ||||
|         if (newSource) { | ||||
|             CoreRoutedItemsManagerSourcesTracker.addReference(newSource, this); | ||||
|         } | ||||
| 
 | ||||
|         super.setSource(newSource); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get page route. | ||||
|      * | ||||
|      * @returns Current page route, if any. | ||||
|      */ | ||||
|     protected abstract getCurrentPageRoute(): ActivatedRoute | null; | ||||
| 
 | ||||
|     /** | ||||
|      * Get the path of the selected item given the current route. | ||||
|      * | ||||
|      * @param route Page route. | ||||
|      * @return Path of the selected item in the given route. | ||||
|      */ | ||||
|     protected abstract getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null; | ||||
| 
 | ||||
|     /** | ||||
|      * Get the path of the selected item. | ||||
|      * | ||||
|      * @param route Page route, if any. | ||||
|      * @return Path of the selected item. | ||||
|      */ | ||||
|     protected getSelectedItemPath(route?: ActivatedRouteSnapshot | null): string | null { | ||||
|         if (!route) { | ||||
|             return null; | ||||
|         } | ||||
| 
 | ||||
|         return this.getSelectedItemPathFromRoute(route); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Update the selected item given the current route. | ||||
|      * | ||||
|      * @param route Current route. | ||||
|      */ | ||||
|     protected updateSelectedItem(route: ActivatedRouteSnapshot | null = null): void { | ||||
|         route = route ?? this.getCurrentPageRoute()?.snapshot ?? null; | ||||
| 
 | ||||
|         const selectedItemPath = this.getSelectedItemPath(route); | ||||
| 
 | ||||
|         const selectedItem = selectedItemPath | ||||
|             ? this.itemsMap?.[selectedItemPath] ?? null | ||||
|             : null; | ||||
|         this.setSelectedItem(selectedItem); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Navigate to an item in the collection. | ||||
|      * | ||||
|      * @param item Item. | ||||
|      * @param options Navigation options. | ||||
|      */ | ||||
|     protected async navigateToItem( | ||||
|         item: Item, | ||||
|         options: Pick<CoreNavigationOptions, 'reset' | 'replace' | 'animationDirection'> = {}, | ||||
|     ): Promise<void> { | ||||
|         // Get current route in the page.
 | ||||
|         const route = this.getCurrentPageRoute(); | ||||
| 
 | ||||
|         if (route === null) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         // If this item is already selected, do nothing.
 | ||||
|         const itemPath = this.getSource().getItemPath(item); | ||||
|         const selectedItemPath = this.getSelectedItemPath(route.snapshot); | ||||
| 
 | ||||
|         if (selectedItemPath === itemPath) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         // Navigate to item.
 | ||||
|         const params = this.getSource().getItemQueryParams(item); | ||||
|         const pathPrefix = selectedItemPath ? selectedItemPath.split('/').fill('../').join('') : ''; | ||||
| 
 | ||||
|         await CoreNavigator.navigate(pathPrefix + itemPath, { params, ...options }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected onSourceItemsUpdated(items: Item[]): void { | ||||
|         super.onSourceItemsUpdated(items); | ||||
| 
 | ||||
|         this.updateSelectedItem(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getItemId(item: Item): string | number { | ||||
|         return this.getSource().getItemPath(item); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -16,17 +16,17 @@ import { ActivatedRoute, ActivatedRouteSnapshot, UrlSegment } from '@angular/rou | ||||
| 
 | ||||
| import { CoreNavigator } from '@services/navigator'; | ||||
| 
 | ||||
| import { CoreItemsManager } from './items-manager'; | ||||
| import { CoreItemsManagerSource } from './items-manager-source'; | ||||
| import { CoreRoutedItemsManager } from './routed-items-manager'; | ||||
| import { CoreRoutedItemsManagerSource } from './routed-items-manager-source'; | ||||
| 
 | ||||
| /** | ||||
|  * Helper class to manage the state and routing of a swipeable page. | ||||
|  */ | ||||
| export class CoreSwipeItemsManager< | ||||
| export class CoreSwipeNavigationItemsManager< | ||||
|     Item = unknown, | ||||
|     Source extends CoreItemsManagerSource<Item> = CoreItemsManagerSource<Item> | ||||
|     Source extends CoreRoutedItemsManagerSource<Item> = CoreRoutedItemsManagerSource<Item> | ||||
| > | ||||
|     extends CoreItemsManager<Item, Source> { | ||||
|     extends CoreRoutedItemsManager<Item, Source> { | ||||
| 
 | ||||
|     /** | ||||
|      * Process page started operations. | ||||
| @ -99,7 +99,7 @@ export class CoreSwipeItemsManager< | ||||
|     protected async getItemBy(delta: number): Promise<Item | null> { | ||||
|         const items = this.getSource().getItems(); | ||||
| 
 | ||||
|         // Get current item.
 | ||||
|         // Get selected item.
 | ||||
|         const index = (this.selectedItem && items?.indexOf(this.selectedItem)) ?? -1; | ||||
| 
 | ||||
|         if (index === -1) { | ||||
| @ -0,0 +1,217 @@ | ||||
| // (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 { CoreSwipeSlidesItemsManagerSource } from './swipe-slides-items-manager-source'; | ||||
| 
 | ||||
| /** | ||||
|  * Items collection source data for "swipe slides". | ||||
|  */ | ||||
| export abstract class CoreSwipeSlidesDynamicItemsManagerSource<Item extends CoreSwipeSlidesDynamicItem> | ||||
|     extends CoreSwipeSlidesItemsManagerSource<Item> { | ||||
| 
 | ||||
|     // Items being loaded, to prevent loading them twice.
 | ||||
|     protected loadingItems: Record<string, boolean> = {}; | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     async load(selectedItem?: Item | null): Promise<void> { | ||||
|         if (!this.loaded && this.initialItem) { | ||||
|             // Load the initial item.
 | ||||
|             await this.loadItem(this.initialItem); | ||||
|         } else if (this.loaded && selectedItem) { | ||||
|             // Reload selected item if needed.
 | ||||
|             await this.loadItem(selectedItem); | ||||
|         } | ||||
| 
 | ||||
|         this.setLoaded(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected async loadItems(): Promise<Item[]> { | ||||
|         // Not used in dynamic slides.
 | ||||
|         return []; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load a certain item and preload next and previous ones. | ||||
|      * | ||||
|      * @param item Item to load. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async loadItem(item: Item): Promise<void> { | ||||
|         const previousItem = this.getPreviousItem(item); | ||||
|         const nextItem = this.getNextItem(item); | ||||
| 
 | ||||
|         await Promise.all([ | ||||
|             this.loadItemInList(item, false), | ||||
|             previousItem && this.loadItemInList(previousItem, true), | ||||
|             nextItem && this.loadItemInList(nextItem, true), | ||||
|         ]); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load or preload a certain item and add it to the list. | ||||
|      * | ||||
|      * @param item Item to load. | ||||
|      * @param preload Whether to preload. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async loadItemInList(item: Item, preload = false): Promise<void> { | ||||
|         const preloadedItem = await this.performLoadItemData(item, preload); | ||||
|         if (!preloadedItem) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         // Add the item at the right position.
 | ||||
|         const existingItem = this.getItem(this.getItemId(item)); | ||||
|         if (existingItem) { | ||||
|             // Already in list, no need to add it.
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (!this.items) { | ||||
|             this.items = []; | ||||
|         } | ||||
| 
 | ||||
|         const previousItem = this.getPreviousItem(item); | ||||
|         const nextItem = this.getNextItem(item); | ||||
|         const previousItemId = previousItem ? this.getItemId(previousItem) : null; | ||||
|         const nextItemId = nextItem ? this.getItemId(nextItem) : null; | ||||
| 
 | ||||
|         const added = this.items.some((item, index) => { | ||||
|             const itemId = this.getItemId(item); | ||||
|             let indexToInsert = -1; | ||||
|             if (itemId === previousItemId) { | ||||
|                 // Previous item found, add the item after it.
 | ||||
|                 indexToInsert = index + 1; | ||||
|             } | ||||
| 
 | ||||
|             if (itemId === nextItemId) { | ||||
|                 // Next item found, add the item before it.
 | ||||
|                 indexToInsert = index; | ||||
|             } | ||||
| 
 | ||||
|             if (indexToInsert > -1) { | ||||
|                 this.items?.splice(indexToInsert, 0, preloadedItem); | ||||
| 
 | ||||
|                 return true; | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         if (!added) { | ||||
|             // Previous and next items not found, this probably means the array is still empty. Add it at the end.
 | ||||
|             this.items.push(preloadedItem); | ||||
|         } | ||||
| 
 | ||||
|         this.notifyItemsUpdated(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load or preload a certain item data. | ||||
|      * This helper function will check some common cases so they don't have to be replicated in all loadItemData implementations. | ||||
|      * | ||||
|      * @param item Item to load. | ||||
|      * @param preload Whether to preload. | ||||
|      * @return Promise resolved with item. Resolve with null if already loading or item is not valid (e.g. there are no more items). | ||||
|      */ | ||||
|     protected async performLoadItemData(item: Item, preload: boolean): Promise<Item | null> { | ||||
|         const itemId = this.getItemId(item); | ||||
| 
 | ||||
|         if (this.loadingItems[itemId]) { | ||||
|             // Already loading, ignore it.
 | ||||
|             return null; | ||||
|         } | ||||
| 
 | ||||
|         const existingItem = this.getItem(itemId); | ||||
|         if (existingItem && ((existingItem.loaded && !existingItem.dirty) || preload)) { | ||||
|             // Already loaded, or preloading an already preloaded item.
 | ||||
|             return existingItem; | ||||
|         } | ||||
| 
 | ||||
|         // Load the item.
 | ||||
|         this.loadingItems[itemId] = true; | ||||
| 
 | ||||
|         try { | ||||
|             const itemData = await this.loadItemData(item, preload); | ||||
| 
 | ||||
|             if (itemData && !preload) { | ||||
|                 itemData.loaded = true; | ||||
|                 itemData.dirty = false; | ||||
|             } | ||||
| 
 | ||||
|             if (existingItem && itemData) { | ||||
|                 // Update item that is already in list.
 | ||||
|                 Object.assign(existingItem, itemData); | ||||
| 
 | ||||
|                 return existingItem; | ||||
|             } | ||||
| 
 | ||||
|             return itemData; | ||||
|         } finally { | ||||
|             this.loadingItems[itemId] = false; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Mark all items as dirty. | ||||
|      */ | ||||
|     markAllItemsDirty(): void { | ||||
|         this.getItems()?.forEach(item => { | ||||
|             item.dirty = true; | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Mark all items as not loaded. | ||||
|      */ | ||||
|     markAllItemsUnloaded(): void { | ||||
|         this.getItems()?.forEach(item => { | ||||
|             item.loaded = false; | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load or preload a certain item data. | ||||
|      * | ||||
|      * @param item Basic data about the item to load. | ||||
|      * @param preload Whether to preload. | ||||
|      * @return Promise resolved with item. Resolve with null if item is not valid (e.g. there are no more items). | ||||
|      */ | ||||
|     abstract loadItemData(item: Item, preload: boolean): Promise<Item | null>; | ||||
| 
 | ||||
|     /** | ||||
|      * Return the data to identify the previous item. | ||||
|      * | ||||
|      * @param item Data about the item. | ||||
|      * @return Previous item data. Null if no previous item. | ||||
|      */ | ||||
|     abstract getPreviousItem(item: Item): Item | null; | ||||
| 
 | ||||
|     /** | ||||
|      * Return the data to identify the next item. | ||||
|      * | ||||
|      * @param item Data about the item. | ||||
|      * @return Next item data. Null if no next item. | ||||
|      */ | ||||
|     abstract getNextItem(item: Item): Item | null; | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export type CoreSwipeSlidesDynamicItem = { | ||||
|     loaded?: boolean; // Whether the item has been loaded. This value can affect UI (e.g. to display a spinner).
 | ||||
|     dirty?: boolean; // Whether the item data needs to be reloaded. This value usually shouldn't affect UI.
 | ||||
| }; | ||||
| @ -0,0 +1,38 @@ | ||||
| // (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 { CoreSwipeSlidesDynamicItem, CoreSwipeSlidesDynamicItemsManagerSource } from './swipe-slides-dynamic-items-manager-source'; | ||||
| import { CoreSwipeSlidesItemsManager } from './swipe-slides-items-manager'; | ||||
| 
 | ||||
| /** | ||||
|  * Helper class to manage items for core-swipe-slides. | ||||
|  */ | ||||
| export class CoreSwipeSlidesDynamicItemsManager< | ||||
|     Item extends CoreSwipeSlidesDynamicItem, | ||||
|     Source extends CoreSwipeSlidesDynamicItemsManagerSource<Item> = CoreSwipeSlidesDynamicItemsManagerSource<Item>, | ||||
| > extends CoreSwipeSlidesItemsManager<Item, Source> { | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     setSelectedItem(item: Item | null): void { | ||||
|         super.setSelectedItem(item); | ||||
| 
 | ||||
|         if (item) { | ||||
|             // Load the item if not loaded yet.
 | ||||
|             this.getSource().loadItem(item); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -0,0 +1,101 @@ | ||||
| // (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 { CoreItemsManagerSource } from './items-manager-source'; | ||||
| 
 | ||||
| /** | ||||
|  * Items collection source data for "swipe slides". | ||||
|  */ | ||||
| export abstract class CoreSwipeSlidesItemsManagerSource<Item = unknown> extends CoreItemsManagerSource<Item> { | ||||
| 
 | ||||
|     protected initialItem?: Item; | ||||
| 
 | ||||
|     constructor(initialItem?: Item) { | ||||
|         super(); | ||||
| 
 | ||||
|         this.initialItem = initialItem; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     async load(): Promise<void> { | ||||
|         const items = await this.loadItems(); | ||||
| 
 | ||||
|         this.setItems(items); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load items. | ||||
|      * | ||||
|      * @return Items list. | ||||
|      */ | ||||
|     protected abstract loadItems(): Promise<Item[]>; | ||||
| 
 | ||||
|     /** | ||||
|      * Get a certain item. | ||||
|      * | ||||
|      * @param id Item ID. | ||||
|      * @return Item, null if not found. | ||||
|      */ | ||||
|     getItem(id: string | number): Item | null { | ||||
|         const index = this.getItemIndexById(id); | ||||
| 
 | ||||
|         return this.items?.[index] ?? null; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get a certain item index. | ||||
|      * | ||||
|      * @param item Item. | ||||
|      * @return Item index, -1 if not found. | ||||
|      */ | ||||
|     getItemIndex(item: Item): number { | ||||
|         return this.getItemIndexById(this.getItemId(item)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get a certain item index. | ||||
|      * | ||||
|      * @param id Item ID. | ||||
|      * @return Item index, -1 if not found. | ||||
|      */ | ||||
|     getItemIndexById(id: string | number): number { | ||||
|         const index = this.items?.findIndex((listItem) => id === this.getItemId(listItem)); | ||||
| 
 | ||||
|         return index ?? -1; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get initial item index. | ||||
|      * | ||||
|      * @return Initial item index. | ||||
|      */ | ||||
|     getInitialItemIndex(): number { | ||||
|         if (!this.initialItem) { | ||||
|             return 0; | ||||
|         } | ||||
| 
 | ||||
|         return this.getItemIndex(this.initialItem); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the ID of an item. | ||||
|      * | ||||
|      * @param item Item. | ||||
|      * @return Item ID. | ||||
|      */ | ||||
|     abstract getItemId(item: Item): string | number; | ||||
| 
 | ||||
| } | ||||
| @ -0,0 +1,33 @@ | ||||
| // (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 { CoreItemsManager } from './items-manager'; | ||||
| import { CoreSwipeSlidesItemsManagerSource } from './swipe-slides-items-manager-source'; | ||||
| 
 | ||||
| /** | ||||
|  * Helper class to manage items for core-swipe-slides. | ||||
|  */ | ||||
| export class CoreSwipeSlidesItemsManager< | ||||
|     Item = unknown, | ||||
|     Source extends CoreSwipeSlidesItemsManagerSource<Item> = CoreSwipeSlidesItemsManagerSource<Item>, | ||||
| > extends CoreItemsManager<Item, Source> { | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getItemId(item: Item): string | number { | ||||
|         return this.getSource().getItemId(item); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -60,6 +60,7 @@ import { CoreComboboxComponent } from './combobox/combobox'; | ||||
| import { CoreSpacerComponent } from './spacer/spacer'; | ||||
| import { CoreHorizontalScrollControlsComponent } from './horizontal-scroll-controls/horizontal-scroll-controls'; | ||||
| import { CoreButtonWithSpinnerComponent } from './button-with-spinner/button-with-spinner'; | ||||
| import { CoreSwipeSlidesComponent } from './swipe-slides/swipe-slides'; | ||||
| 
 | ||||
| @NgModule({ | ||||
|     declarations: [ | ||||
| @ -94,6 +95,7 @@ import { CoreButtonWithSpinnerComponent } from './button-with-spinner/button-wit | ||||
|         CoreSplitViewComponent, | ||||
|         CoreStyleComponent, | ||||
|         CoreSwipeNavigationComponent, | ||||
|         CoreSwipeSlidesComponent, | ||||
|         CoreTabComponent, | ||||
|         CoreTabsComponent, | ||||
|         CoreTabsOutletComponent, | ||||
| @ -143,6 +145,7 @@ import { CoreButtonWithSpinnerComponent } from './button-with-spinner/button-wit | ||||
|         CoreSplitViewComponent, | ||||
|         CoreStyleComponent, | ||||
|         CoreSwipeNavigationComponent, | ||||
|         CoreSwipeSlidesComponent, | ||||
|         CoreTabComponent, | ||||
|         CoreTabsComponent, | ||||
|         CoreTabsOutletComponent, | ||||
|  | ||||
| @ -13,7 +13,7 @@ | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component, Input } from '@angular/core'; | ||||
| import { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; | ||||
| import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager'; | ||||
| import { CoreScreen } from '@services/screen'; | ||||
| 
 | ||||
| @Component({ | ||||
| @ -23,7 +23,7 @@ import { CoreScreen } from '@services/screen'; | ||||
| }) | ||||
| export class CoreSwipeNavigationComponent { | ||||
| 
 | ||||
|     @Input() manager?: CoreSwipeItemsManager; | ||||
|     @Input() manager?: CoreSwipeNavigationItemsManager; | ||||
| 
 | ||||
|     get enabled(): boolean { | ||||
|         return CoreScreen.isMobile && !!this.manager; | ||||
|  | ||||
							
								
								
									
										5
									
								
								src/core/components/swipe-slides/swipe-slides.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/core/components/swipe-slides/swipe-slides.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | ||||
| <ion-slides *ngIf="loaded" (ionSlideWillChange)="slideWillChange()" (ionSlideDidChange)="slideDidChange()" [options]="options"> | ||||
|     <ion-slide *ngFor="let item of items"> | ||||
|         <ng-container *ngIf="template" [ngTemplateOutlet]="template" [ngTemplateOutletContext]="{item: item}"></ng-container> | ||||
|     </ion-slide> | ||||
| </ion-slides> | ||||
							
								
								
									
										15
									
								
								src/core/components/swipe-slides/swipe-slides.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/core/components/swipe-slides/swipe-slides.scss
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,15 @@ | ||||
| :host { | ||||
|     --swipe-slides-min-height: auto; | ||||
| 
 | ||||
|     ion-slides { | ||||
|         min-height: var(--swipe-slides-min-height); | ||||
|     } | ||||
| 
 | ||||
|     ion-slide { | ||||
|         display: block; | ||||
|         font-size: inherit; | ||||
|         justify-content: start; | ||||
|         align-items: start; | ||||
|         text-align: start; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										257
									
								
								src/core/components/swipe-slides/swipe-slides.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										257
									
								
								src/core/components/swipe-slides/swipe-slides.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,257 @@ | ||||
| // (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, ContentChild, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, Output, TemplateRef, ViewChild, | ||||
| } from '@angular/core'; | ||||
| import { CoreSwipeSlidesItemsManager } from '@classes/items-management/swipe-slides-items-manager'; | ||||
| import { IonContent, IonSlides } from '@ionic/angular'; | ||||
| import { CoreDomUtils, VerticalPoint } from '@services/utils/dom'; | ||||
| import { CoreMath } from '@singletons/math'; | ||||
| 
 | ||||
| /** | ||||
|  * Helper component to display swipable slides. | ||||
|  */ | ||||
| @Component({ | ||||
|     selector: 'core-swipe-slides', | ||||
|     templateUrl: 'swipe-slides.html', | ||||
|     styleUrls: ['swipe-slides.scss'], | ||||
| }) | ||||
| export class CoreSwipeSlidesComponent<Item = unknown> implements OnChanges, OnDestroy { | ||||
| 
 | ||||
|     @Input() manager?: CoreSwipeSlidesItemsManager<Item>; | ||||
|     @Input() options: CoreSwipeSlidesOptions = {}; | ||||
|     @Output() onWillChange = new EventEmitter<CoreSwipeCurrentItemData<Item>>(); | ||||
|     @Output() onDidChange = new EventEmitter<CoreSwipeCurrentItemData<Item>>(); | ||||
| 
 | ||||
|     @ViewChild(IonSlides) slides?: IonSlides; | ||||
|     @ContentChild(TemplateRef) template?: TemplateRef<unknown>; // Template defined by the content.
 | ||||
| 
 | ||||
|     protected hostElement: HTMLElement; | ||||
|     protected unsubscribe?: () => void; | ||||
| 
 | ||||
|     constructor( | ||||
|         elementRef: ElementRef<HTMLElement>, | ||||
|         protected content?: IonContent, | ||||
|     ) { | ||||
|         this.hostElement = elementRef.nativeElement; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     ngOnChanges(): void { | ||||
|         if (!this.unsubscribe && this.manager) { | ||||
|             this.initialize(this.manager); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     get items(): Item[] { | ||||
|         return this.manager?.getSource().getItems() || []; | ||||
|     } | ||||
| 
 | ||||
|     get loaded(): boolean { | ||||
|         return !!this.manager?.getSource().isLoaded(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Initialize some properties based on the manager. | ||||
|      */ | ||||
|     protected async initialize(manager: CoreSwipeSlidesItemsManager<Item>): Promise<void> { | ||||
|         this.unsubscribe = manager.getSource().addListener({ | ||||
|             onItemsUpdated: () => this.onItemsUpdated(), | ||||
|         }); | ||||
| 
 | ||||
|         // Don't call default callbacks on init, emit our own events instead.
 | ||||
|         // This is because default callbacks aren't triggered for index 0, and to prevent auto scroll on init.
 | ||||
|         this.options.runCallbacksOnInit = false; | ||||
| 
 | ||||
|         await manager.getSource().waitForLoaded(); | ||||
| 
 | ||||
|         if (this.options.initialSlide === undefined) { | ||||
|             // Calculate the initial slide.
 | ||||
|             const index = manager.getSource().getInitialItemIndex(); | ||||
|             this.options.initialSlide = Math.max(index, 0); | ||||
|         } | ||||
| 
 | ||||
|         // Emit change events with the initial item.
 | ||||
|         const items = manager.getSource().getItems(); | ||||
|         if (!items || !items.length) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         // Validate that the initial index is inside the valid range.
 | ||||
|         const initialIndex = CoreMath.clamp(this.options.initialSlide as number, 0, items.length - 1); | ||||
| 
 | ||||
|         const initialItemData = { | ||||
|             index: initialIndex, | ||||
|             item: items[initialIndex], | ||||
|         }; | ||||
| 
 | ||||
|         manager.setSelectedItem(items[initialIndex]); | ||||
|         this.onWillChange.emit(initialItemData); | ||||
|         this.onDidChange.emit(initialItemData); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Slide to a certain index. | ||||
|      * | ||||
|      * @param index Index. | ||||
|      * @param speed Animation speed. | ||||
|      * @param runCallbacks Whether to run callbacks. | ||||
|      */ | ||||
|     slideToIndex(index: number, speed?: number, runCallbacks?: boolean): void { | ||||
|         this.slides?.slideTo(index, speed, runCallbacks); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Slide to a certain item. | ||||
|      * | ||||
|      * @param item Item. | ||||
|      * @param speed Animation speed. | ||||
|      * @param runCallbacks Whether to run callbacks. | ||||
|      */ | ||||
|     slideToItem(item: Item, speed?: number, runCallbacks?: boolean): void { | ||||
|         const index = this.manager?.getSource().getItemIndex(item) ?? -1; | ||||
|         if (index != -1) { | ||||
|             this.slides?.slideTo(index, speed, runCallbacks); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Slide to next slide. | ||||
|      * | ||||
|      * @param speed Animation speed. | ||||
|      * @param runCallbacks Whether to run callbacks. | ||||
|      */ | ||||
|     slideNext(speed?: number, runCallbacks?: boolean): void { | ||||
|         this.slides?.slideNext(speed, runCallbacks); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Slide to previous slide. | ||||
|      * | ||||
|      * @param speed Animation speed. | ||||
|      * @param runCallbacks Whether to run callbacks. | ||||
|      */ | ||||
|     slidePrev(speed?: number, runCallbacks?: boolean): void { | ||||
|         this.slides?.slidePrev(speed, runCallbacks); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Called when items list has been updated. | ||||
|      * | ||||
|      * @param items New items. | ||||
|      */ | ||||
|     protected onItemsUpdated(): void { | ||||
|         const currentItem = this.manager?.getSelectedItem(); | ||||
| 
 | ||||
|         if (!currentItem || !this.manager) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         // Keep the same slide in case the list has changed.
 | ||||
|         const newIndex = this.manager.getSource().getItemIndex(currentItem) ?? -1; | ||||
|         if (newIndex != -1) { | ||||
|             this.slides?.slideTo(newIndex, 0, false); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Slide will change. | ||||
|      */ | ||||
|     async slideWillChange(): Promise<void> { | ||||
|         const currentItemData = await this.getCurrentSlideItemData(); | ||||
|         if (!currentItemData) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.manager?.setSelectedItem(currentItemData.item); | ||||
| 
 | ||||
|         this.onWillChange.emit(currentItemData); | ||||
| 
 | ||||
|         if (this.options.scrollOnChange !== 'top') { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         // Scroll top. This can be improved in the future to keep the scroll for each slide.
 | ||||
|         const scrollElement = await this.content?.getScrollElement(); | ||||
| 
 | ||||
|         if (!scrollElement || CoreDomUtils.isElementOutsideOfScreen(scrollElement, this.hostElement, VerticalPoint.TOP)) { | ||||
|             // Scroll to top.
 | ||||
|             this.hostElement.scrollIntoView({ behavior: 'smooth' }); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Slide did change. | ||||
|      */ | ||||
|     async slideDidChange(): Promise<void> { | ||||
|         const currentItemData = await this.getCurrentSlideItemData(); | ||||
|         if (!currentItemData) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.onDidChange.emit(currentItemData); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get current item and index based on current slide. | ||||
|      * | ||||
|      * @return Promise resolved with current item data. Null if not found. | ||||
|      */ | ||||
|     protected async getCurrentSlideItemData(): Promise<CoreSwipeCurrentItemData<Item> | null> { | ||||
|         if (!this.slides || !this.manager) { | ||||
|             return null; | ||||
|         } | ||||
| 
 | ||||
|         const index = await this.slides.getActiveIndex(); | ||||
|         const items = this.manager.getSource().getItems(); | ||||
|         const currentItem = items && items[index]; | ||||
| 
 | ||||
|         if (!currentItem) { | ||||
|             return null; | ||||
|         } | ||||
| 
 | ||||
|         return { | ||||
|             item: currentItem, | ||||
|             index, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     ngOnDestroy(): void { | ||||
|         this.unsubscribe && this.unsubscribe(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Options to pass to the component. | ||||
|  * | ||||
|  * @todo Change unknown with the right type once Swiper library is used. | ||||
|  */ | ||||
| export type CoreSwipeSlidesOptions = Record<string, unknown> & { | ||||
|     scrollOnChange?: 'top' | 'none'; // Scroll behaviour on change slide. By default, none.
 | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Data about current item. | ||||
|  */ | ||||
| export type CoreSwipeCurrentItemData<Item> = { | ||||
|     index: number; | ||||
|     item: Item; | ||||
| }; | ||||
| @ -13,14 +13,14 @@ | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Params } from '@angular/router'; | ||||
| import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; | ||||
| import { CoreRoutedItemsManagerSource } from '@classes/items-management/routed-items-manager-source'; | ||||
| 
 | ||||
| import { CoreUser, CoreUserData, CoreUserParticipant, CoreUserProvider } from '../services/user'; | ||||
| 
 | ||||
| /** | ||||
|  * Provides a collection of course participants. | ||||
|  */ | ||||
| export class CoreUserParticipantsSource extends CoreItemsManagerSource<CoreUserParticipant | CoreUserData> { | ||||
| export class CoreUserParticipantsSource extends CoreRoutedItemsManagerSource<CoreUserParticipant | CoreUserData> { | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|  | ||||
| @ -23,7 +23,7 @@ import { CoreSplitViewComponent } from '@components/split-view/split-view'; | ||||
| import { CoreUser, CoreUserParticipant, CoreUserData } from '@features/user/services/user'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { CoreUserParticipantsSource } from '@features/user/classes/participants-source'; | ||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||
| import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; | ||||
| 
 | ||||
| /** | ||||
|  * Page that displays the list of course participants. | ||||
| @ -48,7 +48,7 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro | ||||
|         try { | ||||
|             this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); | ||||
|             this.participants = new CoreUserParticipantsManager( | ||||
|                 CoreItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId]), | ||||
|                 CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId]), | ||||
|                 CoreUserParticipantsPage, | ||||
|             ); | ||||
|         } catch (error) { | ||||
| @ -107,7 +107,7 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const newSource = CoreItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId]); | ||||
|         const newSource = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId]); | ||||
| 
 | ||||
|         this.searchQuery = null; | ||||
|         this.searchInProgress = false; | ||||
| @ -124,7 +124,10 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro | ||||
|     async search(query: string): Promise<void> { | ||||
|         CoreApp.closeKeyboard(); | ||||
| 
 | ||||
|         const newSource = CoreItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId, query]); | ||||
|         const newSource = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( | ||||
|             CoreUserParticipantsSource, | ||||
|             [this.courseId, query], | ||||
|         ); | ||||
| 
 | ||||
|         this.searchInProgress = true; | ||||
|         this.searchQuery = query; | ||||
|  | ||||
| @ -27,9 +27,9 @@ import { CoreUserDelegate, CoreUserDelegateService, CoreUserProfileHandlerData } | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { CoreNavigator } from '@services/navigator'; | ||||
| import { CoreCourses } from '@features/courses/services/courses'; | ||||
| import { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; | ||||
| import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager'; | ||||
| import { CoreUserParticipantsSource } from '@features/user/classes/participants-source'; | ||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||
| import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; | ||||
| 
 | ||||
| @Component({ | ||||
|     selector: 'page-core-user-profile', | ||||
| @ -90,7 +90,10 @@ export class CoreUserProfilePage implements OnInit, OnDestroy { | ||||
| 
 | ||||
|         if (this.courseId && this.route.snapshot.data.swipeManagerSource === 'participants') { | ||||
|             const search = CoreNavigator.getRouteParam('search'); | ||||
|             const source = CoreItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId, search]); | ||||
|             const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( | ||||
|                 CoreUserParticipantsSource, | ||||
|                 [this.courseId, search], | ||||
|             ); | ||||
|             this.users = new CoreUserSwipeItemsManager(source); | ||||
| 
 | ||||
|             this.users.start(); | ||||
| @ -224,7 +227,7 @@ export class CoreUserProfilePage implements OnInit, OnDestroy { | ||||
| /** | ||||
|  * Helper to manage swiping within a collection of users. | ||||
|  */ | ||||
| class CoreUserSwipeItemsManager extends CoreSwipeItemsManager { | ||||
| class CoreUserSwipeItemsManager extends CoreSwipeNavigationItemsManager { | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|  | ||||
| @ -769,21 +769,39 @@ export class CoreDomUtilsProvider { | ||||
|      * | ||||
|      * @param scrollEl The element that must be scrolled. | ||||
|      * @param element DOM element to check. | ||||
|      * @param point The point of the element to check. | ||||
|      * @return Whether the element is outside of the viewport. | ||||
|      */ | ||||
|     isElementOutsideOfScreen(scrollEl: HTMLElement, element: HTMLElement): boolean { | ||||
|     isElementOutsideOfScreen( | ||||
|         scrollEl: HTMLElement, | ||||
|         element: HTMLElement, | ||||
|         point: VerticalPoint = VerticalPoint.MID, | ||||
|     ): boolean { | ||||
|         const elementRect = element.getBoundingClientRect(); | ||||
| 
 | ||||
|         if (!elementRect) { | ||||
|             return false; | ||||
|         } | ||||
| 
 | ||||
|         const elementMidPoint = Math.round((elementRect.bottom + elementRect.top) / 2); | ||||
|         let elementPoint: number; | ||||
|         switch (point) { | ||||
|             case VerticalPoint.TOP: | ||||
|                 elementPoint = elementRect.top; | ||||
|                 break; | ||||
| 
 | ||||
|             case VerticalPoint.BOTTOM: | ||||
|                 elementPoint = elementRect.bottom; | ||||
|                 break; | ||||
| 
 | ||||
|             case VerticalPoint.MID: | ||||
|                 elementPoint = Math.round((elementRect.bottom + elementRect.top) / 2); | ||||
|                 break; | ||||
|         } | ||||
| 
 | ||||
|         const scrollElRect = scrollEl.getBoundingClientRect(); | ||||
|         const scrollTopPos = scrollElRect?.top || 0; | ||||
| 
 | ||||
|         return elementMidPoint > window.innerHeight || elementMidPoint < scrollTopPos; | ||||
|         return elementPoint > window.innerHeight || elementPoint < scrollTopPos; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -2090,3 +2108,12 @@ export type PromptButton = Omit<AlertButton, 'handler'> & { | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     handler?: (value: any, resolve: (value: any) => void, reject: (reason: any) => void) => void; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Vertical points for an element. | ||||
|  */ | ||||
| export enum VerticalPoint { | ||||
|     TOP = 'top', | ||||
|     MID = 'mid', | ||||
|     BOTTOM = 'bottom', | ||||
| } | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user