From 9bf939ab2fb31c5d3900d1bc3326f6d24b9f077a Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 23 Nov 2021 11:23:31 +0100 Subject: [PATCH 1/5] MOBILE-3927 book: Add swipe to book --- .../index/addon-mod-book-index.html | 21 ++- .../mod/book/components/index/index.scss | 9 + src/addons/mod/book/components/index/index.ts | 170 ++++++++++++------ src/core/services/utils/dom.ts | 14 +- 4 files changed, 151 insertions(+), 63 deletions(-) create mode 100644 src/addons/mod/book/components/index/index.scss 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..e85705f95 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.scss b/src/addons/mod/book/components/index/index.scss new file mode 100644 index 000000000..6b0b7043d --- /dev/null +++ b/src/addons/mod/book/components/index/index.scss @@ -0,0 +1,9 @@ +:host { + ion-slide { + display: block; + font-size: inherit; + justify-content: start; + align-items: start; + text-align: start; + } +} diff --git a/src/addons/mod/book/components/index/index.ts b/src/addons/mod/book/components/index/index.ts index 5d3281c25..34ac81fa0 100644 --- a/src/addons/mod/book/components/index/index.ts +++ b/src/addons/mod/book/components/index/index.ts @@ -12,8 +12,8 @@ // 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 } from '@angular/core'; +import { IonContent, IonSlides } from '@ionic/angular'; import { CoreCourseModuleMainResourceComponent } from '@features/course/classes/main-resource-component'; import { AddonModBookProvider, @@ -31,6 +31,8 @@ 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'; /** * Component that displays a book. @@ -38,30 +40,43 @@ import { CoreNavigationBarItem } from '@components/navigation-bar/navigation-bar @Component({ selector: 'addon-mod-book-index', templateUrl: 'addon-mod-book-index.html', + styleUrls: ['index.scss'], }) export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComponent implements OnInit { + @ViewChild(IonSlides) slides?: IonSlides; + @Input() initialChapterId?: number; // The initial chapter ID to load. component = AddonModBookProvider.COMPONENT; - chapterContent?: string; + loadedChapters: LoadedChapter[] = []; + previousChapter?: AddonModBookTocChapter; + nextChapter?: AddonModBookTocChapter; tagsEnabled = false; warning = ''; tags?: CoreTagItem[]; displayNavBar = true; navigationItems: CoreNavigationBarItem[] = []; displayTitlesInNavBar = false; + slidesOpts = { + initialSlide: 0, + autoHeight: true, + }; protected chapters: AddonModBookTocChapter[] = []; protected currentChapter?: number; protected book?: AddonModBookBookWSData; protected contentsMap: AddonModBookContentsMap = {}; + protected element: HTMLElement; constructor( + elementRef: ElementRef, protected content?: IonContent, @Optional() courseContentsPage?: CoreCourseContentsPage, ) { super('AddonModBookIndexComponent', courseContentsPage); + + this.element = elementRef.nativeElement; } /** @@ -102,10 +117,13 @@ 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 || chapterId === this.currentChapter) { + return; + } + + const index = this.loadedChapters.findIndex(chapter => chapter.id === chapterId); + if (index > -1) { + this.slides?.slideTo(index); } } @@ -138,10 +156,11 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp 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); + const index = this.chapters.findIndex((chapter) => chapter.id == this.initialChapterId); - if (chapter) { + if (index >= 0) { this.currentChapter = this.initialChapterId; + this.slidesOpts.initialSlide = index; } } @@ -154,14 +173,12 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp return; } - // Show chapter. - try { - await this.loadChapter(this.currentChapter, refresh); + await this.loadChapters(); - this.warning = downloadResult?.failed ? this.getErrorDownloadingSomeFilesMessage(downloadResult.error!) : ''; - } catch { - // Ignore errors, they're handled inside the loadChapter function. - } + // Show chapter. + await this.viewChapter(this.currentChapter, refresh); + + this.warning = downloadResult?.failed ? this.getErrorDownloadingSomeFilesMessage(downloadResult.error!) : ''; } finally { // Pass false because downloadResourceIfNeeded already invalidates and refresh data if refresh=true. this.fillContextMenu(false); @@ -184,49 +201,94 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp } /** - * Load a book chapter. + * Load book chapters. + * + * @return Promise resolved when done. + */ + protected async loadChapters(): 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.tagsEnabled ? this.contentsMap[chapter.id].tags : [], + }; + })); + + let newIndex = -1; + if (this.loadedChapters.length && newChapters.length != this.loadedChapters.length) { + // Number of chapters has changed. Search the chapter to display, otherwise it could change automatically. + newIndex = this.chapters.findIndex((chapter) => chapter.id === this.currentChapter); + } + + this.loadedChapters = newChapters; + + if (newIndex > -1) { + this.slides?.slideTo(newIndex, 0, false); + } + } catch (exception) { + const error = exception ?? new CoreError(Translate.instant('addon.mod_book.errorchapter')); + if (!error.message) { + error.message = Translate.instant('addon.mod_book.errorchapter'); + } + + throw error; + } + } + + /** + * View 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 { + protected async viewChapter(chapterId: number, logChapterId: boolean): Promise { this.currentChapter = chapterId; - this.content?.scrollToTop(); - try { - const content = await AddonModBook.getChapterContent(this.contentsMap, chapterId, this.module.id); - - this.tags = this.tagsEnabled ? this.contentsMap[this.currentChapter].tags : []; - - this.chapterContent = content; - - 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; + if (this.displayNavBar) { + this.navigationItems = this.getNavigationItems(chapterId); } + + // Chapter loaded, log view. + 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); + } + } + + /** + * Slide has changed. + * + * @return Promise resolved when done. + */ + async slideChanged(): Promise { + if (!this.slides) { + return; + } + + const scrollElement = await this.content?.getScrollElement(); + const container = this.element.querySelector('.addon-mod_book-container'); + + if (container && (!scrollElement || CoreDomUtils.isElementOutsideOfScreen(scrollElement, container, 'top'))) { + // Scroll to top. + container.scrollIntoView({ behavior: 'smooth' }); + } + + const index = await this.slides.getActiveIndex(); + + this.viewChapter(this.loadedChapters[index].id, true); } /** @@ -245,3 +307,9 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp } } + +type LoadedChapter = { + id: number; + content: string; + tags?: CoreTagItem[]; +}; diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index b3052137e..ba8d78d07 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -769,21 +769,29 @@ 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: 'top' | 'mid' | 'bottom' = 'mid'): boolean { const elementRect = element.getBoundingClientRect(); if (!elementRect) { return false; } - const elementMidPoint = Math.round((elementRect.bottom + elementRect.top) / 2); + let elementPoint: number; + if (point === 'top') { + elementPoint = elementRect.top; + } else if (point === 'bottom') { + elementPoint = elementRect.bottom; + } else { + elementPoint = Math.round((elementRect.bottom + elementRect.top) / 2); + } const scrollElRect = scrollEl.getBoundingClientRect(); const scrollTopPos = scrollElRect?.top || 0; - return elementMidPoint > window.innerHeight || elementMidPoint < scrollTopPos; + return elementPoint > window.innerHeight || elementPoint < scrollTopPos; } /** From 6674262bb7adbc2b4b62640089d66b4243fcbc43 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 24 Nov 2021 09:34:33 +0100 Subject: [PATCH 2/5] MOBILE-3927 calendar: Add swipe to calendar monthly view --- .../calendar/addon-calendar-calendar.html | 144 +++--- .../components/calendar/calendar.scss | 12 + .../calendar/components/calendar/calendar.ts | 415 +++++++++++------- .../upcoming-events/upcoming-events.ts | 8 +- src/core/services/utils/time.ts | 80 ++++ 5 files changed, 429 insertions(+), 230 deletions(-) diff --git a/src/addons/calendar/components/calendar/addon-calendar-calendar.html b/src/addons/calendar/components/calendar/addon-calendar-calendar.html index 3030a2b5a..f102136be 100644 --- a/src/addons/calendar/components/calendar/addon-calendar-calendar.html +++ b/src/addons/calendar/components/calendar/addon-calendar-calendar.html @@ -17,7 +17,10 @@ -

{{ periodName }}

+

+ {{ periodName }} + +

@@ -27,74 +30,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..02939b032 100644 --- a/src/addons/calendar/components/calendar/calendar.ts +++ b/src/addons/calendar/components/calendar/calendar.ts @@ -22,11 +22,12 @@ import { EventEmitter, KeyValueDiffers, KeyValueDiffer, + ViewChild, } from '@angular/core'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; -import { CoreTimeUtils } from '@services/utils/time'; +import { CoreTimeUtils, YearAndMonth } from '@services/utils/time'; import { CoreUtils } from '@services/utils/utils'; import { AddonCalendar, @@ -41,6 +42,7 @@ 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 { IonSlides } from '@ionic/angular'; /** * Component that displays a calendar. @@ -52,6 +54,8 @@ import { CoreLocalNotifications } from '@services/local-notifications'; }) export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestroy { + @ViewChild(IonSlides) slides?: IonSlides; + @Input() initialYear?: number; // Initial year to load. @Input() initialMonth?: number; // Initial month to load. @Input() filter?: AddonCalendarFilter; // Filter to apply. @@ -61,15 +65,14 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro @Output() onDayClicked = new EventEmitter<{day: number; month: number; year: number}>(); periodName?: string; - weekDays: AddonCalendarWeekDaysTranslationKeys[] = []; - weeks: AddonCalendarWeek[] = []; + preloadedMonths: PreloadedMonth[] = []; loaded = false; + monthLoaded = false; timeFormat?: string; isCurrentMonth = false; isPastMonth = false; - protected year: number; - protected month: number; + protected visibleMonth: YearAndMonth; protected categoriesRetrieved = false; protected categories: { [id: number]: CoreCategoryData } = {}; protected currentSiteId: string; @@ -92,9 +95,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.preloadedMonths.forEach((month) => { + if (!month.loaded) { + return; + } + + month.weeks.forEach((week) => { + week.days.forEach((day) => { + AddonCalendar.scheduleEventsNotifications(day.eventsFormated || []); + }); }); }); }, this.currentSiteId); @@ -124,16 +133,18 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro const now = new Date(); - this.year = now.getFullYear(); - this.month = now.getMonth() + 1; + this.visibleMonth = { + year: now.getFullYear(), + monthNumber: now.getMonth() + 1, + }; } /** * Component loaded. */ ngOnInit(): void { - this.year = this.initialYear ? this.initialYear : this.year; - this.month = this.initialMonth ? this.initialMonth : this.month; + this.visibleMonth.year = this.initialYear ?? this.visibleMonth.year; + this.visibleMonth.monthNumber = this.initialMonth ?? this.visibleMonth.monthNumber; this.calculateIsCurrentMonth(); @@ -148,11 +159,15 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro this.displayNavButtons = typeof this.displayNavButtons == 'undefined' ? true : CoreUtils.isTrueOrOne(this.displayNavButtons); - if (this.weeks) { + if (this.preloadedMonths.length) { // Check if there's any change in the filter object. const changes = this.differ.diff(this.filter || {}); if (changes) { - this.filterEvents(); + this.preloadedMonths.forEach((month) => { + if (month.loaded) { + this.filterEvents(month.weeks); + } + }); } } } @@ -196,7 +211,7 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro try { await Promise.all(promises); - await this.fetchEvents(); + await this.viewMonth(); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); @@ -206,72 +221,162 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro } /** - * Fetch the events for current month. + * Load or preload a month. * + * @param year Year. + * @param monthNumber Month number. + * @param preload Whether to "preload" the month. When preloading, no events will be fetched. * @return Promise resolved when done. */ - async fetchEvents(): Promise { - // Don't pass courseId and categoryId, we'll filter them locally. - let result: { daynames: Partial[]; weeks: Partial[] }; - try { - result = await AddonCalendar.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; - } + async loadMonth(month: YearAndMonth, preload = false): Promise { + // Check if it's already loaded. + const existingMonth = this.findPreloadedMonth(month); + if (existingMonth && ((existingMonth.loaded && !existingMonth.needsRefresh) || preload)) { + return; } - // 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(), - 'core.strftimemonthyear', - ); - this.weekDays = AddonCalendar.getWeekDays(result.daynames[0].dayno); - this.weeks = result.weeks as AddonCalendarWeek[]; - this.calculateIsCurrentMonth(); + if (!preload) { + this.monthLoaded = false; + } - 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)), - ); + try { + // Load or preload the weeks. + let result: { daynames: Partial[]; weeks: Partial[] }; + if (preload) { + result = await AddonCalendarHelper.getOfflineMonthWeeks(month.year, month.monthNumber); + } else { + try { + // Don't pass courseId and categoryId, we'll filter them locally. + result = await AddonCalendar.getMonthlyEvents(month.year, month.monthNumber); + } catch (error) { + if (!CoreApp.isOnline()) { + // Allow navigating to non-cached months in offline (behave as if using emergency cache). + result = await AddonCalendarHelper.getOfflineMonthWeeks(month.year, month.monthNumber); + } else { + throw error; + } + } + } - day.eventsFormated = day.eventsFormated.concat(onlineEventsFormatted); - })); - })); - - if (this.isCurrentMonth) { + const weekDays = AddonCalendar.getWeekDays(result.daynames[0].dayno); + const weeks = result.weeks as AddonCalendarWeek[]; + const isCurrentMonth = CoreTimeUtils.isCurrentMonth(month); 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; + await Promise.all(weeks.map(async (week) => { + await Promise.all(week.days.map(async (day) => { + day.periodName = CoreTimeUtils.userDate( + new Date(month.year, month.monthNumber - 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)), + ); - if (day.istoday) { - day.eventsFormated?.forEach((event) => { - event.ispast = this.isEventPast(event); - }); + day.eventsFormated = day.eventsFormated.concat(onlineEventsFormatted); + + if (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); + }); + } } - }); + })); + })); + + if (!preload) { + // Merge the online events with offline data. + this.mergeEvents(month, weeks); + // Filter events by course. + this.filterEvents(weeks); + } + + if (existingMonth) { + // Month already exists, update it. + existingMonth.loaded = !preload; + existingMonth.weeks = weeks; + existingMonth.weekDays = weekDays; + + return; + } + + // Add the preloaded month at the right position. + const preloadedMonth: PreloadedMonth = { + ...month, + loaded: !preload, + weeks, + weekDays, + }; + const previousMonth = CoreTimeUtils.getPreviousMonth(month); + const nextMonth = CoreTimeUtils.getNextMonth(month); + const activeIndex = await this.slides?.getActiveIndex(); + + const added = this.preloadedMonths.some((month, index) => { + let positionToInsert = -1; + if (CoreTimeUtils.isSameMonth(month, previousMonth)) { + // Previous month found, add the month after it. + positionToInsert = index + 1; + } + + if (CoreTimeUtils.isSameMonth(month, nextMonth)) { + // Next month found, add the month before it. + positionToInsert = index; + } + + if (positionToInsert > -1) { + this.preloadedMonths.splice(positionToInsert, 0, preloadedMonth); + if (activeIndex !== undefined && positionToInsert <= activeIndex) { + // Added a slide before the active one, keep current slide. + this.slides?.slideTo(activeIndex + 1, 0, false); + } + + return true; + } }); + + if (!added) { + // Previous and next months not found, this probably means the array is still empty. Add it at the end. + this.preloadedMonths.push(preloadedMonth); + } + } finally { + if (!preload) { + this.monthLoaded = true; + } + } + } + + /** + * Load current month and preload next and previous ones. + * + * @return Promise resolved when done. + */ + async viewMonth(): Promise { + // 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.visibleMonth.year, this.visibleMonth.monthNumber - 1).getTime(), + 'core.strftimemonthyear', + ); + this.calculateIsCurrentMonth(); + + try { + // Load current month, and preload next and previous ones. + await Promise.all([ + this.loadMonth(this.visibleMonth, false), + this.loadMonth(CoreTimeUtils.getPreviousMonth(this.visibleMonth), true), + this.loadMonth(CoreTimeUtils.getNextMonth(this.visibleMonth), true), + ]); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); } - // Merge the online events with offline data. - this.mergeEvents(); - // Filter events by course. - this.filterEvents(); } /** @@ -301,9 +406,11 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro /** * Filter events based on the filter popover. + * + * @param weeks Weeks with the events to filter. */ - filterEvents(): void { - this.weeks.forEach((week) => { + filterEvents(weeks: AddonCalendarWeek[]): void { + weeks.forEach((week) => { week.days.forEach((day) => { day.filteredEvents = AddonCalendarHelper.getFilteredEvents( day.eventsFormated || [], @@ -323,18 +430,20 @@ 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 { + async refreshData(): Promise { const promises: Promise[] = []; - // Don't invalidate monthly events after a change, it has already been handled. - if (!afterChange) { - promises.push(AddonCalendar.invalidateMonthlyEvents(this.year, this.month)); - } + promises.push(AddonCalendar.invalidateMonthlyEvents(this.visibleMonth.year, this.visibleMonth.monthNumber)); promises.push(CoreCourses.invalidateCategories(0, true)); promises.push(AddonCalendar.invalidateTimeFormat()); this.categoriesRetrieved = false; // Get categories again. + const preloadedMonth = this.findPreloadedMonth(this.visibleMonth); + if (preloadedMonth) { + preloadedMonth.needsRefresh = true; + } + await Promise.all(promises); this.fetchData(); @@ -343,35 +452,15 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro /** * 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 +480,46 @@ 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 }); + this.onDayClicked.emit({ day: day, month: this.visibleMonth.monthNumber, year: this.visibleMonth.year }); } /** * 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.isCurrentMonth = CoreTimeUtils.isCurrentMonth(this.visibleMonth); + this.isPastMonth = CoreTimeUtils.isCurrentMonth(this.visibleMonth); } /** * Go to current month. */ async goToCurrentMonth(): Promise { + const now = new Date(); - const initialMonth = this.month; - const initialYear = this.year; + const currentMonth = { + monthNumber: now.getMonth() + 1, + year: now.getFullYear(), + }; - this.month = now.getMonth() + 1; - this.year = now.getFullYear(); - - this.loaded = false; - - try { - await this.fetchEvents(); - this.isCurrentMonth = true; - } 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--; - } - } - - /** - * Increase the current month. - */ - protected increaseMonth(): void { - if (this.month === 12) { - this.month = 1; - this.year++; - } else { - this.month++; + const index = this.preloadedMonths.findIndex((month) => CoreTimeUtils.isSameMonth(month, currentMonth)); + if (index > -1) { + this.slides?.slideTo(index); } } /** * Merge online events with the offline events of that period. + * + * @param month Month. + * @param weeks Weeks with the events to filter. */ - protected mergeEvents(): void { + protected mergeEvents(month: YearAndMonth, weeks: AddonCalendarWeek[]): void { const monthOfflineEvents: { [day: number]: AddonCalendarEventToDisplay[] } = - this.offlineEvents[AddonCalendarHelper.getMonthId(this.year, this.month)]; + this.offlineEvents[AddonCalendarHelper.getMonthId(month.year, month.monthNumber)]; - this.weeks.forEach((week) => { + weeks.forEach((week) => { week.days.forEach((day) => { // Schedule notifications for the events retrieved (only future events will be scheduled). @@ -498,18 +555,20 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro * * @param eventId Event ID. */ - protected undeleteEvent(eventId: number): void { - if (!this.weeks) { - return; - } + protected async undeleteEvent(eventId: number): Promise { + this.preloadedMonths.forEach((month) => { + if (!month.loaded) { + return; + } - this.weeks.forEach((week) => { - week.days.forEach((day) => { - const event = day.eventsFormated?.find((event) => event.id == eventId); + month.weeks.forEach((week) => { + week.days.forEach((day) => { + const event = day.eventsFormated?.find((event) => event.id == eventId); - if (event) { - event.deleted = false; - } + if (event) { + event.deleted = false; + } + }); }); }); } @@ -524,6 +583,38 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro return (event.timestart + event.timeduration) < (this.currentTime || CoreTimeUtils.timestamp()); } + /** + * Slide has changed. + * + * @return Promise resolved when done. + */ + async slideChanged(): Promise { + if (!this.slides) { + return; + } + + const index = await this.slides.getActiveIndex(); + const preloadedMonth = this.preloadedMonths[index]; + if (!preloadedMonth) { + return; + } + + this.visibleMonth.year = preloadedMonth.year; + this.visibleMonth.monthNumber = preloadedMonth.monthNumber; + + await this.viewMonth(); + } + + /** + * Find a certain preloaded month. + * + * @param month Month to search. + * @return Preloaded month, undefined if not found. + */ + protected findPreloadedMonth(month: YearAndMonth): PreloadedMonth | undefined { + return this.preloadedMonths.find(preloadedMonth => CoreTimeUtils.isSameMonth(month, preloadedMonth)); + } + /** * Component destroyed. */ @@ -533,3 +624,13 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro } } + +/** + * Preloaded month. + */ +type PreloadedMonth = YearAndMonth & { + loaded: boolean; // Whether the events have been loaded. + weekDays: AddonCalendarWeekDaysTranslationKeys[]; + weeks: AddonCalendarWeek[]; + needsRefresh?: boolean; // Whether the events needs to be re-loaded. +}; 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/core/services/utils/time.ts b/src/core/services/utils/time.ts index 684c741b0..cd9c449f3 100644 --- a/src/core/services/utils/time.ts +++ b/src/core/services/utils/time.ts @@ -389,6 +389,86 @@ export class CoreTimeUtilsProvider { return String(moment().year() - 20); } + /** + * Given a year and a month, return the previous month and its year. + * + * @param month Year and month. + * @return Previous month and its year. + */ + getPreviousMonth(month: YearAndMonth): YearAndMonth { + if (month.monthNumber === 1) { + return { + monthNumber: 12, + year: month.year - 1, + }; + } + + return { + monthNumber: month.monthNumber - 1, + year: month.year, + }; + } + + /** + * Given a year and a month, return the next month and its year. + * + * @param month Year and month. + * @return Next month and its year. + */ + getNextMonth(month: YearAndMonth): YearAndMonth { + if (month.monthNumber === 12) { + return { + monthNumber: 1, + year: month.year + 1, + }; + } + + return { + monthNumber: month.monthNumber + 1, + year: month.year, + }; + } + + /** + * Check if a certain month is current month. + * + * @param month Year and month. + * @return Whether it's current month. + */ + isCurrentMonth(month: YearAndMonth): boolean { + const now = new Date(); + + return month.year == now.getFullYear() && month.monthNumber == now.getMonth() + 1; + } + + /** + * Check if a certain month is a past month. + * + * @param month Year and month. + * @return Whether it's a past month. + */ + isPastMonth(month: YearAndMonth): boolean { + const now = new Date(); + + return month.year < now.getFullYear() || (month.year == now.getFullYear() && month.monthNumber < now.getMonth() + 1); + } + + /** + * Check if two months are the same. + * + * @param monthA Month A. + * @param monthB Month B. + * @return Whether it's same month. + */ + isSameMonth(monthA: YearAndMonth, monthB: YearAndMonth): boolean { + return monthA.monthNumber === monthB.monthNumber && monthA.year === monthB.year; + } + } export const CoreTimeUtils = makeSingleton(CoreTimeUtilsProvider); + +export type YearAndMonth = { + year: number; // Year number. + monthNumber: number; // Month number (1 to 12). +}; From 741880f8df3c2a84087dbc2edf5e9a49c5b50677 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 10 Dec 2021 07:19:33 +0100 Subject: [PATCH 3/5] MOBILE-3927 swipe: Create swipe-slides component and refactor managers --- .../badges/classes/user-badges-source.ts | 4 +- .../pages/issued-badge/issued-badge.page.ts | 7 +- .../pages/user-badges/user-badges.page.ts | 4 +- .../calendar/addon-calendar-calendar.html | 15 +- .../calendar/components/calendar/calendar.ts | 686 +++++++++--------- .../mod/assign/classes/submissions-source.ts | 4 +- .../submission-list/submission-list.page.ts | 4 +- .../submission-review/submission-review.ts | 4 +- .../index/addon-mod-book-index.html | 14 +- src/addons/mod/book/components/index/index.ts | 253 ++++--- .../forum/classes/forum-discussions-source.ts | 4 +- .../mod/forum/components/index/index.ts | 4 +- .../forum/pages/discussion/discussion.page.ts | 4 +- .../new-discussion/new-discussion.page.ts | 4 +- .../classes/glossary-entries-source.ts | 4 +- .../mod/glossary/components/index/index.ts | 4 +- src/addons/mod/glossary/pages/edit/edit.ts | 4 +- src/addons/mod/glossary/pages/entry/entry.ts | 4 +- .../items-management/items-manager-source.ts | 133 +--- .../classes/items-management/items-manager.ts | 96 +-- .../items-management/list-items-manager.ts | 8 +- .../routed-items-manager-source.ts | 150 ++++ ...> routed-items-manager-sources-tracker.ts} | 20 +- .../items-management/routed-items-manager.ts | 135 ++++ .../slides-dynamic-items-manager-source.ts | 141 ++++ .../slides-dynamic-items-manager.ts | 38 + .../slides-items-manager-source.ts | 121 +++ .../items-management/slides-items-manager.ts | 47 ++ .../items-management/swipe-items-manager.ts | 8 +- src/core/components/components.module.ts | 3 + .../components/swipe-slides/swipe-slides.html | 6 + .../swipe-slides/swipe-slides.scss} | 0 .../components/swipe-slides/swipe-slides.ts | 263 +++++++ .../user/classes/participants-source.ts | 4 +- .../pages/participants/participants.page.ts | 11 +- .../user/pages/profile/profile.page.ts | 7 +- 36 files changed, 1496 insertions(+), 722 deletions(-) create mode 100644 src/core/classes/items-management/routed-items-manager-source.ts rename src/core/classes/items-management/{items-manager-sources-tracker.ts => routed-items-manager-sources-tracker.ts} (83%) create mode 100644 src/core/classes/items-management/routed-items-manager.ts create mode 100644 src/core/classes/items-management/slides-dynamic-items-manager-source.ts create mode 100644 src/core/classes/items-management/slides-dynamic-items-manager.ts create mode 100644 src/core/classes/items-management/slides-items-manager-source.ts create mode 100644 src/core/classes/items-management/slides-items-manager.ts create mode 100644 src/core/components/swipe-slides/swipe-slides.html rename src/{addons/mod/book/components/index/index.scss => core/components/swipe-slides/swipe-slides.scss} (100%) create mode 100644 src/core/components/swipe-slides/swipe-slides.ts 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..c48a21034 100644 --- a/src/addons/badges/pages/issued-badge/issued-badge.page.ts +++ b/src/addons/badges/pages/issued-badge/issued-badge.page.ts @@ -24,8 +24,8 @@ import { CoreCourses, CoreEnrolledCourseData } from '@features/courses/services/ 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 { 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. @@ -61,7 +61,10 @@ export class AddonBadgesIssuedBadgePage implements OnInit { this.badgeLoaded = true; }); - const source = CoreItemsManagerSourcesTracker.getOrCreateSource(AddonBadgesUserBadgesSource, [this.courseId, this.userId]); + const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( + AddonBadgesUserBadgesSource, + [this.courseId, this.userId], + ); this.badges = new CoreSwipeItemsManager(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 f102136be..40ba979c3 100644 --- a/src/addons/calendar/components/calendar/addon-calendar-calendar.html +++ b/src/addons/calendar/components/calendar/addon-calendar-calendar.html @@ -19,7 +19,8 @@

{{ periodName }} - + +

@@ -30,14 +31,14 @@ - - + +
- + {{ day.fullname | translate }} @@ -46,7 +47,7 @@
- + @@ -104,7 +105,7 @@
-
-
+ + diff --git a/src/addons/calendar/components/calendar/calendar.ts b/src/addons/calendar/components/calendar/calendar.ts index 02939b032..6a0f3a4c5 100644 --- a/src/addons/calendar/components/calendar/calendar.ts +++ b/src/addons/calendar/components/calendar/calendar.ts @@ -42,7 +42,9 @@ 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 { IonSlides } from '@ionic/angular'; +import { CoreSwipeCurrentItemData, CoreSwipeSlidesComponent } from '@components/swipe-slides/swipe-slides'; +import { CoreSwipeSlidesDynamicItemsManagerSource } from '@classes/items-management/slides-dynamic-items-manager-source'; +import { CoreSwipeSlidesDynamicItemsManager } from '@classes/items-management/slides-dynamic-items-manager'; /** * Component that displays a calendar. @@ -54,7 +56,7 @@ import { IonSlides } from '@ionic/angular'; }) export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestroy { - @ViewChild(IonSlides) slides?: IonSlides; + @ViewChild(CoreSwipeSlidesComponent) slides?: CoreSwipeSlidesComponent; @Input() initialYear?: number; // Initial year to load. @Input() initialMonth?: number; // Initial month to load. @@ -65,22 +67,12 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro @Output() onDayClicked = new EventEmitter<{day: number; month: number; year: number}>(); periodName?: string; - preloadedMonths: PreloadedMonth[] = []; + manager?: CoreSwipeSlidesDynamicItemsManager; loaded = false; - monthLoaded = false; - timeFormat?: string; isCurrentMonth = false; isPastMonth = false; - protected visibleMonth: YearAndMonth; - 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. @@ -95,7 +87,7 @@ 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.preloadedMonths.forEach((month) => { + this.manager?.getSource().getItems()?.forEach((month) => { if (!month.loaded) { return; } @@ -121,30 +113,32 @@ 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); - if (index != -1) { - this.deletedEvents.splice(index, 1); + const index = this.manager?.getSource().deletedEvents.indexOf(data.eventId); + if (index !== undefined && index != -1) { + this.manager?.getSource().deletedEvents.splice(index, 1); } }, this.currentSiteId, ); this.differ = differs.find([]).create(); - - const now = new Date(); - - this.visibleMonth = { - year: now.getFullYear(), - monthNumber: now.getMonth() + 1, - }; } /** * Component loaded. */ ngOnInit(): void { - this.visibleMonth.year = this.initialYear ?? this.visibleMonth.year; - this.visibleMonth.monthNumber = this.initialMonth ?? this.visibleMonth.monthNumber; + const now = new Date(); + + this.canNavigate = typeof this.canNavigate == 'undefined' ? true : CoreUtils.isTrueOrOne(this.canNavigate); + this.displayNavButtons = typeof this.displayNavButtons == 'undefined' ? true : + CoreUtils.isTrueOrOne(this.displayNavButtons); + + const source = new AddonCalendarMonthSlidesItemsManagerSource(this, { + year: this.initialYear ?? now.getFullYear(), + monthNumber: this.initialMonth ?? now.getMonth() + 1, + }); + this.manager = new CoreSwipeSlidesDynamicItemsManager(source); this.calculateIsCurrentMonth(); @@ -155,15 +149,13 @@ 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.preloadedMonths.length) { + if (items?.length) { // Check if there's any change in the filter object. const changes = this.differ.diff(this.filter || {}); if (changes) { - this.preloadedMonths.forEach((month) => { + items.forEach((month) => { if (month.loaded) { this.filterEvents(month.weeks); } @@ -172,47 +164,20 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro } } + 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.viewMonth(); + await this.manager?.getSource().fetchData(); + await this.manager?.getSource().load(); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); } @@ -221,20 +186,310 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro } /** - * Load or preload a month. + * Load current month and preload next and previous ones. * - * @param year Year. - * @param monthNumber Month number. - * @param preload Whether to "preload" the month. When preloading, no events will be fetched. * @return Promise resolved when done. */ - async loadMonth(month: YearAndMonth, preload = false): Promise { - // Check if it's already loaded. - const existingMonth = this.findPreloadedMonth(month); - if (existingMonth && ((existingMonth.loaded && !existingMonth.needsRefresh) || preload)) { + async viewMonth(month: YearAndMonth): Promise { + // 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(month.year, month.monthNumber - 1).getTime(), + 'core.strftimemonthyear', + ); + this.calculateIsCurrentMonth(); + } + + /** + * Filter events based on the filter popover. + * + * @param weeks Weeks with the events to filter. + */ + filterEvents(weeks: AddonCalendarWeek[]): void { + weeks.forEach((week) => { + week.days.forEach((day) => { + day.filteredEvents = AddonCalendarHelper.getFilteredEvents( + day.eventsFormated || [], + this.filter, + this.manager?.getSource().categories || {}, + ); + + // Re-calculate some properties. + AddonCalendarHelper.calculateDayData(day, day.filteredEvents); + }); + }); + } + + /** + * Refresh events. + * + * @param afterChange Whether the refresh is done after an event has changed or has been synced. + * @return Promise resolved when done. + */ + async refreshData(): Promise { + const visibleMonth = this.slides?.getCurrentItem() || null; + + await this.manager?.getSource().invalidateContent(visibleMonth); + + await this.fetchData(); + } + + /** + * Load next month. + */ + loadNext(): void { + this.slides?.slideNext(); + } + + /** + * Load previous month. + */ + loadPrevious(): void { + this.slides?.slidePrev(); + } + + /** + * An event was clicked. + * + * @param calendarEvent Calendar event.. + * @param event Mouse event. + */ + eventClicked(calendarEvent: AddonCalendarEventToDisplay, event: Event): void { + this.onEventClicked.emit(calendarEvent.id); + event.stopPropagation(); + } + + /** + * A day was clicked. + * + * @param day Day. + */ + dayClicked(day: number): void { + const visibleMonth = this.slides?.getCurrentItem(); + if (!visibleMonth) { return; } + this.onDayClicked.emit({ day: day, month: visibleMonth.monthNumber, year: visibleMonth.year }); + } + + /** + * Check if user is viewing the current month. + */ + calculateIsCurrentMonth(): void { + const visibleMonth = this.slides?.getCurrentItem(); + if (!visibleMonth) { + return; + } + + this.currentTime = CoreTimeUtils.timestamp(); + this.isCurrentMonth = CoreTimeUtils.isCurrentMonth(visibleMonth); + this.isPastMonth = CoreTimeUtils.isCurrentMonth(visibleMonth); + } + + /** + * Go to current month. + */ + async goToCurrentMonth(): Promise { + const now = new Date(); + const currentMonth = { + monthNumber: now.getMonth() + 1, + year: now.getFullYear(), + }; + + this.slides?.slideToItem(currentMonth); + } + + /** + * Undelete a certain event. + * + * @param eventId Event ID. + */ + protected async undeleteEvent(eventId: number): Promise { + this.manager?.getSource().getItems()?.forEach((month) => { + if (!month.loaded) { + return; + } + + month.weeks.forEach((week) => { + week.days.forEach((day) => { + const event = day.eventsFormated?.find((event) => event.id == eventId); + + if (event) { + event.deleted = false; + } + }); + }); + }); + } + + /** + * Returns if the event is in the past or not. + * + * @param event Event object. + * @return True if it's in the past. + */ + isEventPast(event: { timestart: number; timeduration: number}): boolean { + return (event.timestart + event.timeduration) < (this.currentTime || CoreTimeUtils.timestamp()); + } + + /** + * Slide has changed. + * + * @param data Data about new item. + */ + slideChanged(data: CoreSwipeCurrentItemData): void { + this.viewMonth(data.item); + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.undeleteEventObserver?.off(); + this.obsDefaultTimeChange?.off(); + } + +} + +/** + * Preloaded month. + */ +type PreloadedMonth = YearAndMonth & { + loaded: boolean; // Whether the events have been loaded. + weekDays: AddonCalendarWeekDaysTranslationKeys[]; + weeks: AddonCalendarWeek[]; + needsRefresh?: boolean; // Whether the events needs to be re-loaded. +}; + +// CoreSwipeSlidesDynamicItemsManagerSource + +/** + * Helper to manage swiping within a collection of chapters. + */ +class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicItemsManagerSource { + + monthLoaded = false; + 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; + protected categoriesRetrieved = false; + + constructor(component: AddonCalendarCalendarComponent, initialMonth: YearAndMonth) { + super(initialMonth); + + this.calendarComponent = component; + } + + /** + * Fetch data. + * + * @return Promise resolved when done. + */ + async fetchData(): Promise { + await Promise.all([ + this.loadCategories(), + this.loadOfflineEvents(), + this.loadOfflineDeletedEvents(), + this.loadTimeFormat(), + ]); + } + + /** + * Load categories to be able to filter events. + * + * @return Promise resolved when done. + */ + 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. + } + } + + /** + * 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. + const filtered = events.filter((event) => event.id > 0); + this.offlineEditedEventsIds = filtered.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: YearAndMonth): string | number { + return `${item.year}#${item.monthNumber}`; + } + + /** + * @inheritdoc + */ + getPreviousItem(item: YearAndMonth): YearAndMonth | null { + return CoreTimeUtils.getPreviousMonth(item); + } + + /** + * @inheritdoc + */ + getNextItem(item: YearAndMonth): YearAndMonth | null { + return CoreTimeUtils.getNextMonth(item); + } + + /** + * @inheritdoc + */ + async loadItemData(month: YearAndMonth, preload = false): Promise { + // Check if it's already loaded. + const existingMonth = this.getItem(month); + if (existingMonth && ((existingMonth.loaded && !existingMonth.needsRefresh) || preload)) { + return existingMonth; + } + if (!preload) { this.monthLoaded = false; } @@ -286,7 +541,7 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro if (day.istoday) { day.eventsFormated?.forEach((event) => { - event.ispast = this.isEventPast(event); + event.ispast = this.calendarComponent.isEventPast(event); }); } } @@ -297,7 +552,7 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro // Merge the online events with offline data. this.mergeEvents(month, weeks); // Filter events by course. - this.filterEvents(weeks); + this.calendarComponent.filterEvents(weeks); } if (existingMonth) { @@ -306,47 +561,16 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro existingMonth.weeks = weeks; existingMonth.weekDays = weekDays; - return; + return existingMonth; } // Add the preloaded month at the right position. - const preloadedMonth: PreloadedMonth = { + return { ...month, loaded: !preload, weeks, weekDays, }; - const previousMonth = CoreTimeUtils.getPreviousMonth(month); - const nextMonth = CoreTimeUtils.getNextMonth(month); - const activeIndex = await this.slides?.getActiveIndex(); - - const added = this.preloadedMonths.some((month, index) => { - let positionToInsert = -1; - if (CoreTimeUtils.isSameMonth(month, previousMonth)) { - // Previous month found, add the month after it. - positionToInsert = index + 1; - } - - if (CoreTimeUtils.isSameMonth(month, nextMonth)) { - // Next month found, add the month before it. - positionToInsert = index; - } - - if (positionToInsert > -1) { - this.preloadedMonths.splice(positionToInsert, 0, preloadedMonth); - if (activeIndex !== undefined && positionToInsert <= activeIndex) { - // Added a slide before the active one, keep current slide. - this.slides?.slideTo(activeIndex + 1, 0, false); - } - - return true; - } - }); - - if (!added) { - // Previous and next months not found, this probably means the array is still empty. Add it at the end. - this.preloadedMonths.push(preloadedMonth); - } } finally { if (!preload) { this.monthLoaded = true; @@ -354,168 +578,13 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro } } - /** - * Load current month and preload next and previous ones. - * - * @return Promise resolved when done. - */ - async viewMonth(): Promise { - // 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.visibleMonth.year, this.visibleMonth.monthNumber - 1).getTime(), - 'core.strftimemonthyear', - ); - this.calculateIsCurrentMonth(); - - try { - // Load current month, and preload next and previous ones. - await Promise.all([ - this.loadMonth(this.visibleMonth, false), - this.loadMonth(CoreTimeUtils.getPreviousMonth(this.visibleMonth), true), - this.loadMonth(CoreTimeUtils.getNextMonth(this.visibleMonth), true), - ]); - } catch (error) { - CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); - } - } - - /** - * 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. - * - * @param weeks Weeks with the events to filter. - */ - filterEvents(weeks: AddonCalendarWeek[]): void { - 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); - }); - }); - } - - /** - * 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(): Promise { - const promises: Promise[] = []; - - promises.push(AddonCalendar.invalidateMonthlyEvents(this.visibleMonth.year, this.visibleMonth.monthNumber)); - promises.push(CoreCourses.invalidateCategories(0, true)); - promises.push(AddonCalendar.invalidateTimeFormat()); - - this.categoriesRetrieved = false; // Get categories again. - - const preloadedMonth = this.findPreloadedMonth(this.visibleMonth); - if (preloadedMonth) { - preloadedMonth.needsRefresh = true; - } - - await Promise.all(promises); - - this.fetchData(); - } - - /** - * Load next month. - */ - loadNext(): void { - this.slides?.slideNext(); - } - - /** - * Load previous month. - */ - loadPrevious(): void { - this.slides?.slidePrev(); - } - - /** - * An event was clicked. - * - * @param calendarEvent Calendar event.. - * @param event Mouse event. - */ - eventClicked(calendarEvent: AddonCalendarEventToDisplay, event: Event): void { - this.onEventClicked.emit(calendarEvent.id); - event.stopPropagation(); - } - - /** - * A day was clicked. - * - * @param day Day. - */ - dayClicked(day: number): void { - this.onDayClicked.emit({ day: day, month: this.visibleMonth.monthNumber, year: this.visibleMonth.year }); - } - - /** - * Check if user is viewing the current month. - */ - calculateIsCurrentMonth(): void { - this.currentTime = CoreTimeUtils.timestamp(); - this.isCurrentMonth = CoreTimeUtils.isCurrentMonth(this.visibleMonth); - this.isPastMonth = CoreTimeUtils.isCurrentMonth(this.visibleMonth); - } - - /** - * Go to current month. - */ - async goToCurrentMonth(): Promise { - - const now = new Date(); - const currentMonth = { - monthNumber: now.getMonth() + 1, - year: now.getFullYear(), - }; - - const index = this.preloadedMonths.findIndex((month) => CoreTimeUtils.isSameMonth(month, currentMonth)); - if (index > -1) { - this.slides?.slideTo(index); - } - } - /** * Merge online events with the offline events of that period. * * @param month Month. * @param weeks Weeks with the events to filter. */ - protected mergeEvents(month: YearAndMonth, weeks: AddonCalendarWeek[]): void { + mergeEvents(month: YearAndMonth, weeks: AddonCalendarWeek[]): void { const monthOfflineEvents: { [day: number]: AddonCalendarEventToDisplay[] } = this.offlineEvents[AddonCalendarHelper.getMonthId(month.year, month.monthNumber)]; @@ -551,86 +620,27 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro } /** - * Undelete a certain event. - * - * @param eventId Event ID. - */ - protected async undeleteEvent(eventId: number): Promise { - this.preloadedMonths.forEach((month) => { - if (!month.loaded) { - return; - } - - month.weeks.forEach((week) => { - week.days.forEach((day) => { - const event = day.eventsFormated?.find((event) => event.id == eventId); - - if (event) { - event.deleted = false; - } - }); - }); - }); - } - - /** - * Returns if the event is in the past or not. - * - * @param event Event object. - * @return True if it's in the past. - */ - protected isEventPast(event: { timestart: number; timeduration: number}): boolean { - return (event.timestart + event.timeduration) < (this.currentTime || CoreTimeUtils.timestamp()); - } - - /** - * Slide has changed. + * Invalidate content. * + * @param visibleMonth The current visible month. * @return Promise resolved when done. */ - async slideChanged(): Promise { - if (!this.slides) { - return; + async invalidateContent(visibleMonth: PreloadedMonth | null): Promise { + const promises: Promise[] = []; + + if (visibleMonth) { + promises.push(AddonCalendar.invalidateMonthlyEvents(visibleMonth.year, visibleMonth.monthNumber)); + } + promises.push(CoreCourses.invalidateCategories(0, true)); + promises.push(AddonCalendar.invalidateTimeFormat()); + + this.categoriesRetrieved = false; // Get categories again. + + if (visibleMonth) { + visibleMonth.needsRefresh = true; } - const index = await this.slides.getActiveIndex(); - const preloadedMonth = this.preloadedMonths[index]; - if (!preloadedMonth) { - return; - } - - this.visibleMonth.year = preloadedMonth.year; - this.visibleMonth.monthNumber = preloadedMonth.monthNumber; - - await this.viewMonth(); - } - - /** - * Find a certain preloaded month. - * - * @param month Month to search. - * @return Preloaded month, undefined if not found. - */ - protected findPreloadedMonth(month: YearAndMonth): PreloadedMonth | undefined { - return this.preloadedMonths.find(preloadedMonth => CoreTimeUtils.isSameMonth(month, preloadedMonth)); - } - - /** - * Component destroyed. - */ - ngOnDestroy(): void { - this.undeleteEventObserver?.off(); - this.obsDefaultTimeChange?.off(); + await Promise.all(promises); } } - -/** - * Preloaded month. - */ -type PreloadedMonth = YearAndMonth & { - loaded: boolean; // Whether the events have been loaded. - weekDays: AddonCalendarWeekDaysTranslationKeys[]; - weeks: AddonCalendarWeek[]; - needsRefresh?: boolean; // Whether the events needs to be re-loaded. -}; 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..7f2e278cd 100644 --- a/src/addons/mod/assign/pages/submission-review/submission-review.ts +++ b/src/addons/mod/assign/pages/submission-review/submission-review.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 { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; import { CoreCourse } from '@features/course/services/course'; import { CanLeave } from '@guards/can-leave'; @@ -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], ); 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 e85705f95..83787d103 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,18 +40,18 @@ previousTranslate="addon.mod_book.navprevtitle" nextTranslate="addon.mod_book.navnexttitle" (action)="changeChapter($event.id)"> - - + +
- -
+
{{ '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 34ac81fa0..4c9f35cbd 100644 --- a/src/addons/mod/book/components/index/index.ts +++ b/src/addons/mod/book/components/index/index.ts @@ -13,7 +13,6 @@ // limitations under the License. import { Component, Optional, Input, OnInit, ViewChild, ElementRef } from '@angular/core'; -import { IonContent, IonSlides } from '@ionic/angular'; import { CoreCourseModuleMainResourceComponent } from '@features/course/classes/main-resource-component'; import { AddonModBookProvider, @@ -29,10 +28,13 @@ 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 { CoreSwipeCurrentItemData, CoreSwipeSlidesComponent, CoreSwipeSlidesOptions } from '@components/swipe-slides/swipe-slides'; +import { CoreSwipeSlidesItemsManagerSource } from '@classes/items-management/slides-items-manager-source'; +import { CoreCourseModule } from '@features/course/services/course-helper'; +import { CoreSwipeSlidesItemsManager } from '@classes/items-management/slides-items-manager'; /** * Component that displays a book. @@ -40,38 +42,32 @@ import { Translate } from '@singletons'; @Component({ selector: 'addon-mod-book-index', templateUrl: 'addon-mod-book-index.html', - styleUrls: ['index.scss'], }) export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComponent implements OnInit { - @ViewChild(IonSlides) slides?: IonSlides; + @ViewChild(CoreSwipeSlidesComponent) slides?: CoreSwipeSlidesComponent; @Input() initialChapterId?: number; // The initial chapter ID to load. component = AddonModBookProvider.COMPONENT; - loadedChapters: LoadedChapter[] = []; + manager?: CoreSwipeSlidesItemsManager; previousChapter?: AddonModBookTocChapter; nextChapter?: AddonModBookTocChapter; tagsEnabled = false; warning = ''; - tags?: CoreTagItem[]; displayNavBar = true; navigationItems: CoreNavigationBarItem[] = []; displayTitlesInNavBar = false; - slidesOpts = { - initialSlide: 0, + slidesOpts: CoreSwipeSlidesOptions = { autoHeight: true, + scrollOnChange: 'top', }; - protected chapters: AddonModBookTocChapter[] = []; protected currentChapter?: number; - protected book?: AddonModBookBookWSData; - protected contentsMap: AddonModBookContentsMap = {}; protected element: HTMLElement; constructor( elementRef: ElementRef, - protected content?: IonContent, @Optional() courseContentsPage?: CoreCourseContentsPage, ) { super('AddonModBookIndexComponent', courseContentsPage); @@ -86,9 +82,25 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp super.ngOnInit(); this.tagsEnabled = CoreTag.areTagsAvailableInSite(); + const source = new AddonModBookSlidesItemsManagerSource( + this.courseId, + this.module, + this.tagsEnabled, + this.initialChapterId, + ); + this.manager = new CoreSwipeSlidesItemsManager(source); + this.loadContent(); } + get book(): AddonModBookBookWSData | undefined { + return this.manager?.getSource().book; + } + + get chapters(): AddonModBookTocChapter[] { + return this.manager?.getSource().chapters || []; + } + /** * Show the TOC. */ @@ -121,10 +133,7 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp return; } - const index = this.loadedChapters.findIndex(chapter => chapter.id === chapterId); - if (index > -1) { - this.slides?.slideTo(index); - } + this.slides?.slideToItem({ id: chapterId }); } /** @@ -132,8 +141,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(); } /** @@ -144,100 +153,35 @@ 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 index = this.chapters.findIndex((chapter) => chapter.id == this.initialChapterId); - - if (index >= 0) { - this.currentChapter = this.initialChapterId; - this.slidesOpts.initialSlide = index; - } - } - - 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; } - await this.loadChapters(); + const downloadResult = await this.downloadResourceIfNeeded(refresh); - // Show chapter. - await this.viewChapter(this.currentChapter, refresh); + const book = await source.loadBookData(); - this.warning = downloadResult?.failed ? this.getErrorDownloadingSomeFilesMessage(downloadResult.error!) : ''; + 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); } } - /** - * Load book data from WS. - * - * @return Promise resolved when done. - */ - protected async loadBookData(): Promise { - this.book = await AddonModBook.getBook(this.courseId, this.module.id); - - this.dataRetrieved.emit(this.book); - - this.description = this.book.intro; - this.displayNavBar = this.book.navstyle != AddonModBookNavStyle.TOC_ONLY; - this.displayTitlesInNavBar = this.book.navstyle == AddonModBookNavStyle.TEXT; - } - - /** - * Load book chapters. - * - * @return Promise resolved when done. - */ - protected async loadChapters(): 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.tagsEnabled ? this.contentsMap[chapter.id].tags : [], - }; - })); - - let newIndex = -1; - if (this.loadedChapters.length && newChapters.length != this.loadedChapters.length) { - // Number of chapters has changed. Search the chapter to display, otherwise it could change automatically. - newIndex = this.chapters.findIndex((chapter) => chapter.id === this.currentChapter); - } - - this.loadedChapters = newChapters; - - if (newIndex > -1) { - this.slides?.slideTo(newIndex, 0, false); - } - } catch (exception) { - const error = exception ?? new CoreError(Translate.instant('addon.mod_book.errorchapter')); - if (!error.message) { - error.message = Translate.instant('addon.mod_book.errorchapter'); - } - - throw error; - } - } - /** * View a book chapter. * @@ -271,24 +215,10 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp /** * Slide has changed. * - * @return Promise resolved when done. + * @param data Data about new item. */ - async slideChanged(): Promise { - if (!this.slides) { - return; - } - - const scrollElement = await this.content?.getScrollElement(); - const container = this.element.querySelector('.addon-mod_book-container'); - - if (container && (!scrollElement || CoreDomUtils.isElementOutsideOfScreen(scrollElement, container, 'top'))) { - // Scroll to top. - container.scrollIntoView({ behavior: 'smooth' }); - } - - const index = await this.slides.getActiveIndex(); - - this.viewChapter(this.loadedChapters[index].id, true); + slideChanged(data: CoreSwipeCurrentItemData): void { + this.viewChapter(data.item.id, true); } /** @@ -313,3 +243,90 @@ type LoadedChapter = { 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 { + // Get contents. No need to refresh, it has been done in downloadResourceIfNeeded. + 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 (exception) { + const error = exception ?? new CoreError(Translate.instant('addon.mod_book.errorchapter')); + if (!error.message) { + error.message = 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/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/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..c6b8fd275 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; + onItemsUpdated?(items: Item[]): void; onReset?(): void; } @@ -27,39 +25,19 @@ 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 hasMoreItems = true; protected listeners: CoreItemsListSourceListener[] = []; protected dirty = false; /** - * Check whether any page has been loaded. + * Check whether any item has been loaded. * - * @returns Whether any page has been loaded. + * @returns Whether any item has been loaded. */ isLoaded(): boolean { return this.items !== null; } - /** - * Check whether there are more pages to be loaded. - * - * @return Whether there are more pages to be loaded. - */ - isCompleted(): boolean { - return !this.hasMoreItems; - } - /** * Set whether the source as dirty. * @@ -80,35 +58,21 @@ 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. * @@ -136,85 +100,22 @@ 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.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..dda251c1e 100644 --- a/src/core/classes/items-management/items-manager.ts +++ b/src/core/classes/items-management/items-manager.ts @@ -12,16 +12,15 @@ // 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'; /** * 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; @@ -51,8 +50,6 @@ export abstract class CoreItemsManager = {}, - ): 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 }); + setSelectedItem(item: Item | null): void { + this.selectedItem = item; } /** @@ -166,14 +103,9 @@ export abstract class CoreItemsManager { - map[this.getSource().getItemPath(item)] = item; - - return map; - }, {}); - - this.updateSelectedItem(); + // Nothing to do. } /** diff --git a/src/core/classes/items-management/list-items-manager.ts b/src/core/classes/items-management/list-items-manager.ts index baacfdefc..9229b1de4 100644 --- a/src/core/classes/items-management/list-items-manager.ts +++ b/src/core/classes/items-management/list-items-manager.ts @@ -20,16 +20,16 @@ import { CoreNavigator } from '@services/navigator'; import { CoreScreen } from '@services/screen'; import { CoreUtils } from '@services/utils/utils'; -import { CoreItemsManager } from './items-manager'; -import { CoreItemsManagerSource } from './items-manager-source'; +import { CoreRoutedItemsManagerSource } from './routed-items-manager-source'; +import { CoreRoutedItemsManager } from './routed-items-manager'; /** * Helper class to manage the state and routing of a list of items in a page. */ export class CoreListItemsManager< Item = unknown, - Source extends CoreItemsManagerSource = 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..fe8aa8d38 --- /dev/null +++ b/src/core/classes/items-management/routed-items-manager.ts @@ -0,0 +1,135 @@ +// (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 { + this.itemsMap = items.reduce((map, item) => { + map[this.getSource().getItemPath(item)] = item; + + return map; + }, {}); + + this.updateSelectedItem(); + } + +} diff --git a/src/core/classes/items-management/slides-dynamic-items-manager-source.ts b/src/core/classes/items-management/slides-dynamic-items-manager-source.ts new file mode 100644 index 000000000..9422f7514 --- /dev/null +++ b/src/core/classes/items-management/slides-dynamic-items-manager-source.ts @@ -0,0 +1,141 @@ +// (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 './slides-items-manager-source'; + +/** + * Items collection source data for "swipe slides". + */ +export abstract class CoreSwipeSlidesDynamicItemsManagerSource extends CoreSwipeSlidesItemsManagerSource { + + /** + * @inheritdoc + */ + async load(): Promise { + if (this.initialItem) { + // Load the initial item. + await this.loadItem(this.initialItem); + } + + this.setInitialized(); + } + + /** + * @inheritdoc + */ + protected async loadItems(): Promise { + // Not used in dynamic slides. + return []; + } + + /** + * Load a certain item and preload next and previous ones. + * + * @param item Basic data about the item to load. + * @return Promise resolved when done. + */ + async loadItem(item: Partial): Promise { + const previousItem = this.getPreviousItem(item); + const nextItem = this.getNextItem(item); + + await Promise.all([ + this.loadItemInList(item, false), + previousItem ? this.loadItemInList(previousItem, true) : undefined, + nextItem ? this.loadItemInList(nextItem, true) : undefined, + ]); + } + + /** + * Load or preload a certain item and add it to the list. + * + * @param item Basic data about the item to load. + * @param preload Whether to preload. + * @return Promise resolved when done. + */ + async loadItemInList(item: Partial, preload = false): Promise { + const preloadedItem = await this.loadItemData(item, preload); + if (!preloadedItem) { + return; + } + + // Add the item at the right position. + const existingItem = this.getItem(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 positionToInsert = -1; + if (itemId === previousItemId) { + // Previous item found, add the item after it. + positionToInsert = index + 1; + } + + if (itemId === nextItemId) { + // Next item found, add the item before it. + positionToInsert = index; + } + + if (positionToInsert > -1) { + this.items?.splice(positionToInsert, 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. + * + * @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: Partial, 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: Partial): Partial | 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: Partial): Partial | null; + +} diff --git a/src/core/classes/items-management/slides-dynamic-items-manager.ts b/src/core/classes/items-management/slides-dynamic-items-manager.ts new file mode 100644 index 000000000..cacae1282 --- /dev/null +++ b/src/core/classes/items-management/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 { CoreSwipeSlidesDynamicItemsManagerSource } from './slides-dynamic-items-manager-source'; +import { CoreSwipeSlidesItemsManager } from './slides-items-manager'; + +/** + * Helper class to manage items for core-swipe-slides. + */ +export class CoreSwipeSlidesDynamicItemsManager< + Item = unknown, + 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/slides-items-manager-source.ts b/src/core/classes/items-management/slides-items-manager-source.ts new file mode 100644 index 000000000..e45c9d5b9 --- /dev/null +++ b/src/core/classes/items-management/slides-items-manager-source.ts @@ -0,0 +1,121 @@ +// (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?: Partial; + protected initialized = false; + protected initializePromise: Promise; + protected resolveInitialize!: () => void; + + constructor(initialItem?: Partial) { + super(); + + this.initialItem = initialItem; + this.initializePromise = new Promise(resolve => this.resolveInitialize = resolve); + } + + /** + * @inheritdoc + */ + isLoaded(): boolean { + return this.initialized && super.isLoaded(); + } + + /** + * Return a promise that is resolved when the source is initialized. + * + * @return Promise. + */ + waitForInitialized(): Promise { + return this.initializePromise; + } + + /** + * Mark the source as initialized. + */ + protected setInitialized(): void { + this.initialized = true; + this.resolveInitialize(); + } + + /** + * @inheritdoc + */ + async load(): Promise { + const items = await this.loadItems(); + + this.setItems(items); + this.setInitialized(); + } + + /** + * Load items. + * + * @return Items list. + */ + protected abstract loadItems(): Promise; + + /** + * Get a certain item. + * + * @param item Partial data about the item to search. + * @return Item, null if not found. + */ + getItem(item: Partial): Item | null { + const index = this.getItemPosition(item); + + return this.items?.[index] ?? null; + } + + /** + * Get a certain item position. + * + * @param item Item to search. + * @return Item position, -1 if not found. + */ + getItemPosition(item: Partial): number { + const itemId = this.getItemId(item); + const index = this.items?.findIndex((listItem) => itemId === this.getItemId(listItem)); + + return index ?? -1; + } + + /** + * Get initial item position. + * + * @return Initial item position. + */ + getInitialPosition(): number { + if (!this.initialItem) { + return 0; + } + + return this.getItemPosition(this.initialItem); + } + + /** + * Get the ID of an item. + * + * @param item Data about the item. + * @return Item ID. + */ + abstract getItemId(item: Partial): string | number; + +} diff --git a/src/core/classes/items-management/slides-items-manager.ts b/src/core/classes/items-management/slides-items-manager.ts new file mode 100644 index 000000000..8c01ef571 --- /dev/null +++ b/src/core/classes/items-management/slides-items-manager.ts @@ -0,0 +1,47 @@ +// (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 './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 + */ + protected onSourceItemsUpdated(items: Item[]): void { + this.itemsMap = items.reduce((map, item) => { + map[this.getSource().getItemId(item)] = item; + + return map; + }, {}); + } + + /** + * Get item by ID. + * + * @param id ID + * @return Item, null if not found. + */ + getItemById(id: string): Item | null { + return this.itemsMap?.[id] ?? null; + } + +} diff --git a/src/core/classes/items-management/swipe-items-manager.ts b/src/core/classes/items-management/swipe-items-manager.ts index 918cab741..0290206ef 100644 --- a/src/core/classes/items-management/swipe-items-manager.ts +++ b/src/core/classes/items-management/swipe-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< Item = unknown, - Source extends CoreItemsManagerSource = CoreItemsManagerSource + Source extends CoreRoutedItemsManagerSource = CoreRoutedItemsManagerSource > - extends CoreItemsManager { + extends CoreRoutedItemsManager { /** * Process page started operations. 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-slides/swipe-slides.html b/src/core/components/swipe-slides/swipe-slides.html new file mode 100644 index 000000000..461366344 --- /dev/null +++ b/src/core/components/swipe-slides/swipe-slides.html @@ -0,0 +1,6 @@ + + + + + diff --git a/src/addons/mod/book/components/index/index.scss b/src/core/components/swipe-slides/swipe-slides.scss similarity index 100% rename from src/addons/mod/book/components/index/index.scss rename to src/core/components/swipe-slides/swipe-slides.scss 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..ac88f6e8e --- /dev/null +++ b/src/core/components/swipe-slides/swipe-slides.ts @@ -0,0 +1,263 @@ +// (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/slides-items-manager'; +import { IonContent, IonSlides } from '@ionic/angular'; +import { CoreDomUtils } from '@services/utils/dom'; + +/** + * 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() onInit = new EventEmitter>(); + @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); + } + } + + /** + * 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 position 0, and to prevent auto scroll on init. + this.options.runCallbacksOnInit = false; + + await manager.getSource().waitForInitialized(); + + if (this.options.initialSlide === undefined) { + // Calculate the initial slide. + const position = manager.getSource().getInitialPosition(); + this.options.initialSlide = position > - 1 ? position : 0; + } + + // Emit change events with the initial item. + const items = manager.getSource().getItems(); + if (!items || !items.length) { + return; + } + + // Validate that the initial position is inside the valid range. + let initialPosition = this.options.initialSlide as number; + if (initialPosition < 0) { + initialPosition = 0; + } else if (initialPosition >= items.length) { + initialPosition = items.length - 1; + } + + const initialItemData = { + index: initialPosition, + item: items[initialPosition], + }; + + manager.setSelectedItem(items[initialPosition]); + this.onWillChange.emit(initialItemData); + this.onDidChange.emit(initialItemData); + } + + /** + * Slide to a certain position. + * + * @param index Position. + * @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: Partial, speed?: number, runCallbacks?: boolean): void { + const index = this.manager?.getSource().getItemPosition(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); + } + + /** + * Get current item. + * + * @return Current item. Undefined if no current item yet. + */ + getCurrentItem(): Item | null { + return this.manager?.getSelectedItem() || null; + } + + /** + * Called when items list has been updated. + * + * @param items New items. + */ + protected onItemsUpdated(): void { + const currentItem = this.getCurrentItem(); + + if (!currentItem || !this.manager) { + return; + } + + // Keep the same slide in case the list has changed. + const newIndex = this.manager.getSource().getItemPosition(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, '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..ee7c6e39c 100644 --- a/src/core/features/user/pages/profile/profile.page.ts +++ b/src/core/features/user/pages/profile/profile.page.ts @@ -29,7 +29,7 @@ import { CoreNavigator } from '@services/navigator'; import { CoreCourses } from '@features/courses/services/courses'; import { CoreSwipeItemsManager } from '@classes/items-management/swipe-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(); From d527695977bf58f1ed5f4417be4c96dd6a87c64c Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 10 Dec 2021 14:50:55 +0100 Subject: [PATCH 4/5] MOBILE-3927 calendar: Add swipe to calendar daily view --- .../calendar/components/calendar/calendar.ts | 136 ++- src/addons/calendar/pages/day/day.html | 132 +-- src/addons/calendar/pages/day/day.page.ts | 781 ++++++++++-------- src/addons/calendar/pages/day/day.scss | 4 + src/addons/calendar/pages/index/index.page.ts | 16 +- .../calendar/services/calendar-helper.ts | 26 +- .../slides-dynamic-items-manager-source.ts | 84 +- .../slides-dynamic-items-manager.ts | 4 +- .../components/swipe-slides/swipe-slides.scss | 6 + src/core/services/utils/time.ts | 121 ++- 10 files changed, 800 insertions(+), 510 deletions(-) diff --git a/src/addons/calendar/components/calendar/calendar.ts b/src/addons/calendar/components/calendar/calendar.ts index 6a0f3a4c5..36614d3d5 100644 --- a/src/addons/calendar/components/calendar/calendar.ts +++ b/src/addons/calendar/components/calendar/calendar.ts @@ -43,7 +43,10 @@ import { CoreCategoryData, CoreCourses } from '@features/courses/services/course import { CoreApp } from '@services/app'; import { CoreLocalNotifications } from '@services/local-notifications'; import { CoreSwipeCurrentItemData, CoreSwipeSlidesComponent } from '@components/swipe-slides/swipe-slides'; -import { CoreSwipeSlidesDynamicItemsManagerSource } from '@classes/items-management/slides-dynamic-items-manager-source'; +import { + CoreSwipeSlidesDynamicItem, + CoreSwipeSlidesDynamicItemsManagerSource, +} from '@classes/items-management/slides-dynamic-items-manager-source'; import { CoreSwipeSlidesDynamicItemsManager } from '@classes/items-management/slides-dynamic-items-manager'; /** @@ -113,8 +116,8 @@ 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.manager?.getSource().deletedEvents.indexOf(data.eventId); - if (index !== undefined && index != -1) { + const index = this.manager?.getSource().deletedEvents.indexOf(data.eventId) ?? -1; + if (index != -1) { this.manager?.getSource().deletedEvents.splice(index, 1); } }, @@ -128,15 +131,15 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro * Component loaded. */ ngOnInit(): void { - const now = new Date(); + const currentMonth = CoreTimeUtils.getCurrentMonth(); this.canNavigate = typeof this.canNavigate == 'undefined' ? true : CoreUtils.isTrueOrOne(this.canNavigate); this.displayNavButtons = typeof this.displayNavButtons == 'undefined' ? true : CoreUtils.isTrueOrOne(this.displayNavButtons); const source = new AddonCalendarMonthSlidesItemsManagerSource(this, { - year: this.initialYear ?? now.getFullYear(), - monthNumber: this.initialMonth ?? now.getMonth() + 1, + year: this.initialYear ?? currentMonth.year, + monthNumber: this.initialMonth ?? currentMonth.monthNumber, }); this.manager = new CoreSwipeSlidesDynamicItemsManager(source); @@ -157,7 +160,7 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro if (changes) { items.forEach((month) => { if (month.loaded) { - this.filterEvents(month.weeks); + this.manager?.getSource().filterEvents(month.weeks, this.filter); } }); } @@ -177,7 +180,7 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro try { await this.manager?.getSource().fetchData(); - await this.manager?.getSource().load(); + await this.manager?.getSource().load(this.manager?.getSelectedItem()); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); } @@ -186,11 +189,9 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro } /** - * Load current month and preload next and previous ones. - * - * @return Promise resolved when done. + * Update data related to month being viewed. */ - async viewMonth(month: YearAndMonth): Promise { + viewMonth(month: YearAndMonth): 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(month.year, month.monthNumber - 1).getTime(), @@ -199,35 +200,19 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro this.calculateIsCurrentMonth(); } - /** - * Filter events based on the filter popover. - * - * @param weeks Weeks with the events to filter. - */ - filterEvents(weeks: AddonCalendarWeek[]): void { - weeks.forEach((week) => { - week.days.forEach((day) => { - day.filteredEvents = AddonCalendarHelper.getFilteredEvents( - day.eventsFormated || [], - this.filter, - this.manager?.getSource().categories || {}, - ); - - // Re-calculate some properties. - AddonCalendarHelper.calculateDayData(day, day.filteredEvents); - }); - }); - } - /** * Refresh events. * * @param afterChange Whether the refresh is done after an event has changed or has been synced. * @return Promise resolved when done. */ - async refreshData(): Promise { + async refreshData(afterChange = false): Promise { const visibleMonth = this.slides?.getCurrentItem() || null; + if (afterChange) { + this.manager?.getSource().markAllItemsDirty(); + } + await this.manager?.getSource().invalidateContent(visibleMonth); await this.fetchData(); @@ -282,8 +267,8 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro } this.currentTime = CoreTimeUtils.timestamp(); - this.isCurrentMonth = CoreTimeUtils.isCurrentMonth(visibleMonth); - this.isPastMonth = CoreTimeUtils.isCurrentMonth(visibleMonth); + this.isCurrentMonth = CoreTimeUtils.isSameMonth(visibleMonth, CoreTimeUtils.getCurrentMonth()); + this.isPastMonth = CoreTimeUtils.isPastMonth(visibleMonth); } /** @@ -305,20 +290,22 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro * @param eventId Event ID. */ protected async undeleteEvent(eventId: number): Promise { - this.manager?.getSource().getItems()?.forEach((month) => { + this.manager?.getSource().getItems()?.some((month) => { if (!month.loaded) { - return; + return false; } - month.weeks.forEach((week) => { - week.days.forEach((day) => { - const event = day.eventsFormated?.find((event) => event.id == eventId); + return month.weeks.some((week) => week.days.some((day) => { + const event = day.eventsFormated?.find((event) => event.id == eventId); - if (event) { - event.deleted = false; - } - }); - }); + if (event) { + event.deleted = false; + + return true; + } + + return false; + })); }); } @@ -354,17 +341,13 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro /** * Preloaded month. */ -type PreloadedMonth = YearAndMonth & { - loaded: boolean; // Whether the events have been loaded. +type PreloadedMonth = YearAndMonth & CoreSwipeSlidesDynamicItem & { weekDays: AddonCalendarWeekDaysTranslationKeys[]; weeks: AddonCalendarWeek[]; - needsRefresh?: boolean; // Whether the events needs to be re-loaded. }; -// CoreSwipeSlidesDynamicItemsManagerSource - /** - * Helper to manage swiping within a collection of chapters. + * Helper to manage swiping within months. */ class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicItemsManagerSource { @@ -399,6 +382,27 @@ class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicI ]); } + /** + * 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. * @@ -437,8 +441,7 @@ class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicI 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); + this.offlineEditedEventsIds = events.filter((event) => event.id > 0).map((event) => event.id); } /** @@ -463,7 +466,7 @@ class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicI * @inheritdoc */ getItemId(item: YearAndMonth): string | number { - return `${item.year}#${item.monthNumber}`; + return AddonCalendarHelper.getMonthId(item); } /** @@ -484,12 +487,6 @@ class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicI * @inheritdoc */ async loadItemData(month: YearAndMonth, preload = false): Promise { - // Check if it's already loaded. - const existingMonth = this.getItem(month); - if (existingMonth && ((existingMonth.loaded && !existingMonth.needsRefresh) || preload)) { - return existingMonth; - } - if (!preload) { this.monthLoaded = false; } @@ -515,7 +512,7 @@ class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicI const weekDays = AddonCalendar.getWeekDays(result.daynames[0].dayno); const weeks = result.weeks as AddonCalendarWeek[]; - const isCurrentMonth = CoreTimeUtils.isCurrentMonth(month); + const isCurrentMonth = CoreTimeUtils.isSameMonth(month, CoreTimeUtils.getCurrentMonth()); const currentDay = new Date().getDate(); let isPast = true; @@ -552,22 +549,11 @@ class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicI // Merge the online events with offline data. this.mergeEvents(month, weeks); // Filter events by course. - this.calendarComponent.filterEvents(weeks); + this.filterEvents(weeks, this.calendarComponent.filter); } - if (existingMonth) { - // Month already exists, update it. - existingMonth.loaded = !preload; - existingMonth.weeks = weeks; - existingMonth.weekDays = weekDays; - - return existingMonth; - } - - // Add the preloaded month at the right position. return { ...month, - loaded: !preload, weeks, weekDays, }; @@ -586,7 +572,7 @@ class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicI */ mergeEvents(month: YearAndMonth, weeks: AddonCalendarWeek[]): void { const monthOfflineEvents: { [day: number]: AddonCalendarEventToDisplay[] } = - this.offlineEvents[AddonCalendarHelper.getMonthId(month.year, month.monthNumber)]; + this.offlineEvents[AddonCalendarHelper.getMonthId(month)]; weeks.forEach((week) => { week.days.forEach((day) => { @@ -637,7 +623,7 @@ class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicI this.categoriesRetrieved = false; // Get categories again. if (visibleMonth) { - visibleMonth.needsRefresh = true; + visibleMonth.dirty = true; } await Promise.all(promises); diff --git a/src/addons/calendar/pages/day/day.html b/src/addons/calendar/pages/day/day.html index ef0fe2306..28b8bf934 100644 --- a/src/addons/calendar/pages/day/day.html +++ b/src/addons/calendar/pages/day/day.html @@ -14,7 +14,7 @@ - @@ -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..86039c369 100644 --- a/src/addons/calendar/pages/day/day.page.ts +++ b/src/addons/calendar/pages/day/day.page.ts @@ -12,14 +12,14 @@ // 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'; import { CoreLocalNotifications } from '@services/local-notifications'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; -import { CoreTimeUtils } from '@services/utils/time'; +import { CoreTimeUtils, YearMonthDay } from '@services/utils/time'; import { AddonCalendarProvider, AddonCalendar, @@ -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/slides-dynamic-items-manager'; +import { CoreSwipeCurrentItemData, CoreSwipeSlidesComponent } from '@components/swipe-slides/swipe-slides'; +import { + CoreSwipeSlidesDynamicItem, + CoreSwipeSlidesDynamicItemsManagerSource, +} from '@classes/items-management/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; @@ -79,16 +74,11 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { protected filterChangedObserver: CoreEventObserver; 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,16 @@ 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 visibleDay = this.slides?.getCurrentItem(); + if (data && (data.source != 'day' || !visibleDay || data.day === undefined || data.year != visibleDay.year || + data.month != visibleDay.monthNumber || data.day != visibleDay.dayNumber)) { + this.manager?.getSource().markAllItemsUnloaded(); this.refreshData(false, true); } }, this.currentSiteId); @@ -160,10 +154,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 +172,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 +183,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 +214,27 @@ 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(); + const currentDay = CoreTimeUtils.getCurrentDay(); + const source = new AddonCalendarDaySlidesItemsManagerSource(this, { + year: CoreNavigator.getRouteNumberParam('year') ?? currentDay.year, + monthNumber: CoreNavigator.getRouteNumberParam('month') ?? currentDay.monthNumber, + dayNumber: CoreNavigator.getRouteNumberParam('day') ?? currentDay.dayNumber, + }); + this.manager = new CoreSwipeSlidesDynamicItemsManager(source); - this.calculateCurrentMoment(); this.calculateIsCurrentDay(); 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 +243,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 +251,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 +263,14 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { } /** - * Fetch the events for current day. - * - * @return Promise resolved when done. + * Update data related to day being 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. + viewDay(day: YearMonthDay): void { this.periodName = CoreTimeUtils.userDate( - new Date(this.year, this.month - 1, this.day).getTime(), + new Date(day.year, day.monthNumber - 1, day.dayNumber).getTime(), '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 +301,12 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { async refreshData(sync?: boolean, afterChange?: boolean): Promise { this.syncIcon = CoreConstants.ICON_LOADING; - const promises: Promise[] = []; + const visibleDay = this.slides?.getCurrentItem() || 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(visibleDay, !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 +325,16 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { if (result.updated) { // Trigger a manual sync event. + const visibleDay = this.slides?.getCurrentItem(); result.source = 'day'; - result.day = this.day; - result.month = this.month; - result.year = this.year; + if (visibleDay) { + result.day = visibleDay.dayNumber; + result.month = visibleDay.monthNumber; + result.year = visibleDay.year; + } + + this.manager?.getSource().markAllItemsUnloaded(); CoreEvents.trigger(AddonCalendarSyncProvider.MANUAL_SYNCED, result, this.currentSiteId); } } catch (error) { @@ -533,7 +362,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 +380,12 @@ 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 visibleDay = this.slides?.getCurrentItem(); + if (visibleDay) { + params.timestamp = moment().year(visibleDay.year).month(visibleDay.monthNumber - 1).date(visibleDay.dayNumber) + .unix() * 1000; + } } if (this.filter.courseId) { @@ -561,141 +395,76 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { CoreNavigator.navigateToSitePath(`/calendar/edit/${eventId}`, { params }); } - /** - * Calculate current moment. - */ - calculateCurrentMoment(): void { - this.currentMoment = moment().year(this.year).month(this.month - 1).date(this.day); - } - /** * Check if user is viewing the current day. */ calculateIsCurrentDay(): void { - const now = new Date(); + const visibleDay = this.slides?.getCurrentItem(); + if (!visibleDay) { + return; + } - this.currentTime = CoreTimeUtils.timestamp(); + this.isCurrentDay = visibleDay.isCurrentDay; + } - 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()); + /** + * Check whether visible day has offline data. + * + * @return Whether visible day has offline data. + */ + visibleDayHasOffline(): boolean { + const visibleDay = this.slides?.getCurrentItem(); + + return !!visibleDay && visibleDay.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 = CoreTimeUtils.getCurrentDay(); this.loaded = false; try { - await this.fetchEvents(); + // Make sure the day is loaded. + await manager.getSource().loadItem(currentDay); + slides.slideToItem(currentDay); this.isCurrentDay = true; } 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; + this.slides?.slidePrev(); } /** - * 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. + * Slide has changed. * - * @param eventId Event ID. - * @param deleted Whether to mark it as deleted or not. - * @return Whether the event was found. + * @param data Data about new item. */ - 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; + slideChanged(data: CoreSwipeCurrentItemData): void { + this.viewDay(data.item); } /** @@ -715,3 +484,359 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { } } + +/** + * Preloaded month. + */ +type PreloadedDay = YearMonthDay & 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: number[] = []; // Events deleted in offline. + timeFormat?: string; + canCreate = false; + + protected dayPage: AddonCalendarDayPage; + protected categoriesRetrieved = false; + + constructor(page: AddonCalendarDayPage, initialDay: YearMonthDay) { + super(initialDay); + + 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.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. + } + } + + /** + * 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 { + this.deletedEvents = await AddonCalendarOffline.getAllDeletedEventsIds(); + } + + /** + * Load time format. + * + * @return Promise resolved when done. + */ + async loadTimeFormat(): Promise { + this.timeFormat = await AddonCalendar.getCalendarTimeFormat(); + } + + /** + * @inheritdoc + */ + getItemId(item: YearMonthDay): string | number { + return AddonCalendarHelper.getDayId(item); + } + + /** + * @inheritdoc + */ + getPreviousItem(item: YearMonthDay): YearMonthDay | null { + return CoreTimeUtils.getPreviousDay(item); + } + + /** + * @inheritdoc + */ + getNextItem(item: YearMonthDay): YearMonthDay | null { + return CoreTimeUtils.getNextDay(item); + } + + /** + * @inheritdoc + */ + async loadItemData(day: YearMonthDay, preload = false): Promise { + const preloadedDay: PreloadedDay = { + ...day, + hasOffline: false, + events: [], + onlineEvents: [], + filteredEvents: [], + isCurrentDay: CoreTimeUtils.isSameDay(day, CoreTimeUtils.getCurrentDay()), + isPastDay: CoreTimeUtils.isPastDay(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.year, day.monthNumber, day.dayNumber); + 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 = moment().year(day.year).month(day.monthNumber - 1).date(day.dayNumber).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.length) { + // No offline events, nothing to merge. + return day.onlineEvents; + } + + const monthOfflineEvents = this.offlineEvents[AddonCalendarHelper.getMonthId(day)]; + const dayOfflineEvents = monthOfflineEvents && monthOfflineEvents[day.dayNumber]; + let result = day.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) { + 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 visibleDay The current visible day. + * @param invalidateDayEvents Whether to invalidate visible day events. + * @return Promise resolved when done. + */ + async invalidateContent(visibleDay: PreloadedDay | null, invalidateDayEvents?: boolean): Promise { + const promises: Promise[] = []; + + if (invalidateDayEvents && visibleDay) { + promises.push(AddonCalendar.invalidateDayEvents(visibleDay.year, visibleDay.monthNumber, visibleDay.dayNumber)); + } + promises.push(AddonCalendar.invalidateAllowedEventTypes()); + promises.push(CoreCourses.invalidateCategories(0, true)); + promises.push(AddonCalendar.invalidateTimeFormat()); + + this.categoriesRetrieved = false; // Get categories again. + + if (visibleDay) { + visibleDay.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) { + if (!this.deletedEvents.includes(eventId)) { + this.deletedEvents.push(eventId); + } + } else { + const index = this.deletedEvents.indexOf(eventId); + if (index != -1) { + this.deletedEvents.splice(index, 1); + } + } + } + +} 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..74f588a7b 100644 --- a/src/addons/calendar/services/calendar-helper.ts +++ b/src/addons/calendar/services/calendar-helper.ts @@ -37,6 +37,7 @@ import { AddonCalendarSyncInvalidateEvent } from './calendar-sync'; import { AddonCalendarOfflineEventDBRecord } from './database/calendar-offline'; import { CoreCategoryData } from '@features/courses/services/courses'; import { AddonCalendarReminderDBRecord } from './database/calendar'; +import { YearAndMonth, YearMonthDay } from '@services/utils/time'; /** * Context levels enumeration. @@ -140,7 +141,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({ year: treatedDay.year(), monthNumber: treatedDay.month() + 1 }); const day = treatedDay.date(); if (!result[monthId]) { @@ -364,14 +365,23 @@ export class AddonCalendarHelperProvider { } /** - * Get the month "id" (year + month). + * Get the month "id". * - * @param year Year. - * @param month Month. + * @param month Month data. * @return The "id". */ - getMonthId(year: number, month: number): string { - return year + '#' + month; + getMonthId(month: YearAndMonth): string { + return `${month.year}#${month.monthNumber}`; + } + + /** + * Get the day "id". + * + * @param day Day data. + * @return The "id". + */ + getDayId(day: YearMonthDay): string { + return `${day.year}#${day.monthNumber}#${day.dayNumber}`; } /** @@ -650,7 +660,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({ year: day.year(), monthNumber: day.month() + 1 }); if (!treatedMonths[monthId]) { // Month not refetch or invalidated already, do it now. treatedMonths[monthId] = true; @@ -686,7 +696,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({ year: day.year(), monthNumber: day.month() + 1 }); if (!treatedMonths[monthId]) { // Month not refetch or invalidated already, do it now. treatedMonths[monthId] = true; diff --git a/src/core/classes/items-management/slides-dynamic-items-manager-source.ts b/src/core/classes/items-management/slides-dynamic-items-manager-source.ts index 9422f7514..a7324ebbc 100644 --- a/src/core/classes/items-management/slides-dynamic-items-manager-source.ts +++ b/src/core/classes/items-management/slides-dynamic-items-manager-source.ts @@ -17,15 +17,22 @@ import { CoreSwipeSlidesItemsManagerSource } from './slides-items-manager-source /** * Items collection source data for "swipe slides". */ -export abstract class CoreSwipeSlidesDynamicItemsManagerSource extends CoreSwipeSlidesItemsManagerSource { +export abstract class CoreSwipeSlidesDynamicItemsManagerSource + extends CoreSwipeSlidesItemsManagerSource { + + // Items being loaded, to prevent loading them twice. + protected loadingItems: Record = {}; /** * @inheritdoc */ - async load(): Promise { - if (this.initialItem) { + async load(currentItem?: Partial | null): Promise { + if (!this.initialized && this.initialItem) { // Load the initial item. await this.loadItem(this.initialItem); + } else if (this.initialized && currentItem) { + // Reload current item if needed. + await this.loadItem(currentItem); } this.setInitialized(); @@ -64,7 +71,7 @@ export abstract class CoreSwipeSlidesDynamicItemsManagerSource e * @return Promise resolved when done. */ async loadItemInList(item: Partial, preload = false): Promise { - const preloadedItem = await this.loadItemData(item, preload); + const preloadedItem = await this.performLoadItemData(item, preload); if (!preloadedItem) { return; } @@ -113,6 +120,70 @@ export abstract class CoreSwipeSlidesDynamicItemsManagerSource e 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 Basic data about the 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: Partial, preload: boolean): Promise { + const itemId = this.getItemId(item); + + if (!itemId || this.loadingItems[itemId]) { + // Not valid or already loading, ignore it. + return null; + } + + const existingItem = this.getItem(item); + 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. * @@ -139,3 +210,8 @@ export abstract class CoreSwipeSlidesDynamicItemsManagerSource e abstract getNextItem(item: Partial): Partial | 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/slides-dynamic-items-manager.ts b/src/core/classes/items-management/slides-dynamic-items-manager.ts index cacae1282..718e71389 100644 --- a/src/core/classes/items-management/slides-dynamic-items-manager.ts +++ b/src/core/classes/items-management/slides-dynamic-items-manager.ts @@ -12,14 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { CoreSwipeSlidesDynamicItemsManagerSource } from './slides-dynamic-items-manager-source'; +import { CoreSwipeSlidesDynamicItem, CoreSwipeSlidesDynamicItemsManagerSource } from './slides-dynamic-items-manager-source'; import { CoreSwipeSlidesItemsManager } from './slides-items-manager'; /** * Helper class to manage items for core-swipe-slides. */ export class CoreSwipeSlidesDynamicItemsManager< - Item = unknown, + Item extends CoreSwipeSlidesDynamicItem, Source extends CoreSwipeSlidesDynamicItemsManagerSource = CoreSwipeSlidesDynamicItemsManagerSource, > extends CoreSwipeSlidesItemsManager { diff --git a/src/core/components/swipe-slides/swipe-slides.scss b/src/core/components/swipe-slides/swipe-slides.scss index 6b0b7043d..71cf2f9f6 100644 --- a/src/core/components/swipe-slides/swipe-slides.scss +++ b/src/core/components/swipe-slides/swipe-slides.scss @@ -1,4 +1,10 @@ :host { + --swipe-slides-min-height: auto; + + ion-slides { + min-height: var(--swipe-slides-min-height); + } + ion-slide { display: block; font-size: inherit; diff --git a/src/core/services/utils/time.ts b/src/core/services/utils/time.ts index cd9c449f3..5c8e420de 100644 --- a/src/core/services/utils/time.ts +++ b/src/core/services/utils/time.ts @@ -396,16 +396,12 @@ export class CoreTimeUtilsProvider { * @return Previous month and its year. */ getPreviousMonth(month: YearAndMonth): YearAndMonth { - if (month.monthNumber === 1) { - return { - monthNumber: 12, - year: month.year - 1, - }; - } + const dayMoment = moment().year(month.year).month(month.monthNumber - 1); + dayMoment.subtract(1, 'month'); return { - monthNumber: month.monthNumber - 1, - year: month.year, + year: dayMoment.year(), + monthNumber: dayMoment.month() + 1, }; } @@ -416,29 +412,27 @@ export class CoreTimeUtilsProvider { * @return Next month and its year. */ getNextMonth(month: YearAndMonth): YearAndMonth { - if (month.monthNumber === 12) { - return { - monthNumber: 1, - year: month.year + 1, - }; - } + const dayMoment = moment().year(month.year).month(month.monthNumber - 1); + dayMoment.add(1, 'month'); return { - monthNumber: month.monthNumber + 1, - year: month.year, + year: dayMoment.year(), + monthNumber: dayMoment.month() + 1, }; } /** - * Check if a certain month is current month. + * Get current month. * - * @param month Year and month. - * @return Whether it's current month. + * @return Current month. */ - isCurrentMonth(month: YearAndMonth): boolean { + getCurrentMonth(): YearAndMonth { const now = new Date(); - return month.year == now.getFullYear() && month.monthNumber == now.getMonth() + 1; + return { + year: now.getFullYear(), + monthNumber: now.getMonth() + 1, + }; } /** @@ -454,8 +448,6 @@ export class CoreTimeUtilsProvider { } /** - * Check if two months are the same. - * * @param monthA Month A. * @param monthB Month B. * @return Whether it's same month. @@ -464,11 +456,94 @@ export class CoreTimeUtilsProvider { return monthA.monthNumber === monthB.monthNumber && monthA.year === monthB.year; } + /** + * Given a day data, return the previous day. + * + * @param day Day. + * @return Previous day. + */ + getPreviousDay(day: YearMonthDay): YearMonthDay { + const dayMoment = moment().year(day.year).month(day.monthNumber - 1).date(day.dayNumber); + dayMoment.subtract(1, 'day'); + + return { + year: dayMoment.year(), + monthNumber: dayMoment.month() + 1, + dayNumber: dayMoment.date(), + }; + } + + /** + * Given a day data, return the next day. + * + * @param day Day. + * @return Next day. + */ + getNextDay(day: YearMonthDay): YearMonthDay { + const dayMoment = moment().year(day.year).month(day.monthNumber - 1).date(day.dayNumber); + dayMoment.add(1, 'day'); + + return { + year: dayMoment.year(), + monthNumber: dayMoment.month() + 1, + dayNumber: dayMoment.date(), + }; + } + + /** + * Get current day. + * + * @return Current day. + */ + getCurrentDay(): YearMonthDay { + const now = new Date(); + + return { + year: now.getFullYear(), + monthNumber: now.getMonth() + 1, + dayNumber: now.getDate(), + }; + } + + /** + * Check if a certain day is a past day. + * + * @param day Day. + * @return Whether it's a past day. + */ + isPastDay(day: YearMonthDay): boolean { + const now = new Date(); + + return day.year < now.getFullYear() || (day.year === now.getFullYear() && day.monthNumber < now.getMonth() + 1) || + (day.year === now.getFullYear() && day.monthNumber === now.getMonth() + 1 && day.dayNumber < now.getDate()); + } + + /** + * Check if two days are the same. + * + * @param dayA Day A. + * @param dayB Day B. + * @return Whether it's same day. + */ + isSameDay(dayA: YearMonthDay, dayB: YearMonthDay): boolean { + return dayA.dayNumber === dayB.dayNumber && dayA.monthNumber === dayB.monthNumber && dayA.year === dayB.year; + } + } export const CoreTimeUtils = makeSingleton(CoreTimeUtilsProvider); +/** + * Data to identify a month. + */ export type YearAndMonth = { year: number; // Year number. monthNumber: number; // Month number (1 to 12). }; + +/** + * Data to identify a day. + */ +export type YearMonthDay = YearAndMonth & { + dayNumber: number; // Day number. +}; From 372c5920a7b2c53ed03cf8a76eb63067cb999134 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 16 Dec 2021 12:29:13 +0100 Subject: [PATCH 5/5] MOBILE-3927 swipe: Apply IR changes --- .../pages/issued-badge/issued-badge.page.ts | 6 +- .../calendar/addon-calendar-calendar.html | 16 +- .../calendar/components/calendar/calendar.ts | 350 +++++++++--------- src/addons/calendar/pages/day/day.html | 18 +- src/addons/calendar/pages/day/day.page.ts | 232 ++++++------ .../calendar/services/calendar-helper.ts | 19 +- src/addons/calendar/services/calendar-sync.ts | 5 +- .../submission-review/submission-review.ts | 4 +- .../index/addon-mod-book-index.html | 10 +- src/addons/mod/book/components/index/index.ts | 70 ++-- .../forum-discussions-swipe-manager.ts | 4 +- .../classes/glossary-entries-swipe-manager.ts | 4 +- .../items-management/items-manager-source.ts | 39 +- .../classes/items-management/items-manager.ts | 62 +++- .../items-management/routed-items-manager.ts | 13 +- ...r.ts => swipe-navigation-items-manager.ts} | 4 +- ...pe-slides-dynamic-items-manager-source.ts} | 54 +-- ... => swipe-slides-dynamic-items-manager.ts} | 4 +- ...s => swipe-slides-items-manager-source.ts} | 72 ++-- ...nager.ts => swipe-slides-items-manager.ts} | 20 +- .../swipe-navigation/swipe-navigation.ts | 4 +- .../components/swipe-slides/swipe-slides.html | 5 +- .../components/swipe-slides/swipe-slides.ts | 60 ++- .../user/pages/profile/profile.page.ts | 4 +- src/core/services/utils/dom.ts | 33 +- src/core/services/utils/time.ts | 155 -------- 26 files changed, 591 insertions(+), 676 deletions(-) rename src/core/classes/items-management/{swipe-items-manager.ts => swipe-navigation-items-manager.ts} (97%) rename src/core/classes/items-management/{slides-dynamic-items-manager-source.ts => swipe-slides-dynamic-items-manager-source.ts} (77%) rename src/core/classes/items-management/{slides-dynamic-items-manager.ts => swipe-slides-dynamic-items-manager.ts} (89%) rename src/core/classes/items-management/{slides-items-manager-source.ts => swipe-slides-items-manager-source.ts} (50%) rename src/core/classes/items-management/{slides-items-manager.ts => swipe-slides-items-manager.ts} (66%) 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 c48a21034..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,7 +23,7 @@ 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 { 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'; @@ -43,7 +43,7 @@ export class AddonBadgesIssuedBadgePage implements OnInit { user?: CoreUserProfile; course?: CoreEnrolledCourseData; badge?: AddonBadgesUserBadge; - badges?: CoreSwipeItemsManager; + badges?: CoreSwipeNavigationItemsManager; badgeLoaded = false; currentTime = 0; @@ -65,7 +65,7 @@ export class AddonBadgesIssuedBadgePage implements OnInit { AddonBadgesUserBadgesSource, [this.courseId, this.userId], ); - this.badges = new CoreSwipeItemsManager(source); + this.badges = new CoreSwipeNavigationItemsManager(source); this.badges.start(); } diff --git a/src/addons/calendar/components/calendar/addon-calendar-calendar.html b/src/addons/calendar/components/calendar/addon-calendar-calendar.html index 40ba979c3..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 @@ - @@ -19,7 +19,7 @@

{{ periodName }} - +

@@ -31,14 +31,14 @@ - - + +
- + {{ day.fullname | translate }} @@ -47,16 +47,16 @@
- +

diff --git a/src/addons/calendar/components/calendar/calendar.ts b/src/addons/calendar/components/calendar/calendar.ts index 36614d3d5..ed3e500c7 100644 --- a/src/addons/calendar/components/calendar/calendar.ts +++ b/src/addons/calendar/components/calendar/calendar.ts @@ -27,7 +27,7 @@ import { import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; -import { CoreTimeUtils, YearAndMonth } from '@services/utils/time'; +import { CoreTimeUtils } from '@services/utils/time'; import { CoreUtils } from '@services/utils/utils'; import { AddonCalendar, @@ -42,12 +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 { CoreSwipeCurrentItemData, CoreSwipeSlidesComponent } from '@components/swipe-slides/swipe-slides'; +import { CoreSwipeSlidesComponent } from '@components/swipe-slides/swipe-slides'; import { CoreSwipeSlidesDynamicItem, CoreSwipeSlidesDynamicItemsManagerSource, -} from '@classes/items-management/slides-dynamic-items-manager-source'; -import { CoreSwipeSlidesDynamicItemsManager } from '@classes/items-management/slides-dynamic-items-manager'; +} 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. @@ -72,15 +73,13 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro periodName?: string; manager?: CoreSwipeSlidesDynamicItemsManager; loaded = false; - isCurrentMonth = false; - isPastMonth = false; protected currentSiteId: string; - 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, @@ -95,7 +94,7 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro return; } - month.weeks.forEach((week) => { + month.weeks?.forEach((week) => { week.days.forEach((day) => { AddonCalendar.scheduleEventsNotifications(day.eventsFormated || []); }); @@ -131,19 +130,20 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro * Component loaded. */ ngOnInit(): void { - const currentMonth = CoreTimeUtils.getCurrentMonth(); - this.canNavigate = typeof this.canNavigate == 'undefined' ? true : CoreUtils.isTrueOrOne(this.canNavigate); this.displayNavButtons = typeof this.displayNavButtons == 'undefined' ? true : CoreUtils.isTrueOrOne(this.displayNavButtons); - const source = new AddonCalendarMonthSlidesItemsManagerSource(this, { - year: this.initialYear ?? currentMonth.year, - monthNumber: this.initialMonth ?? currentMonth.monthNumber, - }); + const source = new AddonCalendarMonthSlidesItemsManagerSource(this, moment({ + year: this.initialYear, + month: this.initialMonth ? this.initialMonth - 1 : undefined, + })); this.manager = new CoreSwipeSlidesDynamicItemsManager(source); - - this.calculateIsCurrentMonth(); + this.managerUnsubscribe = this.manager.addListener({ + onSelectedItemUpdated: (item) => { + this.onMonthViewed(item); + }, + }); this.fetchData(); } @@ -159,7 +159,7 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro const changes = this.differ.diff(this.filter || {}); if (changes) { items.forEach((month) => { - if (month.loaded) { + if (month.loaded && month.weeks) { this.manager?.getSource().filterEvents(month.weeks, this.filter); } }); @@ -190,14 +190,15 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro /** * Update data related to month being viewed. + * + * @param month Month being viewed. */ - viewMonth(month: YearAndMonth): void { + 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(month.year, month.monthNumber - 1).getTime(), + month.moment.unix() * 1000, 'core.strftimemonthyear', ); - this.calculateIsCurrentMonth(); } /** @@ -207,13 +208,13 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro * @return Promise resolved when done. */ async refreshData(afterChange = false): Promise { - const visibleMonth = this.slides?.getCurrentItem() || null; + const selectedMonth = this.manager?.getSelectedItem() || null; if (afterChange) { this.manager?.getSource().markAllItemsDirty(); } - await this.manager?.getSource().invalidateContent(visibleMonth); + await this.manager?.getSource().invalidateContent(selectedMonth); await this.fetchData(); } @@ -249,39 +250,53 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro * @param day Day. */ dayClicked(day: number): void { - const visibleMonth = this.slides?.getCurrentItem(); - if (!visibleMonth) { + const selectedMonth = this.manager?.getSelectedItem(); + if (!selectedMonth) { return; } - this.onDayClicked.emit({ day: day, month: visibleMonth.monthNumber, year: visibleMonth.year }); - } - - /** - * Check if user is viewing the current month. - */ - calculateIsCurrentMonth(): void { - const visibleMonth = this.slides?.getCurrentItem(); - if (!visibleMonth) { - return; - } - - this.currentTime = CoreTimeUtils.timestamp(); - this.isCurrentMonth = CoreTimeUtils.isSameMonth(visibleMonth, CoreTimeUtils.getCurrentMonth()); - this.isPastMonth = CoreTimeUtils.isPastMonth(visibleMonth); + 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 currentMonth = { - monthNumber: now.getMonth() + 1, - year: now.getFullYear(), - }; + const manager = this.manager; + const slides = this.slides; + if (!manager || !slides) { + return; + } - this.slides?.slideToItem(currentMonth); + const currentMonth = { + moment: moment(), + }; + this.loaded = false; + + try { + // Make sure the day is loaded. + await manager.getSource().loadItem(currentMonth); + + slides.slideToItem(currentMonth); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); + } finally { + this.loaded = true; + } + } + + /** + * Check whether selected month is loaded. + */ + selectedMonthLoaded(): boolean { + return !!this.manager?.getSelectedItem()?.loaded; + } + + /** + * Check whether selected month is current month. + */ + selectedMonthIsCurrent(): boolean { + return !!this.manager?.getSelectedItem()?.isCurrentMonth; } /** @@ -295,7 +310,7 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro return false; } - return month.weeks.some((week) => week.days.some((day) => { + return month.weeks?.some((week) => week.days.some((day) => { const event = day.eventsFormated?.find((event) => event.id == eventId); if (event) { @@ -309,41 +324,32 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro }); } - /** - * Returns if the event is in the past or not. - * - * @param event Event object. - * @return True if it's in the past. - */ - isEventPast(event: { timestart: number; timeduration: number}): boolean { - return (event.timestart + event.timeduration) < (this.currentTime || CoreTimeUtils.timestamp()); - } - - /** - * Slide has changed. - * - * @param data Data about new item. - */ - slideChanged(data: CoreSwipeCurrentItemData): void { - this.viewMonth(data.item); - } - /** * 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 = YearAndMonth & CoreSwipeSlidesDynamicItem & { - weekDays: AddonCalendarWeekDaysTranslationKeys[]; - weeks: AddonCalendarWeek[]; +type PreloadedMonth = MonthBasicData & CoreSwipeSlidesDynamicItem & { + weekDays?: AddonCalendarWeekDaysTranslationKeys[]; + weeks?: AddonCalendarWeek[]; + isCurrentMonth?: boolean; + isPastMonth?: boolean; }; /** @@ -351,8 +357,7 @@ type PreloadedMonth = YearAndMonth & CoreSwipeSlidesDynamicItem & { */ class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicItemsManagerSource { - monthLoaded = false; - categories: { [id: number]: CoreCategoryData } = {}; + categories?: { [id: number]: CoreCategoryData }; // Offline events classified in month & day. offlineEvents: { [monthId: string]: { [day: number]: AddonCalendarEventToDisplay[] } } = {}; offlineEditedEventsIds: number[] = []; // IDs of events edited in offline. @@ -360,10 +365,9 @@ class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicI timeFormat?: string; protected calendarComponent: AddonCalendarCalendarComponent; - protected categoriesRetrieved = false; - constructor(component: AddonCalendarCalendarComponent, initialMonth: YearAndMonth) { - super(initialMonth); + constructor(component: AddonCalendarCalendarComponent, initialMoment: moment.Moment) { + super({ moment: initialMoment }); this.calendarComponent = component; } @@ -394,7 +398,7 @@ class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicI day.filteredEvents = AddonCalendarHelper.getFilteredEvents( day.eventsFormated || [], filter, - this.categories, + this.categories || {}, ); // Re-calculate some properties. @@ -409,20 +413,16 @@ class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicI * @return Promise resolved when done. */ async loadCategories(): Promise { - if (this.categoriesRetrieved) { + if (this.categories) { // Already retrieved, stop. return; } try { - const cats = await CoreCourses.getCategories(0, true); - this.categoriesRetrieved = true; - this.categories = {}; + const categories = await CoreCourses.getCategories(0, true); // Index categories by ID. - cats.forEach((category) => { - this.categories[category.id] = category; - }); + this.categories = CoreUtils.arrayToObject(categories, 'id'); } catch { // Ignore errors. } @@ -465,103 +465,104 @@ class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicI /** * @inheritdoc */ - getItemId(item: YearAndMonth): string | number { - return AddonCalendarHelper.getMonthId(item); + getItemId(item: MonthBasicData): string | number { + return AddonCalendarHelper.getMonthId(item.moment); } /** * @inheritdoc */ - getPreviousItem(item: YearAndMonth): YearAndMonth | null { - return CoreTimeUtils.getPreviousMonth(item); + getPreviousItem(item: MonthBasicData): MonthBasicData | null { + return { + moment: item.moment.clone().subtract(1, 'month'), + }; } /** * @inheritdoc */ - getNextItem(item: YearAndMonth): YearAndMonth | null { - return CoreTimeUtils.getNextMonth(item); + getNextItem(item: MonthBasicData): MonthBasicData | null { + return { + moment: item.moment.clone().add(1, 'month'), + }; } /** * @inheritdoc */ - async loadItemData(month: YearAndMonth, preload = false): Promise { - if (!preload) { - this.monthLoaded = false; - } + 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; - try { - // Load or preload the weeks. - let result: { daynames: Partial[]; weeks: Partial[] }; - if (preload) { - result = await AddonCalendarHelper.getOfflineMonthWeeks(month.year, month.monthNumber); - } else { - try { - // Don't pass courseId and categoryId, we'll filter them locally. - result = await AddonCalendar.getMonthlyEvents(month.year, month.monthNumber); - } catch (error) { - if (!CoreApp.isOnline()) { - // Allow navigating to non-cached months in offline (behave as if using emergency cache). - result = await AddonCalendarHelper.getOfflineMonthWeeks(month.year, month.monthNumber); - } else { - throw error; - } + 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 isCurrentMonth = CoreTimeUtils.isSameMonth(month, CoreTimeUtils.getCurrentMonth()); - const currentDay = new Date().getDate(); - let isPast = true; - - await Promise.all(weeks.map(async (week) => { - await Promise.all(week.days.map(async (day) => { - day.periodName = CoreTimeUtils.userDate( - new Date(month.year, month.monthNumber - 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 (isCurrentMonth) { - day.istoday = day.mday == currentDay; - day.ispast = isPast && !day.istoday; - isPast = day.ispast; - - if (day.istoday) { - day.eventsFormated?.forEach((event) => { - event.ispast = this.calendarComponent.isEventPast(event); - }); - } - } - })); - })); - - if (!preload) { - // Merge the online events with offline data. - this.mergeEvents(month, weeks); - // Filter events by course. - this.filterEvents(weeks, this.calendarComponent.filter); - } - - return { - ...month, - weeks, - weekDays, - }; - } finally { - if (!preload) { - this.monthLoaded = true; - } } + + 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; } /** @@ -570,9 +571,9 @@ class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicI * @param month Month. * @param weeks Weeks with the events to filter. */ - mergeEvents(month: YearAndMonth, weeks: AddonCalendarWeek[]): void { + mergeEvents(month: MonthBasicData, weeks: AddonCalendarWeek[]): void { const monthOfflineEvents: { [day: number]: AddonCalendarEventToDisplay[] } = - this.offlineEvents[AddonCalendarHelper.getMonthId(month)]; + this.offlineEvents[AddonCalendarHelper.getMonthId(month.moment)]; weeks.forEach((week) => { week.days.forEach((day) => { @@ -605,25 +606,36 @@ class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicI }); } + /** + * 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: { timestart: number; timeduration: number}, currentTime: number): boolean { + return (event.timestart + event.timeduration) < currentTime; + } + /** * Invalidate content. * - * @param visibleMonth The current visible month. + * @param selectedMonth The current selected month. * @return Promise resolved when done. */ - async invalidateContent(visibleMonth: PreloadedMonth | null): Promise { + async invalidateContent(selectedMonth: PreloadedMonth | null): Promise { const promises: Promise[] = []; - if (visibleMonth) { - promises.push(AddonCalendar.invalidateMonthlyEvents(visibleMonth.year, visibleMonth.monthNumber)); + if (selectedMonth) { + promises.push(AddonCalendar.invalidateMonthlyEvents(selectedMonth.moment.year(), selectedMonth.moment.month() + 1)); } promises.push(CoreCourses.invalidateCategories(0, true)); promises.push(AddonCalendar.invalidateTimeFormat()); - this.categoriesRetrieved = false; // Get categories again. + this.categories = undefined; // Get categories again. - if (visibleMonth) { - visibleMonth.dirty = true; + if (selectedMonth) { + selectedMonth.dirty = true; } await Promise.all(promises); diff --git a/src/addons/calendar/pages/day/day.html b/src/addons/calendar/pages/day/day.html index 28b8bf934..01a7daceb 100644 --- a/src/addons/calendar/pages/day/day.html +++ b/src/addons/calendar/pages/day/day.html @@ -11,10 +11,10 @@ - - @@ -47,23 +47,23 @@ - - - + + + - + {{ 'core.hasdatatosync' | translate:{$a: 'core.day' | translate} }} - - - + + diff --git a/src/addons/calendar/pages/day/day.page.ts b/src/addons/calendar/pages/day/day.page.ts index 86039c369..984d5931b 100644 --- a/src/addons/calendar/pages/day/day.page.ts +++ b/src/addons/calendar/pages/day/day.page.ts @@ -19,7 +19,7 @@ import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreLocalNotifications } from '@services/local-notifications'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; -import { CoreTimeUtils, YearMonthDay } from '@services/utils/time'; +import { CoreTimeUtils } from '@services/utils/time'; import { AddonCalendarProvider, AddonCalendar, @@ -40,12 +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/slides-dynamic-items-manager'; -import { CoreSwipeCurrentItemData, CoreSwipeSlidesComponent } from '@components/swipe-slides/swipe-slides'; +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/slides-dynamic-items-manager-source'; +} from '@classes/items-management/swipe-slides-dynamic-items-manager-source'; /** * Page that displays the calendar events for a certain day. @@ -72,13 +72,13 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { protected onlineObserver: Subscription; protected obsDefaultTimeChange?: CoreEventObserver; protected filterChangedObserver: CoreEventObserver; + protected managerUnsubscribe?: () => void; periodName?: string; manager?: CoreSwipeSlidesDynamicItemsManager; loaded = false; isOnline = false; syncIcon = CoreConstants.ICON_LOADING; - isCurrentDay = false; filter: AddonCalendarFilter = { filtered: false, courseId: undefined, @@ -97,7 +97,7 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { // Re-schedule events if default time changes. this.obsDefaultTimeChange = CoreEvents.on(AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_CHANGED, () => { this.manager?.getSource().getItems()?.forEach(day => { - AddonCalendar.scheduleEventsNotifications(day.onlineEvents); + AddonCalendar.scheduleEventsNotifications(day.onlineEvents || []); }); }, this.currentSiteId); } @@ -140,9 +140,8 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { // Refresh data if calendar events are synchronized manually but not by this page. this.manualSyncObserver = CoreEvents.on(AddonCalendarSyncProvider.MANUAL_SYNCED, (data) => { - const visibleDay = this.slides?.getCurrentItem(); - if (data && (data.source != 'day' || !visibleDay || data.day === undefined || data.year != visibleDay.year || - data.month != visibleDay.monthNumber || data.day != visibleDay.dayNumber)) { + 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); } @@ -214,15 +213,18 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { this.filter.filtered = typeof this.filter.courseId != 'undefined' || types.some((name) => !this.filter[name]); - const currentDay = CoreTimeUtils.getCurrentDay(); - const source = new AddonCalendarDaySlidesItemsManagerSource(this, { - year: CoreNavigator.getRouteNumberParam('year') ?? currentDay.year, - monthNumber: CoreNavigator.getRouteNumberParam('month') ?? currentDay.monthNumber, - dayNumber: CoreNavigator.getRouteNumberParam('day') ?? currentDay.dayNumber, - }); + 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.calculateIsCurrentDay(); + this.managerUnsubscribe = this.manager.addListener({ + onSelectedItemUpdated: (item) => { + this.onDayViewed(item); + }, + }); this.fetchData(true); } @@ -264,13 +266,14 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { /** * Update data related to day being viewed. + * + * @param day Day viewed. */ - viewDay(day: YearMonthDay): void { + onDayViewed(day: DayBasicData): void { this.periodName = CoreTimeUtils.userDate( - new Date(day.year, day.monthNumber - 1, day.dayNumber).getTime(), + day.moment.unix() * 1000, 'core.strftimedaydate', ); - this.calculateIsCurrentDay(); } /** @@ -301,10 +304,10 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { async refreshData(sync?: boolean, afterChange?: boolean): Promise { this.syncIcon = CoreConstants.ICON_LOADING; - const visibleDay = this.slides?.getCurrentItem() || null; + const selectedDay = this.manager?.getSelectedItem() || null; // Don't invalidate day events after a change, it has already been handled. - await this.manager?.getSource().invalidateContent(visibleDay, !afterChange); + await this.manager?.getSource().invalidateContent(selectedDay, !afterChange); await this.fetchData(sync); } @@ -325,14 +328,9 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { if (result.updated) { // Trigger a manual sync event. - const visibleDay = this.slides?.getCurrentItem(); + const selectedDay = this.manager?.getSelectedItem(); result.source = 'day'; - - if (visibleDay) { - result.day = visibleDay.dayNumber; - result.month = visibleDay.monthNumber; - result.year = visibleDay.year; - } + result.moment = selectedDay?.moment; this.manager?.getSource().markAllItemsUnloaded(); CoreEvents.trigger(AddonCalendarSyncProvider.MANUAL_SYNCED, result, this.currentSiteId); @@ -344,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. * @@ -381,10 +386,9 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { // It's a new event, set the time. eventId = 0; - const visibleDay = this.slides?.getCurrentItem(); - if (visibleDay) { - params.timestamp = moment().year(visibleDay.year).month(visibleDay.monthNumber - 1).date(visibleDay.dayNumber) - .unix() * 1000; + const selectedDay = this.manager?.getSelectedItem(); + if (selectedDay) { + params.timestamp = selectedDay.moment.unix() * 1000; } } @@ -396,26 +400,14 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { } /** - * Check if user is viewing the current day. - */ - calculateIsCurrentDay(): void { - const visibleDay = this.slides?.getCurrentItem(); - if (!visibleDay) { - return; - } - - this.isCurrentDay = visibleDay.isCurrentDay; - } - - /** - * Check whether visible day has offline data. + * Check whether selected day has offline data. * - * @return Whether visible day has offline data. + * @return Whether selected day has offline data. */ - visibleDayHasOffline(): boolean { - const visibleDay = this.slides?.getCurrentItem(); + selectedDayHasOffline(): boolean { + const selectedDay = this.manager?.getSelectedItem(); - return !!visibleDay && visibleDay.hasOffline; + return !!(selectedDay?.hasOffline); } /** @@ -428,7 +420,9 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { return; } - const currentDay = CoreTimeUtils.getCurrentDay(); + const currentDay = { + moment: moment(), + }; this.loaded = false; try { @@ -436,7 +430,6 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { await manager.getSource().loadItem(currentDay); slides.slideToItem(currentDay); - this.isCurrentDay = true; } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); } finally { @@ -458,15 +451,6 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { this.slides?.slidePrev(); } - /** - * Slide has changed. - * - * @param data Data about new item. - */ - slideChanged(data: CoreSwipeCurrentItemData): void { - this.viewDay(data.item); - } - /** * Page destroyed. */ @@ -481,20 +465,28 @@ 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 = YearMonthDay & CoreSwipeSlidesDynamicItem & { - events: AddonCalendarEventToDisplay[]; // Events (both online and offline). - onlineEvents: AddonCalendarEventToDisplay[]; - filteredEvents: AddonCalendarEventToDisplay[]; - isCurrentDay: boolean; - isPastDay: boolean; - hasOffline: boolean; // Whether the day has offline data. +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. }; /** @@ -506,16 +498,15 @@ class AddonCalendarDaySlidesItemsManagerSource extends CoreSwipeSlidesDynamicIte // Offline events classified in month & day. offlineEvents: Record> = {}; offlineEditedEventsIds: number[] = []; // IDs of events edited in offline. - categories: { [id: number]: CoreCategoryData } = {}; - deletedEvents: number[] = []; // Events deleted in offline. + categories?: { [id: number]: CoreCategoryData }; + deletedEvents?: Set; // Events deleted in offline. timeFormat?: string; canCreate = false; protected dayPage: AddonCalendarDayPage; - protected categoriesRetrieved = false; - constructor(page: AddonCalendarDayPage, initialDay: YearMonthDay) { - super(initialDay); + constructor(page: AddonCalendarDayPage, initialMoment: moment.Moment) { + super({ moment: initialMoment }); this.dayPage = page; } @@ -553,7 +544,7 @@ class AddonCalendarDaySlidesItemsManagerSource extends CoreSwipeSlidesDynamicIte * @param filter Filter to apply. */ filterEvents(day: PreloadedDay, filter: AddonCalendarFilter): void { - day.filteredEvents = AddonCalendarHelper.getFilteredEvents(day.events, filter, this.categories); + day.filteredEvents = AddonCalendarHelper.getFilteredEvents(day.events || [], filter, this.categories || {}); } /** @@ -584,20 +575,16 @@ class AddonCalendarDaySlidesItemsManagerSource extends CoreSwipeSlidesDynamicIte * @return Promise resolved when done. */ async loadCategories(): Promise { - if (this.categoriesRetrieved) { + if (this.categories) { // Already retrieved, stop. return; } try { - const cats = await CoreCourses.getCategories(0, true); - this.categoriesRetrieved = true; - this.categories = {}; + const categories = await CoreCourses.getCategories(0, true); // Index categories by ID. - cats.forEach((category) => { - this.categories[category.id] = category; - }); + this.categories = CoreUtils.arrayToObject(categories, 'id'); } catch { // Ignore errors. } @@ -625,7 +612,9 @@ class AddonCalendarDaySlidesItemsManagerSource extends CoreSwipeSlidesDynamicIte * @return Promise resolved when done. */ async loadOfflineDeletedEvents(): Promise { - this.deletedEvents = await AddonCalendarOffline.getAllDeletedEventsIds(); + const deletedEventsIds = await AddonCalendarOffline.getAllDeletedEventsIds(); + + this.deletedEvents = new Set(deletedEventsIds); } /** @@ -640,36 +629,40 @@ class AddonCalendarDaySlidesItemsManagerSource extends CoreSwipeSlidesDynamicIte /** * @inheritdoc */ - getItemId(item: YearMonthDay): string | number { - return AddonCalendarHelper.getDayId(item); + getItemId(item: DayBasicData): string | number { + return AddonCalendarHelper.getDayId(item.moment); } /** * @inheritdoc */ - getPreviousItem(item: YearMonthDay): YearMonthDay | null { - return CoreTimeUtils.getPreviousDay(item); + getPreviousItem(item: DayBasicData): DayBasicData | null { + return { + moment: item.moment.clone().subtract(1, 'day'), + }; } /** * @inheritdoc */ - getNextItem(item: YearMonthDay): YearMonthDay | null { - return CoreTimeUtils.getNextDay(item); + getNextItem(item: DayBasicData): DayBasicData | null { + return { + moment: item.moment.clone().add(1, 'day'), + }; } /** * @inheritdoc */ - async loadItemData(day: YearMonthDay, preload = false): Promise { + async loadItemData(day: DayBasicData, preload = false): Promise { const preloadedDay: PreloadedDay = { ...day, hasOffline: false, events: [], onlineEvents: [], filteredEvents: [], - isCurrentDay: CoreTimeUtils.isSameDay(day, CoreTimeUtils.getCurrentDay()), - isPastDay: CoreTimeUtils.isPastDay(day), + isCurrentDay: day.moment.isSame(moment(), 'day'), + isPastDay: day.moment.isBefore(moment(), 'day'), }; if (preload) { @@ -680,7 +673,7 @@ class AddonCalendarDaySlidesItemsManagerSource extends CoreSwipeSlidesDynamicIte try { // Don't pass courseId and categoryId, we'll filter them locally. - result = await AddonCalendar.getDayEvents(day.year, day.monthNumber, day.dayNumber); + 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)), ); @@ -692,7 +685,7 @@ class AddonCalendarDaySlidesItemsManagerSource extends CoreSwipeSlidesDynamicIte } // Schedule notifications for the events retrieved (only future events will be scheduled). - AddonCalendar.scheduleEventsNotifications(preloadedDay.onlineEvents); + AddonCalendar.scheduleEventsNotifications(preloadedDay.onlineEvents || []); // Merge the online events with offline data. preloadedDay.events = this.mergeEvents(preloadedDay); @@ -701,7 +694,7 @@ class AddonCalendarDaySlidesItemsManagerSource extends CoreSwipeSlidesDynamicIte this.filterEvents(preloadedDay, this.dayPage.filter); // Re-calculate the formatted time so it uses the device date. - const dayTime = moment().year(day.year).month(day.monthNumber - 1).date(day.dayNumber).unix() * 1000; + const dayTime = day.moment.unix() * 1000; const currentTime = CoreTimeUtils.timestamp(); const promises = preloadedDay.events.map(async (event) => { @@ -734,19 +727,19 @@ class AddonCalendarDaySlidesItemsManagerSource extends CoreSwipeSlidesDynamicIte mergeEvents(day: PreloadedDay): AddonCalendarEventToDisplay[] { day.hasOffline = false; - if (!Object.keys(this.offlineEvents).length && !this.deletedEvents.length) { + if (!Object.keys(this.offlineEvents).length && !this.deletedEvents?.size) { // No offline events, nothing to merge. - return day.onlineEvents; + return day.onlineEvents || []; } - const monthOfflineEvents = this.offlineEvents[AddonCalendarHelper.getMonthId(day)]; - const dayOfflineEvents = monthOfflineEvents && monthOfflineEvents[day.dayNumber]; - let result = day.onlineEvents; + const monthOfflineEvents = this.offlineEvents[AddonCalendarHelper.getMonthId(day.moment)]; + const dayOfflineEvents = monthOfflineEvents && monthOfflineEvents[day.moment.date()]; + let result = day.onlineEvents || []; - if (this.deletedEvents.length) { + if (this.deletedEvents?.size) { // Mark as deleted the events that were deleted in offline. result.forEach((event) => { - event.deleted = this.deletedEvents.indexOf(event.id) != -1; + event.deleted = this.deletedEvents?.has(event.id); if (event.deleted) { day.hasOffline = true; @@ -758,7 +751,7 @@ class AddonCalendarDaySlidesItemsManagerSource extends CoreSwipeSlidesDynamicIte // 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) { + if (result.length != day.onlineEvents?.length) { day.hasOffline = true; } } @@ -775,24 +768,28 @@ class AddonCalendarDaySlidesItemsManagerSource extends CoreSwipeSlidesDynamicIte /** * Invalidate content. * - * @param visibleDay The current visible day. - * @param invalidateDayEvents Whether to invalidate visible day events. + * @param selectedDay The current selected day. + * @param invalidateDayEvents Whether to invalidate selected day events. * @return Promise resolved when done. */ - async invalidateContent(visibleDay: PreloadedDay | null, invalidateDayEvents?: boolean): Promise { + async invalidateContent(selectedDay: PreloadedDay | null, invalidateDayEvents?: boolean): Promise { const promises: Promise[] = []; - if (invalidateDayEvents && visibleDay) { - promises.push(AddonCalendar.invalidateDayEvents(visibleDay.year, visibleDay.monthNumber, visibleDay.dayNumber)); + 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.categoriesRetrieved = false; // Get categories again. + this.categories = undefined; // Get categories again. - if (visibleDay) { - visibleDay.dirty = true; + if (selectedDay) { + selectedDay.dirty = true; } await Promise.all(promises); @@ -807,7 +804,7 @@ class AddonCalendarDaySlidesItemsManagerSource extends CoreSwipeSlidesDynamicIte 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); + const event = day.onlineEvents?.find((event) => event.id == eventId); if (!event) { return false; @@ -819,8 +816,8 @@ class AddonCalendarDaySlidesItemsManagerSource extends CoreSwipeSlidesDynamicIte day.hasOffline = true; } else { // Re-calculate "hasOffline". - day.hasOffline = day.events.length != day.onlineEvents.length || - day.events.some((event) => event.deleted || event.offline); + day.hasOffline = day.events?.length != day.onlineEvents?.length || + day.events?.some((event) => event.deleted || event.offline); } return true; @@ -828,14 +825,9 @@ class AddonCalendarDaySlidesItemsManagerSource extends CoreSwipeSlidesDynamicIte // Add it or remove it from the list of deleted events. if (deleted) { - if (!this.deletedEvents.includes(eventId)) { - this.deletedEvents.push(eventId); - } + this.deletedEvents?.add(eventId); } else { - const index = this.deletedEvents.indexOf(eventId); - if (index != -1) { - this.deletedEvents.splice(index, 1); - } + this.deletedEvents?.delete(eventId); } } diff --git a/src/addons/calendar/services/calendar-helper.ts b/src/addons/calendar/services/calendar-helper.ts index 74f588a7b..776c2b38d 100644 --- a/src/addons/calendar/services/calendar-helper.ts +++ b/src/addons/calendar/services/calendar-helper.ts @@ -37,7 +37,6 @@ import { AddonCalendarSyncInvalidateEvent } from './calendar-sync'; import { AddonCalendarOfflineEventDBRecord } from './database/calendar-offline'; import { CoreCategoryData } from '@features/courses/services/courses'; import { AddonCalendarReminderDBRecord } from './database/calendar'; -import { YearAndMonth, YearMonthDay } from '@services/utils/time'; /** * Context levels enumeration. @@ -141,7 +140,7 @@ export class AddonCalendarHelperProvider { // Add the event to all the days it lasts. while (!treatedDay.isAfter(endDay, 'day')) { - const monthId = this.getMonthId({ year: treatedDay.year(), monthNumber: treatedDay.month() + 1 }); + const monthId = this.getMonthId(treatedDay); const day = treatedDay.date(); if (!result[monthId]) { @@ -367,21 +366,21 @@ export class AddonCalendarHelperProvider { /** * Get the month "id". * - * @param month Month data. + * @param moment Month moment. * @return The "id". */ - getMonthId(month: YearAndMonth): string { - return `${month.year}#${month.monthNumber}`; + getMonthId(moment: moment.Moment): string { + return `${moment.year()}#${moment.month() + 1}`; } /** * Get the day "id". * - * @param day Day data. + * @param day Day moment. * @return The "id". */ - getDayId(day: YearMonthDay): string { - return `${day.year}#${day.monthNumber}#${day.dayNumber}`; + getDayId(moment: moment.Moment): string { + return `${this.getMonthId(moment)}#${moment.date()}`; } /** @@ -660,7 +659,7 @@ export class AddonCalendarHelperProvider { fetchTimestarts.map((fetchTime) => { const day = moment(new Date(fetchTime * 1000)); - const monthId = this.getMonthId({ year: day.year(), monthNumber: day.month() + 1 }); + const monthId = this.getMonthId(day); if (!treatedMonths[monthId]) { // Month not refetch or invalidated already, do it now. treatedMonths[monthId] = true; @@ -696,7 +695,7 @@ export class AddonCalendarHelperProvider { invalidateTimestarts.map((fetchTime) => { const day = moment(new Date(fetchTime * 1000)); - const monthId = this.getMonthId({ year: day.year(), monthNumber: 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/pages/submission-review/submission-review.ts b/src/addons/mod/assign/pages/submission-review/submission-review.ts index 7f2e278cd..20342ef15 100644 --- a/src/addons/mod/assign/pages/submission-review/submission-review.ts +++ b/src/addons/mod/assign/pages/submission-review/submission-review.ts @@ -15,7 +15,7 @@ import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; -import { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; +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'; @@ -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 83787d103..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,14 +40,14 @@ previousTranslate="addon.mod_book.navprevtitle" nextTranslate="addon.mod_book.navnexttitle" (action)="changeChapter($event.id)"> - - + +

- -
+
{{ '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 4c9f35cbd..d7f78d112 100644 --- a/src/addons/mod/book/components/index/index.ts +++ b/src/addons/mod/book/components/index/index.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Optional, Input, OnInit, ViewChild, ElementRef } from '@angular/core'; +import { Component, Optional, Input, OnInit, ViewChild, ElementRef, OnDestroy } from '@angular/core'; import { CoreCourseModuleMainResourceComponent } from '@features/course/classes/main-resource-component'; import { AddonModBookProvider, @@ -31,10 +31,11 @@ import { AddonModBookTocComponent } from '../toc/toc'; import { CoreNavigationBarItem } from '@components/navigation-bar/navigation-bar'; import { CoreError } from '@classes/errors/error'; import { Translate } from '@singletons'; -import { CoreSwipeCurrentItemData, CoreSwipeSlidesComponent, CoreSwipeSlidesOptions } from '@components/swipe-slides/swipe-slides'; -import { CoreSwipeSlidesItemsManagerSource } from '@classes/items-management/slides-items-manager-source'; +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/slides-items-manager'; +import { CoreSwipeSlidesItemsManager } from '@classes/items-management/swipe-slides-items-manager'; +import { CoreTextUtils } from '@services/utils/text'; /** * Component that displays a book. @@ -43,7 +44,7 @@ import { CoreSwipeSlidesItemsManager } from '@classes/items-management/slides-it 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; @@ -51,9 +52,6 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp component = AddonModBookProvider.COMPONENT; manager?: CoreSwipeSlidesItemsManager; - previousChapter?: AddonModBookTocChapter; - nextChapter?: AddonModBookTocChapter; - tagsEnabled = false; warning = ''; displayNavBar = true; navigationItems: CoreNavigationBarItem[] = []; @@ -63,8 +61,9 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp scrollOnChange: 'top', }; - protected currentChapter?: number; + protected firstLoad = true; protected element: HTMLElement; + protected managerUnsubscribe?: () => void; constructor( elementRef: ElementRef, @@ -81,14 +80,18 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp async ngOnInit(): Promise { super.ngOnInit(); - this.tagsEnabled = CoreTag.areTagsAvailableInSite(); const source = new AddonModBookSlidesItemsManagerSource( this.courseId, this.module, - this.tagsEnabled, + CoreTag.areTagsAvailableInSite(), this.initialChapterId, ); this.manager = new CoreSwipeSlidesItemsManager(source); + this.managerUnsubscribe = this.manager.addListener({ + onSelectedItemUpdated: (item) => { + this.onChapterViewed(item.id); + }, + }); this.loadContent(); } @@ -106,12 +109,14 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp */ 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, }, @@ -129,7 +134,7 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp * @return Promise resolved when done. */ changeChapter(chapterId: number): void { - if (!chapterId || chapterId === this.currentChapter) { + if (!chapterId) { return; } @@ -183,14 +188,15 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp } /** - * View a book chapter. + * Update data related to chapter being viewed. * - * @param chapterId Chapter to load. - * @param logChapterId Whether chapter ID should be passed to the log view function. + * @param chapterId Chapter viewed. * @return Promise resolved when done. */ - protected async viewChapter(chapterId: number, logChapterId: boolean): Promise { - this.currentChapter = chapterId; + 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; if (this.displayNavBar) { this.navigationItems = this.getNavigationItems(chapterId); @@ -212,15 +218,6 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp } } - /** - * Slide has changed. - * - * @param data Data about new item. - */ - slideChanged(data: CoreSwipeCurrentItemData): void { - this.viewChapter(data.item.id, true); - } - /** * Converts chapters to navigation items. * @@ -236,11 +233,20 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp })); } + /** + * @inheritdoc + */ + ngOnDestroy(): void { + super.ngOnDestroy(); + + this.managerUnsubscribe && this.managerUnsubscribe(); + } + } type LoadedChapter = { id: number; - content: string; + content?: string; tags?: CoreTagItem[]; }; @@ -287,7 +293,6 @@ class AddonModBookSlidesItemsManagerSource extends CoreSwipeSlidesItemsManagerSo * Load module contents. */ async loadContents(): Promise { - // Get contents. No need to refresh, it has been done in downloadResourceIfNeeded. const contents = await CoreCourse.getModuleContents(this.MODULE, this.COURSE_ID); this.contentsMap = AddonModBook.getContentsMap(contents); @@ -310,10 +315,9 @@ class AddonModBookSlidesItemsManagerSource extends CoreSwipeSlidesItemsManagerSo })); return newChapters; - } catch (exception) { - const error = exception ?? new CoreError(Translate.instant('addon.mod_book.errorchapter')); - if (!error.message) { - error.message = Translate.instant('addon.mod_book.errorchapter'); + } catch (error) { + if (!CoreTextUtils.getErrorMessageFromError(error)) { + throw new CoreError(Translate.instant('addon.mod_book.errorchapter')); } throw error; 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/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/core/classes/items-management/items-manager-source.ts b/src/core/classes/items-management/items-manager-source.ts index c6b8fd275..4a2c7de2e 100644 --- a/src/core/classes/items-management/items-manager-source.ts +++ b/src/core/classes/items-management/items-manager-source.ts @@ -15,7 +15,7 @@ /** * Updates listener. */ -export interface CoreItemsListSourceListener { +export interface CoreItemsManagerSourceListener { onItemsUpdated?(items: Item[]): void; onReset?(): void; } @@ -26,16 +26,40 @@ export interface CoreItemsListSourceListener { export abstract class CoreItemsManagerSource { protected items: Item[] | null = null; - protected listeners: CoreItemsListSourceListener[] = []; + protected listeners: CoreItemsManagerSourceListener[] = []; protected dirty = false; + protected loaded = false; + protected loadedPromise: Promise; + protected resolveLoaded!: () => void; + + constructor() { + this.loadedPromise = new Promise(resolve => this.resolveLoaded = resolve); + } /** - * Check whether any item has been loaded. + * Check whether data is loaded. * - * @returns Whether any item has been loaded. + * @returns Whether data is loaded. */ isLoaded(): boolean { - return this.items !== null; + return this.loaded; + } + + /** + * Return a promise that is resolved when the data is loaded. + * + * @return Promise. + */ + waitForLoaded(): Promise { + return this.loadedPromise; + } + + /** + * Mark the source as initialized. + */ + protected setLoaded(): void { + this.loaded = true; + this.resolveLoaded(); } /** @@ -79,7 +103,7 @@ export abstract class CoreItemsManagerSource { * @param listener Listener. * @returns Unsubscribe function. */ - addListener(listener: CoreItemsListSourceListener): () => void { + addListener(listener: CoreItemsManagerSourceListener): () => void { this.listeners.push(listener); return () => this.removeListener(listener); @@ -90,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) { @@ -107,6 +131,7 @@ export abstract class CoreItemsManagerSource { */ protected setItems(items: Item[]): void { this.items = items; + this.setLoaded(); this.notifyItemsUpdated(); } diff --git a/src/core/classes/items-management/items-manager.ts b/src/core/classes/items-management/items-manager.ts index dda251c1e..2cc88a2ac 100644 --- a/src/core/classes/items-management/items-manager.ts +++ b/src/core/classes/items-management/items-manager.ts @@ -14,6 +14,13 @@ import { CoreItemsManagerSource } from './items-manager-source'; +/** + * Listeners. + */ +export interface CoreItemsanagerListener { + onSelectedItemUpdated?(item: Item): void; +} + /** * Helper to manage a collection of items in a page. */ @@ -25,6 +32,7 @@ export abstract class CoreItemsManager< 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); @@ -96,6 +104,35 @@ export abstract class CoreItemsManager< */ setSelectedItem(item: Item | null): void { this.selectedItem = item; + + this.listeners.forEach(listener => listener.onSelectedItemUpdated?.call(listener, item)); + } + + /** + * Register a listener. + * + * @param listener Listener. + * @returns Unsubscribe function. + */ + addListener(listener: CoreItemsanagerListener): () => void { + this.listeners.push(listener); + + return () => this.removeListener(listener); + } + + /** + * Remove a listener. + * + * @param listener Listener. + */ + removeListener(listener: CoreItemsanagerListener): void { + const index = this.listeners.indexOf(listener); + + if (index === -1) { + return; + } + + this.listeners.splice(index, 1); } /** @@ -103,9 +140,12 @@ export abstract class CoreItemsManager< * * @param items New items. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars protected onSourceItemsUpdated(items: Item[]): void { - // Nothing to do. + this.itemsMap = items.reduce((map, item) => { + map[this.getItemId(item)] = item; + + return map; + }, {}); } /** @@ -116,4 +156,22 @@ export abstract class CoreItemsManager< this.selectedItem = null; } + /** + * Get item by ID. + * + * @param id ID + * @return Item, null if not found. + */ + getItemById(id: string | number): Item | null { + return this.itemsMap?.[id] ?? null; + } + + /** + * Get an ID to identify an item. + * + * @param item Data about the item. + * @return Item ID. + */ + abstract getItemId(item: Item): string | number; + } diff --git a/src/core/classes/items-management/routed-items-manager.ts b/src/core/classes/items-management/routed-items-manager.ts index fe8aa8d38..6feb244cc 100644 --- a/src/core/classes/items-management/routed-items-manager.ts +++ b/src/core/classes/items-management/routed-items-manager.ts @@ -123,13 +123,16 @@ export abstract class CoreRoutedItemsManager< * @inheritdoc */ protected onSourceItemsUpdated(items: Item[]): void { - this.itemsMap = items.reduce((map, item) => { - map[this.getSource().getItemPath(item)] = item; - - return map; - }, {}); + 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 97% rename from src/core/classes/items-management/swipe-items-manager.ts rename to src/core/classes/items-management/swipe-navigation-items-manager.ts index 0290206ef..a156cedc9 100644 --- a/src/core/classes/items-management/swipe-items-manager.ts +++ b/src/core/classes/items-management/swipe-navigation-items-manager.ts @@ -22,7 +22,7 @@ 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 CoreRoutedItemsManagerSource = CoreRoutedItemsManagerSource > @@ -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/slides-dynamic-items-manager-source.ts b/src/core/classes/items-management/swipe-slides-dynamic-items-manager-source.ts similarity index 77% rename from src/core/classes/items-management/slides-dynamic-items-manager-source.ts rename to src/core/classes/items-management/swipe-slides-dynamic-items-manager-source.ts index a7324ebbc..cd54848c8 100644 --- a/src/core/classes/items-management/slides-dynamic-items-manager-source.ts +++ b/src/core/classes/items-management/swipe-slides-dynamic-items-manager-source.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { CoreSwipeSlidesItemsManagerSource } from './slides-items-manager-source'; +import { CoreSwipeSlidesItemsManagerSource } from './swipe-slides-items-manager-source'; /** * Items collection source data for "swipe slides". @@ -26,16 +26,16 @@ export abstract class CoreSwipeSlidesDynamicItemsManagerSource | null): Promise { - if (!this.initialized && this.initialItem) { + async load(selectedItem?: Item | null): Promise { + if (!this.loaded && this.initialItem) { // Load the initial item. await this.loadItem(this.initialItem); - } else if (this.initialized && currentItem) { - // Reload current item if needed. - await this.loadItem(currentItem); + } else if (this.loaded && selectedItem) { + // Reload selected item if needed. + await this.loadItem(selectedItem); } - this.setInitialized(); + this.setLoaded(); } /** @@ -49,35 +49,35 @@ export abstract class CoreSwipeSlidesDynamicItemsManagerSource): Promise { + 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) : undefined, - nextItem ? this.loadItemInList(nextItem, true) : undefined, + previousItem && this.loadItemInList(previousItem, true), + nextItem && this.loadItemInList(nextItem, true), ]); } /** * Load or preload a certain item and add it to the list. * - * @param item Basic data about the item to load. + * @param item Item to load. * @param preload Whether to preload. * @return Promise resolved when done. */ - async loadItemInList(item: Partial, preload = false): Promise { + 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(item); + const existingItem = this.getItem(this.getItemId(item)); if (existingItem) { // Already in list, no need to add it. return; @@ -94,19 +94,19 @@ export abstract class CoreSwipeSlidesDynamicItemsManagerSource { const itemId = this.getItemId(item); - let positionToInsert = -1; + let indexToInsert = -1; if (itemId === previousItemId) { // Previous item found, add the item after it. - positionToInsert = index + 1; + indexToInsert = index + 1; } if (itemId === nextItemId) { // Next item found, add the item before it. - positionToInsert = index; + indexToInsert = index; } - if (positionToInsert > -1) { - this.items?.splice(positionToInsert, 0, preloadedItem); + if (indexToInsert > -1) { + this.items?.splice(indexToInsert, 0, preloadedItem); return true; } @@ -124,19 +124,19 @@ export abstract class CoreSwipeSlidesDynamicItemsManagerSource, preload: boolean): Promise { + protected async performLoadItemData(item: Item, preload: boolean): Promise { const itemId = this.getItemId(item); - if (!itemId || this.loadingItems[itemId]) { - // Not valid or already loading, ignore it. + if (this.loadingItems[itemId]) { + // Already loading, ignore it. return null; } - const existingItem = this.getItem(item); + const existingItem = this.getItem(itemId); if (existingItem && ((existingItem.loaded && !existingItem.dirty) || preload)) { // Already loaded, or preloading an already preloaded item. return existingItem; @@ -191,7 +191,7 @@ export abstract class CoreSwipeSlidesDynamicItemsManagerSource, preload: boolean): Promise; + abstract loadItemData(item: Item, preload: boolean): Promise; /** * Return the data to identify the previous item. @@ -199,7 +199,7 @@ export abstract class CoreSwipeSlidesDynamicItemsManagerSource): Partial | null; + abstract getPreviousItem(item: Item): Item | null; /** * Return the data to identify the next item. @@ -207,7 +207,7 @@ export abstract class CoreSwipeSlidesDynamicItemsManagerSource): Partial | null; + abstract getNextItem(item: Item): Item | null; } diff --git a/src/core/classes/items-management/slides-dynamic-items-manager.ts b/src/core/classes/items-management/swipe-slides-dynamic-items-manager.ts similarity index 89% rename from src/core/classes/items-management/slides-dynamic-items-manager.ts rename to src/core/classes/items-management/swipe-slides-dynamic-items-manager.ts index 718e71389..6bbb5d542 100644 --- a/src/core/classes/items-management/slides-dynamic-items-manager.ts +++ b/src/core/classes/items-management/swipe-slides-dynamic-items-manager.ts @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { CoreSwipeSlidesDynamicItem, CoreSwipeSlidesDynamicItemsManagerSource } from './slides-dynamic-items-manager-source'; -import { CoreSwipeSlidesItemsManager } from './slides-items-manager'; +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. diff --git a/src/core/classes/items-management/slides-items-manager-source.ts b/src/core/classes/items-management/swipe-slides-items-manager-source.ts similarity index 50% rename from src/core/classes/items-management/slides-items-manager-source.ts rename to src/core/classes/items-management/swipe-slides-items-manager-source.ts index e45c9d5b9..7dd41b754 100644 --- a/src/core/classes/items-management/slides-items-manager-source.ts +++ b/src/core/classes/items-management/swipe-slides-items-manager-source.ts @@ -19,40 +19,12 @@ import { CoreItemsManagerSource } from './items-manager-source'; */ export abstract class CoreSwipeSlidesItemsManagerSource extends CoreItemsManagerSource { - protected initialItem?: Partial; - protected initialized = false; - protected initializePromise: Promise; - protected resolveInitialize!: () => void; + protected initialItem?: Item; - constructor(initialItem?: Partial) { + constructor(initialItem?: Item) { super(); this.initialItem = initialItem; - this.initializePromise = new Promise(resolve => this.resolveInitialize = resolve); - } - - /** - * @inheritdoc - */ - isLoaded(): boolean { - return this.initialized && super.isLoaded(); - } - - /** - * Return a promise that is resolved when the source is initialized. - * - * @return Promise. - */ - waitForInitialized(): Promise { - return this.initializePromise; - } - - /** - * Mark the source as initialized. - */ - protected setInitialized(): void { - this.initialized = true; - this.resolveInitialize(); } /** @@ -62,7 +34,6 @@ export abstract class CoreSwipeSlidesItemsManagerSource extends const items = await this.loadItems(); this.setItems(items); - this.setInitialized(); } /** @@ -75,47 +46,56 @@ export abstract class CoreSwipeSlidesItemsManagerSource extends /** * Get a certain item. * - * @param item Partial data about the item to search. + * @param id Item ID. * @return Item, null if not found. */ - getItem(item: Partial): Item | null { - const index = this.getItemPosition(item); + getItem(id: string | number): Item | null { + const index = this.getItemIndexById(id); return this.items?.[index] ?? null; } /** - * Get a certain item position. + * Get a certain item index. * - * @param item Item to search. - * @return Item position, -1 if not found. + * @param item Item. + * @return Item index, -1 if not found. */ - getItemPosition(item: Partial): number { - const itemId = this.getItemId(item); - const index = this.items?.findIndex((listItem) => itemId === this.getItemId(listItem)); + 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 position. + * Get initial item index. * - * @return Initial item position. + * @return Initial item index. */ - getInitialPosition(): number { + getInitialItemIndex(): number { if (!this.initialItem) { return 0; } - return this.getItemPosition(this.initialItem); + return this.getItemIndex(this.initialItem); } /** * Get the ID of an item. * - * @param item Data about the item. + * @param item Item. * @return Item ID. */ - abstract getItemId(item: Partial): string | number; + abstract getItemId(item: Item): string | number; } diff --git a/src/core/classes/items-management/slides-items-manager.ts b/src/core/classes/items-management/swipe-slides-items-manager.ts similarity index 66% rename from src/core/classes/items-management/slides-items-manager.ts rename to src/core/classes/items-management/swipe-slides-items-manager.ts index 8c01ef571..5cd254b70 100644 --- a/src/core/classes/items-management/slides-items-manager.ts +++ b/src/core/classes/items-management/swipe-slides-items-manager.ts @@ -13,7 +13,7 @@ // limitations under the License. import { CoreItemsManager } from './items-manager'; -import { CoreSwipeSlidesItemsManagerSource } from './slides-items-manager-source'; +import { CoreSwipeSlidesItemsManagerSource } from './swipe-slides-items-manager-source'; /** * Helper class to manage items for core-swipe-slides. @@ -26,22 +26,8 @@ export class CoreSwipeSlidesItemsManager< /** * @inheritdoc */ - protected onSourceItemsUpdated(items: Item[]): void { - this.itemsMap = items.reduce((map, item) => { - map[this.getSource().getItemId(item)] = item; - - return map; - }, {}); - } - - /** - * Get item by ID. - * - * @param id ID - * @return Item, null if not found. - */ - getItemById(id: string): Item | null { - return this.itemsMap?.[id] ?? null; + getItemId(item: Item): string | number { + return this.getSource().getItemId(item); } } 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 index 461366344..fc18d8c18 100644 --- a/src/core/components/swipe-slides/swipe-slides.html +++ b/src/core/components/swipe-slides/swipe-slides.html @@ -1,6 +1,5 @@ - - + + diff --git a/src/core/components/swipe-slides/swipe-slides.ts b/src/core/components/swipe-slides/swipe-slides.ts index ac88f6e8e..139393670 100644 --- a/src/core/components/swipe-slides/swipe-slides.ts +++ b/src/core/components/swipe-slides/swipe-slides.ts @@ -15,9 +15,10 @@ import { Component, ContentChild, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, Output, TemplateRef, ViewChild, } from '@angular/core'; -import { CoreSwipeSlidesItemsManager } from '@classes/items-management/slides-items-manager'; +import { CoreSwipeSlidesItemsManager } from '@classes/items-management/swipe-slides-items-manager'; import { IonContent, IonSlides } from '@ionic/angular'; -import { CoreDomUtils } from '@services/utils/dom'; +import { CoreDomUtils, VerticalPoint } from '@services/utils/dom'; +import { CoreMath } from '@singletons/math'; /** * Helper component to display swipable slides. @@ -31,7 +32,6 @@ export class CoreSwipeSlidesComponent implements OnChanges, OnDe @Input() manager?: CoreSwipeSlidesItemsManager; @Input() options: CoreSwipeSlidesOptions = {}; - @Output() onInit = new EventEmitter>(); @Output() onWillChange = new EventEmitter>(); @Output() onDidChange = new EventEmitter>(); @@ -57,6 +57,14 @@ export class CoreSwipeSlidesComponent implements OnChanges, OnDe } } + get items(): Item[] { + return this.manager?.getSource().getItems() || []; + } + + get loaded(): boolean { + return !!this.manager?.getSource().isLoaded(); + } + /** * Initialize some properties based on the manager. */ @@ -66,15 +74,15 @@ export class CoreSwipeSlidesComponent implements OnChanges, OnDe }); // Don't call default callbacks on init, emit our own events instead. - // This is because default callbacks aren't triggered for position 0, and to prevent auto scroll on init. + // 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().waitForInitialized(); + await manager.getSource().waitForLoaded(); if (this.options.initialSlide === undefined) { // Calculate the initial slide. - const position = manager.getSource().getInitialPosition(); - this.options.initialSlide = position > - 1 ? position : 0; + const index = manager.getSource().getInitialItemIndex(); + this.options.initialSlide = Math.max(index, 0); } // Emit change events with the initial item. @@ -83,28 +91,23 @@ export class CoreSwipeSlidesComponent implements OnChanges, OnDe return; } - // Validate that the initial position is inside the valid range. - let initialPosition = this.options.initialSlide as number; - if (initialPosition < 0) { - initialPosition = 0; - } else if (initialPosition >= items.length) { - initialPosition = items.length - 1; - } + // 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: initialPosition, - item: items[initialPosition], + index: initialIndex, + item: items[initialIndex], }; - manager.setSelectedItem(items[initialPosition]); + manager.setSelectedItem(items[initialIndex]); this.onWillChange.emit(initialItemData); this.onDidChange.emit(initialItemData); } /** - * Slide to a certain position. + * Slide to a certain index. * - * @param index Position. + * @param index Index. * @param speed Animation speed. * @param runCallbacks Whether to run callbacks. */ @@ -119,8 +122,8 @@ export class CoreSwipeSlidesComponent implements OnChanges, OnDe * @param speed Animation speed. * @param runCallbacks Whether to run callbacks. */ - slideToItem(item: Partial, speed?: number, runCallbacks?: boolean): void { - const index = this.manager?.getSource().getItemPosition(item) ?? -1; + 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); } @@ -146,29 +149,20 @@ export class CoreSwipeSlidesComponent implements OnChanges, OnDe this.slides?.slidePrev(speed, runCallbacks); } - /** - * Get current item. - * - * @return Current item. Undefined if no current item yet. - */ - getCurrentItem(): Item | null { - return this.manager?.getSelectedItem() || null; - } - /** * Called when items list has been updated. * * @param items New items. */ protected onItemsUpdated(): void { - const currentItem = this.getCurrentItem(); + 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().getItemPosition(currentItem) ?? -1; + const newIndex = this.manager.getSource().getItemIndex(currentItem) ?? -1; if (newIndex != -1) { this.slides?.slideTo(newIndex, 0, false); } @@ -194,7 +188,7 @@ export class CoreSwipeSlidesComponent implements OnChanges, OnDe // 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, 'top')) { + if (!scrollElement || CoreDomUtils.isElementOutsideOfScreen(scrollElement, this.hostElement, VerticalPoint.TOP)) { // Scroll to top. this.hostElement.scrollIntoView({ behavior: 'smooth' }); } diff --git a/src/core/features/user/pages/profile/profile.page.ts b/src/core/features/user/pages/profile/profile.page.ts index ee7c6e39c..69a8fcd26 100644 --- a/src/core/features/user/pages/profile/profile.page.ts +++ b/src/core/features/user/pages/profile/profile.page.ts @@ -27,7 +27,7 @@ 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 { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; @@ -227,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 ba8d78d07..9b22c544e 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -772,7 +772,11 @@ export class CoreDomUtilsProvider { * @param point The point of the element to check. * @return Whether the element is outside of the viewport. */ - isElementOutsideOfScreen(scrollEl: HTMLElement, element: HTMLElement, point: 'top' | 'mid' | 'bottom' = 'mid'): boolean { + isElementOutsideOfScreen( + scrollEl: HTMLElement, + element: HTMLElement, + point: VerticalPoint = VerticalPoint.MID, + ): boolean { const elementRect = element.getBoundingClientRect(); if (!elementRect) { @@ -780,12 +784,18 @@ export class CoreDomUtilsProvider { } let elementPoint: number; - if (point === 'top') { - elementPoint = elementRect.top; - } else if (point === 'bottom') { - elementPoint = elementRect.bottom; - } else { - elementPoint = Math.round((elementRect.bottom + elementRect.top) / 2); + 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(); @@ -2098,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', +} diff --git a/src/core/services/utils/time.ts b/src/core/services/utils/time.ts index 5c8e420de..684c741b0 100644 --- a/src/core/services/utils/time.ts +++ b/src/core/services/utils/time.ts @@ -389,161 +389,6 @@ export class CoreTimeUtilsProvider { return String(moment().year() - 20); } - /** - * Given a year and a month, return the previous month and its year. - * - * @param month Year and month. - * @return Previous month and its year. - */ - getPreviousMonth(month: YearAndMonth): YearAndMonth { - const dayMoment = moment().year(month.year).month(month.monthNumber - 1); - dayMoment.subtract(1, 'month'); - - return { - year: dayMoment.year(), - monthNumber: dayMoment.month() + 1, - }; - } - - /** - * Given a year and a month, return the next month and its year. - * - * @param month Year and month. - * @return Next month and its year. - */ - getNextMonth(month: YearAndMonth): YearAndMonth { - const dayMoment = moment().year(month.year).month(month.monthNumber - 1); - dayMoment.add(1, 'month'); - - return { - year: dayMoment.year(), - monthNumber: dayMoment.month() + 1, - }; - } - - /** - * Get current month. - * - * @return Current month. - */ - getCurrentMonth(): YearAndMonth { - const now = new Date(); - - return { - year: now.getFullYear(), - monthNumber: now.getMonth() + 1, - }; - } - - /** - * Check if a certain month is a past month. - * - * @param month Year and month. - * @return Whether it's a past month. - */ - isPastMonth(month: YearAndMonth): boolean { - const now = new Date(); - - return month.year < now.getFullYear() || (month.year == now.getFullYear() && month.monthNumber < now.getMonth() + 1); - } - - /** - * @param monthA Month A. - * @param monthB Month B. - * @return Whether it's same month. - */ - isSameMonth(monthA: YearAndMonth, monthB: YearAndMonth): boolean { - return monthA.monthNumber === monthB.monthNumber && monthA.year === monthB.year; - } - - /** - * Given a day data, return the previous day. - * - * @param day Day. - * @return Previous day. - */ - getPreviousDay(day: YearMonthDay): YearMonthDay { - const dayMoment = moment().year(day.year).month(day.monthNumber - 1).date(day.dayNumber); - dayMoment.subtract(1, 'day'); - - return { - year: dayMoment.year(), - monthNumber: dayMoment.month() + 1, - dayNumber: dayMoment.date(), - }; - } - - /** - * Given a day data, return the next day. - * - * @param day Day. - * @return Next day. - */ - getNextDay(day: YearMonthDay): YearMonthDay { - const dayMoment = moment().year(day.year).month(day.monthNumber - 1).date(day.dayNumber); - dayMoment.add(1, 'day'); - - return { - year: dayMoment.year(), - monthNumber: dayMoment.month() + 1, - dayNumber: dayMoment.date(), - }; - } - - /** - * Get current day. - * - * @return Current day. - */ - getCurrentDay(): YearMonthDay { - const now = new Date(); - - return { - year: now.getFullYear(), - monthNumber: now.getMonth() + 1, - dayNumber: now.getDate(), - }; - } - - /** - * Check if a certain day is a past day. - * - * @param day Day. - * @return Whether it's a past day. - */ - isPastDay(day: YearMonthDay): boolean { - const now = new Date(); - - return day.year < now.getFullYear() || (day.year === now.getFullYear() && day.monthNumber < now.getMonth() + 1) || - (day.year === now.getFullYear() && day.monthNumber === now.getMonth() + 1 && day.dayNumber < now.getDate()); - } - - /** - * Check if two days are the same. - * - * @param dayA Day A. - * @param dayB Day B. - * @return Whether it's same day. - */ - isSameDay(dayA: YearMonthDay, dayB: YearMonthDay): boolean { - return dayA.dayNumber === dayB.dayNumber && dayA.monthNumber === dayB.monthNumber && dayA.year === dayB.year; - } - } export const CoreTimeUtils = makeSingleton(CoreTimeUtilsProvider); - -/** - * Data to identify a month. - */ -export type YearAndMonth = { - year: number; // Year number. - monthNumber: number; // Month number (1 to 12). -}; - -/** - * Data to identify a day. - */ -export type YearMonthDay = YearAndMonth & { - dayNumber: number; // Day number. -};