diff --git a/src/addons/badges/classes/user-badges-source.ts b/src/addons/badges/classes/user-badges-source.ts index ea36a237f..e7aac1ed7 100644 --- a/src/addons/badges/classes/user-badges-source.ts +++ b/src/addons/badges/classes/user-badges-source.ts @@ -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 { +export class AddonBadgesUserBadgesSource extends CoreRoutedItemsManagerSource { readonly COURSE_ID: number; readonly USER_ID: number; diff --git a/src/addons/badges/pages/issued-badge/issued-badge.page.ts b/src/addons/badges/pages/issued-badge/issued-badge.page.ts index 907e04c89..4a9285995 100644 --- a/src/addons/badges/pages/issued-badge/issued-badge.page.ts +++ b/src/addons/badges/pages/issued-badge/issued-badge.page.ts @@ -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(); } diff --git a/src/addons/badges/pages/user-badges/user-badges.page.ts b/src/addons/badges/pages/user-badges/user-badges.page.ts index e614c3bac..31ba89d84 100644 --- a/src/addons/badges/pages/user-badges/user-badges.page.ts +++ b/src/addons/badges/pages/user-badges/user-badges.page.ts @@ -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, ); } diff --git a/src/addons/calendar/components/calendar/addon-calendar-calendar.html b/src/addons/calendar/components/calendar/addon-calendar-calendar.html index 3030a2b5a..ca1e7dc08 100644 --- a/src/addons/calendar/components/calendar/addon-calendar-calendar.html +++ b/src/addons/calendar/components/calendar/addon-calendar-calendar.html @@ -1,7 +1,7 @@ - @@ -17,7 +17,11 @@ -

{{ periodName }}

+

+ {{ periodName }} + + +

@@ -27,74 +31,81 @@ - - -
- - - - {{ day.fullname | translate }} - - - - -
-
+ + + + +
+ + + + {{ day.fullname | translate }} + + + + +
+
+ + + + + + +

+ + {{ day.periodName | translate }} +

- - - - - -

- - {{ day.periodName | translate }} -

+ +

- -

- - -
- -
- - - - - - {{ event.timestart * 1000 | coreFormatDate: timeFormat }} - - - - - - {{ 'addon.calendar.type' + event.formattedType | translate }} - {{ event.iconTitle }} - - {{event.name}} + +
+ +
+ + + + + {{ event.timestart * 1000 | coreFormatDate: timeFormat }} + + + + + + {{ 'addon.calendar.type' + event.formattedType | translate }} + + {{ event.iconTitle }} + + + {{event.name}} +
+
+

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

- -

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

-
- - - - -
- +
+ + + +
+
+
+
+
diff --git a/src/addons/calendar/components/calendar/calendar.scss b/src/addons/calendar/components/calendar/calendar.scss index acbcccf6b..24e0c2e99 100644 --- a/src/addons/calendar/components/calendar/calendar.scss +++ b/src/addons/calendar/components/calendar/calendar.scss @@ -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]) { diff --git a/src/addons/calendar/components/calendar/calendar.ts b/src/addons/calendar/components/calendar/calendar.ts index a4b2178bc..ed3e500c7 100644 --- a/src/addons/calendar/components/calendar/calendar.ts +++ b/src/addons/calendar/components/calendar/calendar.ts @@ -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; + @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; loaded = false; - timeFormat?: string; - isCurrentMonth = false; - isPastMonth = false; - protected year: number; - protected month: number; - protected categoriesRetrieved = false; - protected categories: { [id: number]: CoreCategoryData } = {}; protected currentSiteId: string; - protected offlineEvents: { [monthId: string]: { [day: number]: AddonCalendarEventToDisplay[] } } = - {}; // Offline events classified in month & day. - - protected offlineEditedEventsIds: number[] = []; // IDs of events edited in offline. - protected deletedEvents: number[] = []; // Events deleted in offline. - protected currentTime?: number; protected differ: KeyValueDiffer; // To detect changes in the data input. - // Observers. + // Observers and listeners. protected undeleteEventObserver: CoreEventObserver; protected obsDefaultTimeChange?: CoreEventObserver; + protected managerUnsubscribe?: () => void; constructor( differs: KeyValueDiffers, @@ -92,9 +89,15 @@ 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) => { - week.days.forEach((day) => { - AddonCalendar.scheduleEventsNotifications(day.eventsFormated || []); + 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 { - const promises: Promise[] = []; - - 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 { - // Don't pass courseId and categoryId, we'll filter them locally. - let result: { daynames: Partial[]; weeks: Partial[] }; - 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 { - 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 { - const promises: Promise[] = []; + async refreshData(afterChange = false): Promise { + 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 { - 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 { - 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 { - 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; - } - - this.loaded = true; - } - - /** - * Decrease the current month. - */ - protected decreaseMonth(): void { - if (this.month === 1) { - this.month = 12; - this.year--; - } else { - this.month--; + } finally { + this.loaded = true; } } /** - * Increase the current month. + * Check whether selected month is loaded. */ - protected increaseMonth(): void { - if (this.month === 12) { - this.month = 1; - 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 { + 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 { + + 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 { + 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 { + 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 { + // 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 { + this.deletedEvents = await AddonCalendarOffline.getAllDeletedEventsIds(); + } + + /** + * Load time format. + * + * @return Promise resolved when done. + */ + async loadTimeFormat(): Promise { + 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 { + // Load or preload the weeks. + let result: { daynames: Partial[]; weeks: Partial[] }; + 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 { + const promises: Promise[] = []; + + 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); } } diff --git a/src/addons/calendar/components/upcoming-events/upcoming-events.ts b/src/addons/calendar/components/upcoming-events/upcoming-events.ts index f59363845..a11c5f81b 100644 --- a/src/addons/calendar/components/upcoming-events/upcoming-events.ts +++ b/src/addons/calendar/components/upcoming-events/upcoming-events.ts @@ -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 { + async refreshData(): Promise { const promises: Promise[] = []; - // Don't invalidate upcoming events after a change, it has already been handled. - if (!afterChange) { - promises.push(AddonCalendar.invalidateAllUpcomingEvents()); - } + promises.push(AddonCalendar.invalidateAllUpcomingEvents()); promises.push(CoreCourses.invalidateCategories(0, true)); promises.push(AddonCalendar.invalidateLookAhead()); promises.push(AddonCalendar.invalidateTimeFormat()); diff --git a/src/addons/calendar/pages/day/day.html b/src/addons/calendar/pages/day/day.html index ef0fe2306..01a7daceb 100644 --- a/src/addons/calendar/pages/day/day.html +++ b/src/addons/calendar/pages/day/day.html @@ -11,10 +11,10 @@ - - @@ -27,70 +27,78 @@ - - - - - - - - - -

{{ periodName }}

-
- - - - - -
-
+ + + + + + + + + + +

{{ periodName }}

+
+ + + + + +
+
- - - - - - {{ 'core.hasdatatosync' | translate:{$a: 'core.day' | translate} }} - - + + + + + + + + {{ 'core.hasdatatosync' | translate:{$a: 'core.day' | translate} }} + + - - + + - - - - - - - - - - {{ 'addon.calendar.type' + event.formattedType | translate }} - {{ event.iconTitle }} - -

- -

-

-
- - - {{ 'core.notsent' | translate }} - - - - {{ 'core.deletedoffline' | translate }} - -
-
-
+ + + + + + + + + + {{ 'addon.calendar.type' + event.formattedType | translate }} + {{ event.iconTitle }} + +

+ +

+

+
+ + + {{ 'core.notsent' | translate }} + + + + {{ 'core.deletedoffline' | translate }} + +
+
+
+
+
+
diff --git a/src/addons/calendar/pages/day/day.page.ts b/src/addons/calendar/pages/day/day.page.ts index 19963cb62..984d5931b 100644 --- a/src/addons/calendar/pages/day/day.page.ts +++ b/src/addons/calendar/pages/day/day.page.ts @@ -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; - 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[] = []; + manager?: CoreSwipeSlidesDynamicItemsManager; 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 { - this.syncIcon = CoreConstants.ICON_LOADING; this.isOnline = CoreApp.isOnline(); @@ -268,53 +253,9 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { } try { - const promises: Promise[] = []; + 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 { - 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 { this.syncIcon = CoreConstants.ICON_LOADING; - const promises: Promise[] = []; + 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 { - 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 { - 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; } - - this.loaded = true; } /** * Load next day. */ async loadNext(): Promise { - 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 { - 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 { + + courses: Partial[] = []; + // Offline events classified in month & day. + offlineEvents: Record> = {}; + offlineEditedEventsIds: number[] = []; // IDs of events edited in offline. + categories?: { [id: number]: CoreCategoryData }; + deletedEvents?: Set; // 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 { + 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 { + 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 { + this.canCreate = await AddonCalendarHelper.canEditEvents(courseId); + } + + /** + * Load categories to be able to filter events. + * + * @return Promise resolved when done. + */ + async loadCategories(): Promise { + 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 { + // 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 { + const deletedEventsIds = await AddonCalendarOffline.getAllDeletedEventsIds(); + + this.deletedEvents = new Set(deletedEventsIds); + } + + /** + * Load time format. + * + * @return Promise resolved when done. + */ + async loadTimeFormat(): Promise { + 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 { + 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 { + const promises: Promise[] = []; + + 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); + } } } diff --git a/src/addons/calendar/pages/day/day.scss b/src/addons/calendar/pages/day/day.scss index a2b9031b0..d3fdad33c 100644 --- a/src/addons/calendar/pages/day/day.scss +++ b/src/addons/calendar/pages/day/day.scss @@ -6,4 +6,8 @@ font-size: 1.2rem; } } + + core-swipe-slides { + --swipe-slides-min-height: calc(100% - 52px); + } } diff --git a/src/addons/calendar/pages/index/index.page.ts b/src/addons/calendar/pages/index/index.page.ts index f2c8fb51b..ba5d5366d 100644 --- a/src/addons/calendar/pages/index/index.page.ts +++ b/src/addons/calendar/pages/index/index.page.ts @@ -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 { + async refreshData(sync = false, showErrors = false, afterChange = false): Promise { this.syncIcon = CoreConstants.ICON_LOADING; const promises: Promise[] = []; @@ -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()); } diff --git a/src/addons/calendar/services/calendar-helper.ts b/src/addons/calendar/services/calendar-helper.ts index d441b1c1d..776c2b38d 100644 --- a/src/addons/calendar/services/calendar-helper.ts +++ b/src/addons/calendar/services/calendar-helper.ts @@ -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; diff --git a/src/addons/calendar/services/calendar-sync.ts b/src/addons/calendar/services/calendar-sync.ts index 56f3e8a3c..12d26e5bb 100644 --- a/src/addons/calendar/services/calendar-sync.ts +++ b/src/addons/calendar/services/calendar-sync.ts @@ -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 = { diff --git a/src/addons/mod/assign/classes/submissions-source.ts b/src/addons/mod/assign/classes/submissions-source.ts index b0905d5fe..a1c36e70c 100644 --- a/src/addons/mod/assign/classes/submissions-source.ts +++ b/src/addons/mod/assign/classes/submissions-source.ts @@ -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 { +export class AddonModAssignSubmissionsSource extends CoreRoutedItemsManagerSource { /** * @inheritdoc diff --git a/src/addons/mod/assign/pages/submission-list/submission-list.page.ts b/src/addons/mod/assign/pages/submission-list/submission-list.page.ts index c8cb848f9..e0b09e3b2 100644 --- a/src/addons/mod/assign/pages/submission-list/submission-list.page.ts +++ b/src/addons/mod/assign/pages/submission-list/submission-list.page.ts @@ -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], ); diff --git a/src/addons/mod/assign/pages/submission-review/submission-review.ts b/src/addons/mod/assign/pages/submission-review/submission-review.ts index 015c73425..20342ef15 100644 --- a/src/addons/mod/assign/pages/submission-review/submission-review.ts +++ b/src/addons/mod/assign/pages/submission-review/submission-review.ts @@ -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 diff --git a/src/addons/mod/book/components/index/addon-mod-book-index.html b/src/addons/mod/book/components/index/addon-mod-book-index.html index ce5420250..247a4b5c7 100644 --- a/src/addons/mod/book/components/index/addon-mod-book-index.html +++ b/src/addons/mod/book/components/index/addon-mod-book-index.html @@ -40,15 +40,18 @@ previousTranslate="addon.mod_book.navprevtitle" nextTranslate="addon.mod_book.navnexttitle" (action)="changeChapter($event.id)"> -
- - -
- {{ 'core.tag.tags' | translate }}: - -
-
+ + +
+ +
+ {{ 'core.tag.tags' | translate }}: + +
+
+
+
diff --git a/src/addons/mod/book/components/index/index.ts b/src/addons/mod/book/components/index/index.ts index 5d3281c25..d7f78d112 100644 --- a/src/addons/mod/book/components/index/index.ts +++ b/src/addons/mod/book/components/index/index.ts @@ -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; warning = ''; - tags?: CoreTagItem[]; displayNavBar = true; navigationItems: CoreNavigationBarItem[] = []; 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 { 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 { // Create the toc modal. + const visibleChapter = this.manager?.getSelectedItem(); + const modalData = await CoreDomUtils.openSideModal({ 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 { - return AddonModBook.invalidateContent(this.module.id, this.courseId); + protected async invalidateContent(): Promise { + await this.manager?.getSource().invalidateContent(); } /** @@ -126,42 +158,29 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp */ protected async fetchContent(refresh = false): Promise { 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,63 +188,33 @@ 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 { - this.book = await AddonModBook.getBook(this.courseId, this.module.id); + protected async onChapterViewed(chapterId: number): Promise { + // Don't log the chapter ID when the user has just opened the book. + const logChapterId = this.firstLoad; + this.firstLoad = false; - this.dataRetrieved.emit(this.book); + if (this.displayNavBar) { + this.navigationItems = this.getNavigationItems(chapterId); + } - this.description = this.book.intro; - this.displayNavBar = this.book.navstyle != AddonModBookNavStyle.TOC_ONLY; - this.displayTitlesInNavBar = this.book.navstyle == AddonModBookNavStyle.TEXT; - } + // Chapter loaded, log view. + await CoreUtils.ignoreErrors(AddonModBook.logView( + this.module.instance!, + logChapterId ? chapterId : undefined, + this.module.name, + )); - /** - * 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 { - this.currentChapter = chapterId; - this.content?.scrollToTop(); + const currentChapterIndex = this.chapters.findIndex((chapter) => chapter.id == chapterId); + const isLastChapter = currentChapterIndex < 0 || this.chapters[currentChapterIndex + 1] === undefined; - try { - const content = await AddonModBook.getChapterContent(this.contentsMap, chapterId, this.module.id); - - this.tags = this.tagsEnabled ? this.contentsMap[this.currentChapter].tags : []; - - this.chapterContent = content; - - 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. - await CoreUtils.ignoreErrors(AddonModBook.logView( - this.module.instance!, - logChapterId ? chapterId : undefined, - this.module.name, - )); - - const currentChapterIndex = this.chapters.findIndex((chapter) => chapter.id == chapterId); - const isLastChapter = currentChapterIndex < 0 || this.chapters[currentChapterIndex + 1] === undefined; - - // Module is completed when last chapter is viewed, so we only check completion if the last is reached. - 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; + // Module is completed when last chapter is viewed, so we only check completion if the last is reached. + if (isLastChapter) { + CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); } } @@ -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 { + + 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 { + this.book = await AddonModBook.getBook(this.COURSE_ID, this.MODULE.id); + + return this.book; + } + + /** + * Load module contents. + */ + async loadContents(): Promise { + 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 { + 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 { + return AddonModBook.invalidateContent(this.MODULE.id, this.COURSE_ID); + } + } diff --git a/src/addons/mod/forum/classes/forum-discussions-source.ts b/src/addons/mod/forum/classes/forum-discussions-source.ts index 7a08b4e8d..26bd22967 100644 --- a/src/addons/mod/forum/classes/forum-discussions-source.ts +++ b/src/addons/mod/forum/classes/forum-discussions-source.ts @@ -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 { +export class AddonModForumDiscussionsSource extends CoreRoutedItemsManagerSource { static readonly NEW_DISCUSSION: AddonModForumNewDiscussionForm = { newDiscussion: true }; diff --git a/src/addons/mod/forum/classes/forum-discussions-swipe-manager.ts b/src/addons/mod/forum/classes/forum-discussions-swipe-manager.ts index d409a6f58..2b1ca1e99 100644 --- a/src/addons/mod/forum/classes/forum-discussions-swipe-manager.ts +++ b/src/addons/mod/forum/classes/forum-discussions-swipe-manager.ts @@ -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 { + extends CoreSwipeNavigationItemsManager { /** * @inheritdoc diff --git a/src/addons/mod/forum/components/index/index.ts b/src/addons/mod/forum/components/index/index.ts index e7a778d3b..696f762da 100644 --- a/src/addons/mod/forum/components/index/index.ts +++ b/src/addons/mod/forum/components/index/index.ts @@ -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}/` : ''], ); diff --git a/src/addons/mod/forum/pages/discussion/discussion.page.ts b/src/addons/mod/forum/pages/discussion/discussion.page.ts index 103b9355b..0b6428ae0 100644 --- a/src/addons/mod/forum/pages/discussion/discussion.page.ts +++ b/src/addons/mod/forum/pages/discussion/discussion.page.ts @@ -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 ?? ''], ), diff --git a/src/addons/mod/forum/pages/new-discussion/new-discussion.page.ts b/src/addons/mod/forum/pages/new-discussion/new-discussion.page.ts index 7e9efa79a..699eaf7ec 100644 --- a/src/addons/mod/forum/pages/new-discussion/new-discussion.page.ts +++ b/src/addons/mod/forum/pages/new-discussion/new-discussion.page.ts @@ -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 ?? ''], ); diff --git a/src/addons/mod/glossary/classes/glossary-entries-source.ts b/src/addons/mod/glossary/classes/glossary-entries-source.ts index 42fbe67c5..d1dd67b5c 100644 --- a/src/addons/mod/glossary/classes/glossary-entries-source.ts +++ b/src/addons/mod/glossary/classes/glossary-entries-source.ts @@ -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 { +export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource { static readonly NEW_ENTRY: AddonModGlossaryNewEntryForm = { newEntry: true }; diff --git a/src/addons/mod/glossary/classes/glossary-entries-swipe-manager.ts b/src/addons/mod/glossary/classes/glossary-entries-swipe-manager.ts index 45015a760..c6fc2bb57 100644 --- a/src/addons/mod/glossary/classes/glossary-entries-swipe-manager.ts +++ b/src/addons/mod/glossary/classes/glossary-entries-swipe-manager.ts @@ -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 { + extends CoreSwipeNavigationItemsManager { /** * @inheritdoc diff --git a/src/addons/mod/glossary/components/index/index.ts b/src/addons/mod/glossary/components/index/index.ts index 6f250813b..7599c7afc 100644 --- a/src/addons/mod/glossary/components/index/index.ts +++ b/src/addons/mod/glossary/components/index/index.ts @@ -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}/` : ''], ); diff --git a/src/addons/mod/glossary/pages/edit/edit.ts b/src/addons/mod/glossary/pages/edit/edit.ts index dfb06aa5d..2edc0d440 100644 --- a/src/addons/mod/glossary/pages/edit/edit.ts +++ b/src/addons/mod/glossary/pages/edit/edit.ts @@ -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 ?? ''], ); diff --git a/src/addons/mod/glossary/pages/entry/entry.ts b/src/addons/mod/glossary/pages/entry/entry.ts index d88b470c1..41dfb3b8c 100644 --- a/src/addons/mod/glossary/pages/entry/entry.ts +++ b/src/addons/mod/glossary/pages/entry/entry.ts @@ -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 ?? ''], ); diff --git a/src/core/classes/items-management/items-manager-source.ts b/src/core/classes/items-management/items-manager-source.ts index 29acf445f..4a2c7de2e 100644 --- a/src/core/classes/items-management/items-manager-source.ts +++ b/src/core/classes/items-management/items-manager-source.ts @@ -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 { - onItemsUpdated?(items: Item[], hasMoreItems: boolean): void; +export interface CoreItemsManagerSourceListener { + onItemsUpdated?(items: Item[]): void; onReset?(): void; } @@ -27,37 +25,41 @@ export interface CoreItemsListSourceListener { */ export abstract class CoreItemsManagerSource { - /** - * 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[] = []; + protected dirty = false; + protected loaded = false; + protected loadedPromise: Promise; + protected resolveLoaded!: () => void; + + constructor() { + this.loadedPromise = new Promise(resolve => this.resolveLoaded = resolve); } - protected items: Item[] | null = null; - protected hasMoreItems = true; - protected listeners: CoreItemsListSourceListener[] = []; - 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 { + return this.loadedPromise; + } + + /** + * Mark the source as initialized. + */ + protected setLoaded(): void { + this.loaded = true; + this.resolveLoaded(); } /** @@ -80,42 +82,28 @@ export abstract class CoreItemsManagerSource { 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; + /** * Register a listener. * * @param listener Listener. * @returns Unsubscribe function. */ - addListener(listener: CoreItemsListSourceListener): () => void { + addListener(listener: CoreItemsManagerSourceListener): () => void { this.listeners.push(listener); return () => this.removeListener(listener); @@ -126,7 +114,7 @@ export abstract class CoreItemsManagerSource { * * @param listener Listener. */ - removeListener(listener: CoreItemsListSourceListener): void { + removeListener(listener: CoreItemsManagerSourceListener): void { const index = this.listeners.indexOf(listener); if (index === -1) { @@ -136,85 +124,23 @@ export abstract class CoreItemsManagerSource { this.listeners.splice(index, 1); } - /** - * Reload the collection, this resets the data to the first page. - */ - async reload(): Promise { - const { items, hasMoreItems } = await this.loadPageItems(0); - - this.dirty = false; - this.setItems(items, hasMoreItems ?? false); - } - - /** - * Load more items, if any. - */ - async load(): Promise { - 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)); } } diff --git a/src/core/classes/items-management/items-manager.ts b/src/core/classes/items-management/items-manager.ts index f2be8bd67..2cc88a2ac 100644 --- a/src/core/classes/items-management/items-manager.ts +++ b/src/core/classes/items-management/items-manager.ts @@ -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 { + onSelectedItemUpdated?(item: Item): void; +} /** * Helper to manage a collection of items in a page. */ -export abstract class CoreItemsManager = CoreItemsManagerSource> { +export abstract class CoreItemsManager< + Item = unknown, + Source extends CoreItemsManagerSource = CoreItemsManagerSource, +> { protected source?: { instance: Source; unsubscribe: () => void }; protected itemsMap: Record | null = null; protected selectedItem: Item | null = null; + protected listeners: CoreItemsanagerListener[] = []; constructor(source: Source) { this.setSource(source); @@ -51,8 +58,6 @@ export abstract class CoreItemsManager 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 = {}, - ): Promise { - // Get current route in the page. - const route = this.getCurrentPageRoute(); + addListener(listener: CoreItemsanagerListener): () => void { + this.listeners.push(listener); - if (route === null) { + return () => this.removeListener(listener); + } + + /** + * Remove a listener. + * + * @param listener Listener. + */ + removeListener(listener: CoreItemsanagerListener): 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 { - map[this.getSource().getItemPath(item)] = item; + map[this.getItemId(item)] = item; return map; }, {}); - - this.updateSelectedItem(); } /** @@ -184,4 +156,22 @@ export abstract class CoreItemsManager = CoreItemsManagerSource -> extends CoreItemsManager { + Source extends CoreRoutedItemsManagerSource = CoreRoutedItemsManagerSource +> extends CoreRoutedItemsManager { protected pageRouteLocator?: unknown | ActivatedRoute; protected splitView?: CoreSplitViewComponent; diff --git a/src/core/classes/items-management/routed-items-manager-source.ts b/src/core/classes/items-management/routed-items-manager-source.ts new file mode 100644 index 000000000..6b53c913a --- /dev/null +++ b/src/core/classes/items-management/routed-items-manager-source.ts @@ -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 extends CoreItemsManagerSource { + + 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 { + this.dirty = true; + + await this.load(); + } + + /** + * Load more items, if any. + */ + async load(): Promise { + 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; + +} diff --git a/src/core/classes/items-management/items-manager-sources-tracker.ts b/src/core/classes/items-management/routed-items-manager-sources-tracker.ts similarity index 83% rename from src/core/classes/items-management/items-manager-sources-tracker.ts rename to src/core/classes/items-management/routed-items-manager-sources-tracker.ts index e3c2f16b9..6d0e0b740 100644 --- a/src/core/classes/items-management/items-manager-sources-tracker.ts +++ b/src/core/classes/items-management/routed-items-manager-sources-tracker.ts @@ -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 = { +type SourceConstructor = { getSourceId(...args: unknown[]): string; new (...args: unknown[]): T; }; type SourceConstuctorInstance = T extends { new(...args: unknown[]): infer P } ? P : never; -type InstanceTracking = { instance: CoreItemsManagerSource; references: unknown[] }; +type InstanceTracking = { instance: CoreRoutedItemsManagerSource; references: unknown[] }; type Instances = Record; /** - * Tracks CoreItemsManagerSource instances to reuse between pages. + * Tracks CoreRoutedItemsManagerSource instances to reuse between pages. */ -export class CoreItemsManagerSourcesTracker { +export class CoreRoutedItemsManagerSourcesTracker { private static instances: WeakMap = new WeakMap(); - private static instanceIds: WeakMap = new WeakMap(); + private static instanceIds: WeakMap = 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>( + static getOrCreateSource>( constructor: C, constructorArguments: ConstructorParameters, ): SourceConstuctorInstance { @@ -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( + private static createInstance( id: string, constructor: SourceConstructor, constructorArguments: ConstructorParameters>, diff --git a/src/core/classes/items-management/routed-items-manager.ts b/src/core/classes/items-management/routed-items-manager.ts new file mode 100644 index 000000000..6feb244cc --- /dev/null +++ b/src/core/classes/items-management/routed-items-manager.ts @@ -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 = CoreRoutedItemsManagerSource, +> extends CoreItemsManager { + + /** + * @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 = {}, + ): Promise { + // 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); + } + +} diff --git a/src/core/classes/items-management/swipe-items-manager.ts b/src/core/classes/items-management/swipe-navigation-items-manager.ts similarity index 89% rename from src/core/classes/items-management/swipe-items-manager.ts rename to src/core/classes/items-management/swipe-navigation-items-manager.ts index 918cab741..a156cedc9 100644 --- a/src/core/classes/items-management/swipe-items-manager.ts +++ b/src/core/classes/items-management/swipe-navigation-items-manager.ts @@ -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 = CoreItemsManagerSource + Source extends CoreRoutedItemsManagerSource = CoreRoutedItemsManagerSource > - extends CoreItemsManager { + extends CoreRoutedItemsManager { /** * Process page started operations. @@ -99,7 +99,7 @@ export class CoreSwipeItemsManager< protected async getItemBy(delta: number): Promise { const items = this.getSource().getItems(); - // Get current item. + // Get selected item. const index = (this.selectedItem && items?.indexOf(this.selectedItem)) ?? -1; if (index === -1) { diff --git a/src/core/classes/items-management/swipe-slides-dynamic-items-manager-source.ts b/src/core/classes/items-management/swipe-slides-dynamic-items-manager-source.ts new file mode 100644 index 000000000..cd54848c8 --- /dev/null +++ b/src/core/classes/items-management/swipe-slides-dynamic-items-manager-source.ts @@ -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 + extends CoreSwipeSlidesItemsManagerSource { + + // Items being loaded, to prevent loading them twice. + protected loadingItems: Record = {}; + + /** + * @inheritdoc + */ + async load(selectedItem?: Item | null): Promise { + 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 { + // 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 { + 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 { + 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 { + 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; + + /** + * 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. +}; diff --git a/src/core/classes/items-management/swipe-slides-dynamic-items-manager.ts b/src/core/classes/items-management/swipe-slides-dynamic-items-manager.ts new file mode 100644 index 000000000..6bbb5d542 --- /dev/null +++ b/src/core/classes/items-management/swipe-slides-dynamic-items-manager.ts @@ -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 = CoreSwipeSlidesDynamicItemsManagerSource, +> extends CoreSwipeSlidesItemsManager { + + /** + * @inheritdoc + */ + setSelectedItem(item: Item | null): void { + super.setSelectedItem(item); + + if (item) { + // Load the item if not loaded yet. + this.getSource().loadItem(item); + } + } + +} diff --git a/src/core/classes/items-management/swipe-slides-items-manager-source.ts b/src/core/classes/items-management/swipe-slides-items-manager-source.ts new file mode 100644 index 000000000..7dd41b754 --- /dev/null +++ b/src/core/classes/items-management/swipe-slides-items-manager-source.ts @@ -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 extends CoreItemsManagerSource { + + protected initialItem?: Item; + + constructor(initialItem?: Item) { + super(); + + this.initialItem = initialItem; + } + + /** + * @inheritdoc + */ + async load(): Promise { + const items = await this.loadItems(); + + this.setItems(items); + } + + /** + * Load items. + * + * @return Items list. + */ + protected abstract loadItems(): Promise; + + /** + * 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; + +} diff --git a/src/core/classes/items-management/swipe-slides-items-manager.ts b/src/core/classes/items-management/swipe-slides-items-manager.ts new file mode 100644 index 000000000..5cd254b70 --- /dev/null +++ b/src/core/classes/items-management/swipe-slides-items-manager.ts @@ -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 = CoreSwipeSlidesItemsManagerSource, +> extends CoreItemsManager { + + /** + * @inheritdoc + */ + getItemId(item: Item): string | number { + return this.getSource().getItemId(item); + } + +} diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index d39a0e057..ae31faa41 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -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, diff --git a/src/core/components/swipe-navigation/swipe-navigation.ts b/src/core/components/swipe-navigation/swipe-navigation.ts index 57dab122d..c26e0ab67 100644 --- a/src/core/components/swipe-navigation/swipe-navigation.ts +++ b/src/core/components/swipe-navigation/swipe-navigation.ts @@ -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; diff --git a/src/core/components/swipe-slides/swipe-slides.html b/src/core/components/swipe-slides/swipe-slides.html new file mode 100644 index 000000000..fc18d8c18 --- /dev/null +++ b/src/core/components/swipe-slides/swipe-slides.html @@ -0,0 +1,5 @@ + + + + + diff --git a/src/core/components/swipe-slides/swipe-slides.scss b/src/core/components/swipe-slides/swipe-slides.scss new file mode 100644 index 000000000..71cf2f9f6 --- /dev/null +++ b/src/core/components/swipe-slides/swipe-slides.scss @@ -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; + } +} diff --git a/src/core/components/swipe-slides/swipe-slides.ts b/src/core/components/swipe-slides/swipe-slides.ts new file mode 100644 index 000000000..139393670 --- /dev/null +++ b/src/core/components/swipe-slides/swipe-slides.ts @@ -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 implements OnChanges, OnDestroy { + + @Input() manager?: CoreSwipeSlidesItemsManager; + @Input() options: CoreSwipeSlidesOptions = {}; + @Output() onWillChange = new EventEmitter>(); + @Output() onDidChange = new EventEmitter>(); + + @ViewChild(IonSlides) slides?: IonSlides; + @ContentChild(TemplateRef) template?: TemplateRef; // Template defined by the content. + + protected hostElement: HTMLElement; + protected unsubscribe?: () => void; + + constructor( + elementRef: ElementRef, + 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): Promise { + 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 { + 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 { + 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 | 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 & { + scrollOnChange?: 'top' | 'none'; // Scroll behaviour on change slide. By default, none. +}; + +/** + * Data about current item. + */ +export type CoreSwipeCurrentItemData = { + index: number; + item: Item; +}; diff --git a/src/core/features/user/classes/participants-source.ts b/src/core/features/user/classes/participants-source.ts index 9aa6e4db1..892341ab1 100644 --- a/src/core/features/user/classes/participants-source.ts +++ b/src/core/features/user/classes/participants-source.ts @@ -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 { +export class CoreUserParticipantsSource extends CoreRoutedItemsManagerSource { /** * @inheritdoc diff --git a/src/core/features/user/pages/participants/participants.page.ts b/src/core/features/user/pages/participants/participants.page.ts index a15ed9a11..3ff362630 100644 --- a/src/core/features/user/pages/participants/participants.page.ts +++ b/src/core/features/user/pages/participants/participants.page.ts @@ -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 { CoreApp.closeKeyboard(); - const newSource = CoreItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId, query]); + const newSource = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( + CoreUserParticipantsSource, + [this.courseId, query], + ); this.searchInProgress = true; this.searchQuery = query; diff --git a/src/core/features/user/pages/profile/profile.page.ts b/src/core/features/user/pages/profile/profile.page.ts index bcfc6bad3..69a8fcd26 100644 --- a/src/core/features/user/pages/profile/profile.page.ts +++ b/src/core/features/user/pages/profile/profile.page.ts @@ -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 diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index b3052137e..9b22c544e 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -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 & { // 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', +}