diff --git a/src/core/components/tabs-outlet/tabs-outlet.ts b/src/core/components/tabs-outlet/tabs-outlet.ts index 54da61022..d7575f2fe 100644 --- a/src/core/components/tabs-outlet/tabs-outlet.ts +++ b/src/core/components/tabs-outlet/tabs-outlet.ts @@ -62,7 +62,7 @@ export class CoreTabsOutletComponent extends CoreTabsBaseComponent { + this.stackEventsSubscription = this.ionTabs.outlet.stackEvents.subscribe(async (stackEvent: StackEvent) => { if (!this.isCurrentView) { return; } + // Add tabid to the tab content element. + if (stackEvent.enteringView.element.id == '') { + const tab = this.tabs.find((tab) => tab.page == stackEvent.enteringView.url); + stackEvent.enteringView.element.id = tab?.id || ''; + } + this.showHideNavBarButtons(stackEvent.enteringView.element.tagName); await this.listenContentScroll(stackEvent.enteringView.element, stackEvent.enteringView.id); @@ -111,8 +117,8 @@ export class CoreTabsOutletComponent extends CoreTabsBaseComponent { - this.lastActiveComponent = this.ionTabs?.outlet.component; + this.outletActivatedSubscription = this.ionTabs.outlet.activateEvents.subscribe(() => { + this.lastActiveComponent = this.ionTabs.outlet.component; }); } @@ -140,8 +146,8 @@ export class CoreTabsOutletComponent extends CoreTabsBaseComponent).ionViewDidEnter?.(); + if (this.existsInNavigationStack && this.ionTabs.outlet.isActivated) { + (this.ionTabs.outlet.component as Partial).ionViewDidEnter?.(); } // After the view has entered for the first time, we can assume that it'll always be in the navigation stack @@ -180,10 +186,9 @@ export class CoreTabsOutletComponent extends CoreTabsBaseComponent { - const instance = domUtils.getInstanceByElement(element); + const instance = CoreDomUtils.getInstanceByElement(element); if (instance) { const pagetagName = element.closest('.ion-page')?.tagName; diff --git a/src/core/directives/collapsible-header.ts b/src/core/directives/collapsible-header.ts index 24ba81a03..e0927de10 100644 --- a/src/core/directives/collapsible-header.ts +++ b/src/core/directives/collapsible-header.ts @@ -43,6 +43,10 @@ export class CoreCollapsibleHeaderDirective implements OnDestroy { protected headerSubHeadingFontSize = 0; protected contentSubHeadingFontSize = 0; protected subHeadingStartDifference = 0; + protected inContent = true; + protected title?: HTMLElement | null; + protected titleHeight = 0; + protected contentH1?: HTMLElement | null; constructor(el: ElementRef) { this.header = el.nativeElement; @@ -67,8 +71,6 @@ export class CoreCollapsibleHeaderDirective implements OnDestroy { /** * Gets the loading content id to wait for the loading to finish. * - * @TODO: If no core-loading is present, load directly. Take into account content needs to be initialized. - * * @return Promise resolved with Loading Id, if any. */ protected async getLoadingId(): Promise { @@ -80,6 +82,17 @@ export class CoreCollapsibleHeaderDirective implements OnDestroy { return; } + + const title = this.header.parentElement?.querySelector('.collapsible-title') || null; + + if (title) { + // Title already found, no need to wait for loading. + this.loadingObserver.off(); + this.setupRealTitle(); + + return; + } + } return this.content.querySelector('core-loading.core-loading-loaded:not(.core-loading-inline) .core-loading-content')?.id; @@ -89,6 +102,7 @@ export class CoreCollapsibleHeaderDirective implements OnDestroy { * Call this function when header is not collapsible. */ protected cannotCollapse(): void { + this.content = undefined; this.loadingObserver.off(); this.header.classList.add('core-header-collapsed'); } @@ -112,16 +126,25 @@ export class CoreCollapsibleHeaderDirective implements OnDestroy { await animation.finished; })); - const title = this.content.querySelector('.collapsible-title, h1'); - const contentH1 = this.content.querySelector('h1'); + let title = this.content.querySelector('.collapsible-title'); + if (!title) { + // Title is outside the ion-content. + title = this.header.parentElement?.querySelector('.collapsible-title') || null; + this.inContent = false; + } + this.contentH1 = title?.querySelector('h1'); const headerH1 = this.header.querySelector('h1'); - if (!title || !contentH1 || !headerH1) { + if (!title || !this.contentH1 || !headerH1 || !this.contentH1.parentElement) { this.cannotCollapse(); return; } - this.titleTopDifference = contentH1.getBoundingClientRect().top - headerH1.getBoundingClientRect().top; + this.title = title; + this.titleHeight = title.getBoundingClientRect().height; + + this.titleTopDifference = this.contentH1.getBoundingClientRect().top - headerH1.getBoundingClientRect().top; + if (this.titleTopDifference <= 0) { this.cannotCollapse(); @@ -141,17 +164,17 @@ export class CoreCollapsibleHeaderDirective implements OnDestroy { } const headerH1Styles = getComputedStyle(headerH1); - const contentH1Styles = getComputedStyle(contentH1); + const contentH1Styles = getComputedStyle(this.contentH1); if (Platform.isRTL) { // Checking position over parent because transition may not be finished. - const contentH1Position = contentH1.getBoundingClientRect().right - this.content.getBoundingClientRect().right; + const contentH1Position = this.contentH1.getBoundingClientRect().right - this.content.getBoundingClientRect().right; const headerH1Position = headerH1.getBoundingClientRect().right - this.header.getBoundingClientRect().right; this.h1StartDifference = Math.round(contentH1Position - (headerH1Position - parseFloat(headerH1Styles.paddingRight))); } else { // Checking position over parent because transition may not be finished. - const contentH1Position = contentH1.getBoundingClientRect().left - this.content.getBoundingClientRect().left; + const contentH1Position = this.contentH1.getBoundingClientRect().left - this.content.getBoundingClientRect().left; const headerH1Position = headerH1.getBoundingClientRect().left - this.header.getBoundingClientRect().left; this.h1StartDifference = Math.round(contentH1Position - (headerH1Position + parseFloat(headerH1Styles.paddingLeft))); @@ -165,10 +188,10 @@ export class CoreCollapsibleHeaderDirective implements OnDestroy { if (styleName != 'font-size' && styleName != 'font-family' && (styleName.startsWith('font-') || styleName.startsWith('letter-'))) { - contentH1.style.setProperty(styleName, headerH1Styles.getPropertyValue(styleName)); + this.contentH1?.style.setProperty(styleName, headerH1Styles.getPropertyValue(styleName)); } }); - contentH1.style.setProperty( + this.contentH1.style.setProperty( '--max-width', (parseFloat(headerH1Styles.width) -parseFloat(headerH1Styles.paddingLeft) @@ -176,47 +199,70 @@ export class CoreCollapsibleHeaderDirective implements OnDestroy { +'px'), ); - contentH1.setAttribute('aria-hidden', 'true'); + this.contentH1.setAttribute('aria-hidden', 'true'); + + // Clone element to let the other elements be static. + const contentH1Clone = this.contentH1.cloneNode(true) as HTMLElement; + contentH1Clone.classList.add('cloned'); + this.contentH1.parentElement.insertBefore(contentH1Clone, this.contentH1); + this.contentH1.style.setProperty( + 'top', + (contentH1Clone.getBoundingClientRect().top - + this.contentH1.parentElement.getBoundingClientRect().top + + parseInt(getComputedStyle(this.contentH1.parentElement).marginTop || '0', 10)) + 'px', + ); + this.contentH1.style.setProperty('position', 'absolute'); + + this.setupContent(); + } + + /** + * Setup content scroll. + * + * @param parentId Parent id to recalculate content + * @param retries Retries to find content in case it's loading. + */ + async setupContent(parentId?: string, retries = 5): Promise { + if (parentId) { + this.content = this.header.parentElement?.querySelector(`#${parentId} ion-content:not(.disable-scroll-y)`); + this.inContent = false; + if (!this.content && retries > 0) { + await CoreUtils.nextTick(); + await this.setupContent(parentId, --retries); + + return; + } + + this.onScroll(this.content?.scrollTop || 0); + } + + if (!this.title || !this.content) { + return; + } // Add something under the hood to change the page background. - let color = getComputedStyle(title).getPropertyValue('backgroundColor').trim(); + let color = getComputedStyle(this.title).getPropertyValue('backgroundColor').trim(); if (color == '') { - color = getComputedStyle(title).getPropertyValue('--background').trim(); + color = getComputedStyle(this.title).getPropertyValue('--background').trim(); } const underHeader = document.createElement('div'); underHeader.classList.add('core-underheader'); underHeader.style.setProperty('height', this.header.clientHeight + 'px'); underHeader.style.setProperty('background', color); - this.content.shadowRoot?.querySelector('#background-content')?.prepend(underHeader); - - this.content.style.setProperty('--offset-top', this.header.clientHeight + 'px'); - - // Subheading. - const headerSubHeading = this.header.querySelector('h2,.subheading'); - const contentSubHeading = title.querySelector('h2,.subheading'); - if (headerSubHeading && contentSubHeading) { - const headerSubHeadingStyles = getComputedStyle(headerSubHeading); - this.headerSubHeadingFontSize = parseFloat(headerSubHeadingStyles.fontSize); - - const contentSubHeadingStyles = getComputedStyle(contentSubHeading); - this.contentSubHeadingFontSize = parseFloat(contentSubHeadingStyles.fontSize); - - if (Platform.isRTL) { - this.subHeadingStartDifference = contentSubHeading.getBoundingClientRect().right - - (headerSubHeading.getBoundingClientRect().right - parseFloat(headerSubHeadingStyles.paddingRight)); - } else { - this.subHeadingStartDifference = contentSubHeading.getBoundingClientRect().left - - (headerSubHeading.getBoundingClientRect().left + parseFloat(headerSubHeadingStyles.paddingLeft)); + if (this.inContent) { + this.content.shadowRoot?.querySelector('#background-content')?.prepend(underHeader); + this.content.style.setProperty('--offset-top', this.header.clientHeight + 'px'); + } else { + if (!this.header.closest('.ion-page')?.querySelector('.core-underheader')) { + this.header.closest('.ion-page')?.insertBefore(underHeader, this.header); } - - contentSubHeading.setAttribute('aria-hidden', 'true'); } this.content.scrollEvents = true; this.content.addEventListener('ionScroll', (e: CustomEvent): void => { if (e.target == this.content) { - this.onScroll(title, contentH1, contentSubHeading, e.detail); + this.onScroll(e.detail.scrollTop); } }); } @@ -224,54 +270,45 @@ export class CoreCollapsibleHeaderDirective implements OnDestroy { /** * On scroll function. * - * @param title Title on ion content. - * @param contentH1 Heading 1 of title, if found. - * @param scrollDetail Event details. + * @param scrollTop Scroll top measure. */ protected onScroll( - title: HTMLElement, - contentH1: HTMLElement, - contentSubheading: HTMLElement | null, - scrollDetail: ScrollDetail, + scrollTop: number, ): void { - const progress = CoreMath.clamp(scrollDetail.scrollTop / this.titleTopDifference, 0, 1); + if (!this.title || !this.contentH1) { + return; + } + + const progress = CoreMath.clamp(scrollTop / this.titleTopDifference, 0, 1); const collapsed = progress >= 1; + if (!this.inContent) { + this.title.style.transform = 'translateY(-' + scrollTop + 'px)'; + const height = this.titleHeight - scrollTop; + this.title.style.height = (height > 0 ? height : 0) + 'px'; + } + // Check total collapse. this.header.classList.toggle('core-header-collapsed', collapsed); - title.classList.toggle('collapsible-title-collapsed', collapsed); - title.classList.toggle('collapsible-title-collapse-started', scrollDetail.scrollTop > 0); - title.classList.toggle('collapsible-title-collapse-nowrap', progress > 0.5); - title.style.setProperty('--collapse-opacity', (1 - progress) +''); + this.title.classList.toggle('collapsible-title-collapsed', collapsed); + this.title.classList.toggle('collapsible-title-collapse-started', scrollTop > 0); + this.title.classList.toggle('collapsible-title-collapse-nowrap', progress > 0.5); + this.title.style.setProperty('--collapse-opacity', (1 - progress) +''); if (collapsed) { - contentH1.style.transform = 'translateX(-' + this.h1StartDifference + 'px)'; - contentH1.style.setProperty('font-size', this.headerH1FontSize + 'px'); - - if (contentSubheading) { - contentSubheading.style.transform = 'translateX(-' + this.subHeadingStartDifference + 'px)'; - contentSubheading.style.setProperty('font-size', this.headerSubHeadingFontSize + 'px'); - } + this.contentH1.style.transform = 'translateX(-' + this.h1StartDifference + 'px)'; + this.contentH1.style.setProperty('font-size', this.headerH1FontSize + 'px'); return; } // Zoom font-size out. const newFontSize = this.contentH1FontSize - ((this.contentH1FontSize - this.headerH1FontSize) * progress); - contentH1.style.setProperty('font-size', newFontSize + 'px'); + this.contentH1.style.setProperty('font-size', newFontSize + 'px'); // Move. const newStart = - this.h1StartDifference * progress; - contentH1.style.transform = 'translateX(' + newStart + 'px)'; - - if (contentSubheading) { - const newFontSize = this.contentSubHeadingFontSize - - ((this.contentSubHeadingFontSize - this.headerSubHeadingFontSize) * progress); - contentSubheading.style.setProperty('font-size', newFontSize + 'px'); - - const newStart = - this.subHeadingStartDifference * progress; - contentSubheading.style.transform = 'translateX(' + newStart + 'px)'; - } + this.contentH1.style.transform = 'translateX(' + newStart + 'px)'; } /** diff --git a/src/core/features/block/components/side-blocks-button/side-blocks-button.scss b/src/core/features/block/components/side-blocks-button/side-blocks-button.scss index 579196d50..76b104a1a 100644 --- a/src/core/features/block/components/side-blocks-button/side-blocks-button.scss +++ b/src/core/features/block/components/side-blocks-button/side-blocks-button.scss @@ -4,6 +4,7 @@ @include position(50%, 0px, null, null); position: fixed; z-index: 10; + transform: translateY(-50%); ion-button { margin: 0; diff --git a/src/core/features/course/components/format/core-course-format.html b/src/core/features/course/components/format/core-course-format.html index 9ecb91ece..3249dad88 100644 --- a/src/core/features/course/components/format/core-course-format.html +++ b/src/core/features/course/components/format/core-course-format.html @@ -10,30 +10,6 @@ - - - - - - - -

- - -

-

{{ course.displayname || course.fullname }}

-
- - -
-
- - - -
-
-
@@ -72,15 +48,15 @@ - - - - + + + + - + {{'core.course.courseindex' | translate }} @@ -121,8 +97,7 @@ + [showActivityDates]="course.showactivitydates" [showCompletionConditions]="course.showcompletionconditions"> diff --git a/src/core/features/course/components/format/format.scss b/src/core/features/course/components/format/format.scss index 25f43001b..f6444fb72 100644 --- a/src/core/features/course/components/format/format.scss +++ b/src/core/features/course/components/format/format.scss @@ -1,55 +1,11 @@ -@import '~theme/globals.scss'; +.core-course-section-nav-buttons { + display: flex; + justify-content: flex-end; -:host { - - .core-format-progress-list { - margin-bottom: 0; - - .item { - background: transparent; - - .label { - margin-top: 0; - margin-bottom: 0; - } - } + core-format-text { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + text-transform: none; } - - .core-course-thumb { - height: var(--core-courseimage-on-course-size); - width: var(--core-courseimage-on-course-size); - } - - @if ($core-show-courseimage-on-course) { - .core-course-thumb { - display: block; - } - } - - @if ($core-hide-progress-on-course) { - .core-course-progress { - display: none; - } - } - - - .core-button-selector-row { - display: flex; - core-combobox { - flex-grow: 1; - } - } - - .core-course-section-nav-buttons { - display: flex; - justify-content: flex-end; - - core-format-text { - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - text-transform: none; - } - } - } diff --git a/src/core/features/course/components/format/format.ts b/src/core/features/course/components/format/format.ts index e7efb6bc4..1cdc25197 100644 --- a/src/core/features/course/components/format/format.ts +++ b/src/core/features/course/components/format/format.ts @@ -68,7 +68,7 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { static readonly LOAD_MORE_ACTIVITIES = 20; // How many activities should load each time showMoreActivities is called. @Input() course!: CoreCourseAnyCourseData; // The course to render. - @Input() sections?: CoreCourseSection[]; // List of course sections. + @Input() sections: CoreCourseSectionWithStatus[] = []; // List of course sections. @Input() initialSectionId?: number; // The section to load first (by ID). @Input() initialSectionNumber?: number; // The section to load first (by number). @Input() moduleId?: number; // The module ID to scroll to. Must be inside the initial selected section. @@ -95,10 +95,10 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { allSectionsId: number = CoreCourseProvider.ALL_SECTIONS_ID; stealthModulesSectionId: number = CoreCourseProvider.STEALTH_MODULES_SECTION_ID; loaded = false; - imageThumb?: string; progress?: number; protected selectTabObserver?: CoreEventObserver; + protected completionObserver?: CoreEventObserver; protected lastCourseFormat?: string; constructor( @@ -139,6 +139,37 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { this.sectionChanged(section); } }); + + // The completion of any of the modules have changed. + this.completionObserver = CoreEvents.on(CoreEvents.COMPLETION_CHANGED, (data) => { + if (data.completion.courseId != this.course.id) { + return; + } + + // Emit a new event for other components. + this.completionChanged.emit(data.completion); + + if (data.completion.valueused !== false || !this.course || !('progress' in this.course) || + typeof this.course.progress != 'number') { + return; + } + + // If the completion value is not used, the page won't be reloaded, so update the progress bar. + const completionModules = ( []) + .concat(...this.sections.map((section) => section.modules)) + .map((module) => module.completion && module.completion > 0 ? 1 : module.completion) + .reduce((accumulator, currentValue) => (accumulator || 0) + (currentValue || 0), 0); + + const moduleProgressPercent = 100 / (completionModules || 1); + // Use min/max here to avoid floating point rounding errors over/under-flowing the progress bar. + if (data.completion.state === CoreCourseModuleCompletionStatus.COMPLETION_COMPLETE) { + this.course.progress = Math.min(100, this.course.progress + moduleProgressPercent); + } else { + this.course.progress = Math.max(0, this.course.progress - moduleProgressPercent); + } + + this.updateProgress(); + }); } /** @@ -157,10 +188,6 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { this.hasBlocks = await CoreBlockHelper.hasCourseBlocks(this.course.id); this.updateProgress(); - - if ('overviewfiles' in this.course) { - this.imageThumb = this.course.overviewfiles?.[0]?.fileurl; - } } if (changes.sections && this.sections) { @@ -246,8 +273,9 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { const hasSeveralSections = sections.length > 2 || (sections.length == 2 && !hasAllSections); if (this.selectedSection) { + const selectedSection = this.selectedSection; // We have a selected section, but the list has changed. Search the section in the list. - let newSection = sections.find(section => this.compareSections(section, this.selectedSection!)); + let newSection = sections.find(section => this.compareSections(section, selectedSection)); if (!newSection) { // Section not found, calculate which one to use. @@ -317,22 +345,22 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { if (newSection.id != this.allSectionsId) { // Select next and previous sections to show the arrows. - const i = this.sections!.findIndex((value) => this.compareSections(value, this.selectedSection!)); + const i = this.sections.findIndex((value) => this.compareSections(value, newSection)); let j: number; for (j = i - 1; j >= 1; j--) { - if (this.canViewSection(this.sections![j])) { + if (this.canViewSection(this.sections[j])) { break; } } - this.previousSection = j >= 1 ? this.sections![j] : undefined; + this.previousSection = j >= 1 ? this.sections[j] : undefined; - for (j = i + 1; j < this.sections!.length; j++) { - if (this.canViewSection(this.sections![j])) { + for (j = i + 1; j < this.sections.length; j++) { + if (this.canViewSection(this.sections[j])) { break; } } - this.nextSection = j < this.sections!.length ? this.sections![j] : undefined; + this.nextSection = j < this.sections.length ? this.sections[j] : undefined; } else { this.previousSection = undefined; this.nextSection = undefined; @@ -463,7 +491,7 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { } /** - * Component destroyed. + * @inheritdoc */ ngOnDestroy(): void { this.selectTabObserver && this.selectTabObserver.off(); @@ -498,35 +526,6 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { section.id != CoreCourseProvider.STEALTH_MODULES_SECTION_ID; } - /** - * The completion of any of the modules have changed. - */ - onCompletionChange(completionData: CoreCourseModuleCompletionData): void { - // Emit a new event for other components. - this.completionChanged.emit(completionData); - - if (completionData.valueused !== false || !this.course || !('progress' in this.course) || - typeof this.course.progress != 'number') { - return; - } - - // If the completion value is not used, the page won't be reloaded, so update the progress bar. - const completionModules = ( []) - .concat(...this.sections!.map((section) => section.modules)) - .map((module) => module.completion && module.completion > 0 ? 1 : module.completion) - .reduce((accumulator, currentValue) => (accumulator || 0) + (currentValue || 0), 0); - - const moduleProgressPercent = 100 / (completionModules || 1); - // Use min/max here to avoid floating point rounding errors over/under-flowing the progress bar. - if (completionData.state === CoreCourseModuleCompletionStatus.COMPLETION_COMPLETE) { - this.course.progress = Math.min(100, this.course.progress + moduleProgressPercent); - } else { - this.course.progress = Math.max(0, this.course.progress - moduleProgressPercent); - } - - this.updateProgress(); - } - /** * Update course progress. */ @@ -546,14 +545,4 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { this.progress = this.course.progress; } - /** - * Open the course summary - */ - openCourseSummary(): void { - CoreNavigator.navigateToSitePath( - '/course/' + this.course.id + '/preview', - { params: { course: this.course, avoidOpenCourse: true } }, - ); - } - } diff --git a/src/core/features/course/components/module-completion-legacy/module-completion-legacy.ts b/src/core/features/course/components/module-completion-legacy/module-completion-legacy.ts index 6bf6d2414..023967422 100644 --- a/src/core/features/course/components/module-completion-legacy/module-completion-legacy.ts +++ b/src/core/features/course/components/module-completion-legacy/module-completion-legacy.ts @@ -12,14 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { CoreUser } from '@features/user/services/user'; -import { CoreCourseModuleCompletionStatus, CoreCourseModuleCompletionTracking } from '@features/course/services/course'; +import { + CoreCourseCompletionType, + CoreCourseModuleCompletionStatus, + CoreCourseModuleCompletionTracking, +} from '@features/course/services/course'; import { CoreFilterHelper } from '@features/filter/services/filter-helper'; import { Translate } from '@singletons'; import { CoreCourseModuleCompletionBaseComponent } from '@features/course/classes/module-completion'; import { CoreCourseHelper } from '@features/course/services/course-helper'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; /** * Component to handle activity completion in sites previous to 3.11. @@ -35,11 +40,29 @@ import { CoreCourseHelper } from '@features/course/services/course-helper'; templateUrl: 'core-course-module-completion-legacy.html', styleUrls: ['module-completion-legacy.scss'], }) -export class CoreCourseModuleCompletionLegacyComponent extends CoreCourseModuleCompletionBaseComponent { +export class CoreCourseModuleCompletionLegacyComponent extends CoreCourseModuleCompletionBaseComponent + implements OnInit, OnDestroy { completionImage?: string; completionDescription?: string; + protected completionObserver?: CoreEventObserver; + + /** + * @inheritdoc + */ + ngOnInit(): void { + this.completionObserver = CoreEvents.on(CoreEvents.COMPLETION_CHANGED, (data) => { + if (!this.completion || this.completion.cmid != data.completion.cmid && data.type != CoreCourseCompletionType.MANUAL) { + return; + } + + this.completion = data.completion; + this.calculateData(); + this.completionChanged.emit(this.completion); + }); + } + /** * @inheritdoc */ @@ -126,9 +149,16 @@ export class CoreCourseModuleCompletionLegacyComponent extends CoreCourseModuleC await CoreCourseHelper.changeManualCompletion(this.completion, event); - this.calculateData(); + // @deprecated MANUAL_COMPLETION_CHANGED is deprecated since 4.0 use COMPLETION_CHANGED instead. + CoreEvents.trigger(CoreEvents.MANUAL_COMPLETION_CHANGED, { completion: this.completion }); + CoreEvents.trigger(CoreEvents.COMPLETION_CHANGED, { completion: this.completion, type: CoreCourseCompletionType.MANUAL }); + } - this.completionChanged.emit(this.completion); + /** + * @inheritdoc + */ + ngOnDestroy(): void { + this.completionObserver?.off(); } } diff --git a/src/core/features/course/components/module-manual-completion/module-manual-completion.ts b/src/core/features/course/components/module-manual-completion/module-manual-completion.ts index ede613119..926349adf 100644 --- a/src/core/features/course/components/module-manual-completion/module-manual-completion.ts +++ b/src/core/features/course/components/module-manual-completion/module-manual-completion.ts @@ -13,6 +13,7 @@ // limitations under the License. import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChange } from '@angular/core'; +import { CoreCourseCompletionType } from '@features/course/services/course'; import { CoreCourseHelper, CoreCourseModuleCompletionData } from '@features/course/services/course-helper'; import { CoreUser } from '@features/user/services/user'; @@ -34,14 +35,14 @@ export class CoreCourseModuleManualCompletionComponent implements OnInit, OnChan accessibleDescription: string | null = null; - protected manualChangedObserver?: CoreEventObserver; + protected completionObserver?: CoreEventObserver; /** * @inheritdoc */ ngOnInit(): void { - this.manualChangedObserver = CoreEvents.on(CoreEvents.MANUAL_COMPLETION_CHANGED, (data) => { - if (!this.completion || this.completion.cmid != data.completion.cmid) { + this.completionObserver = CoreEvents.on(CoreEvents.COMPLETION_CHANGED, (data) => { + if (!this.completion || this.completion.cmid != data.completion.cmid && data.type != CoreCourseCompletionType.MANUAL) { return; } @@ -98,14 +99,16 @@ export class CoreCourseModuleManualCompletionComponent implements OnInit, OnChan await CoreCourseHelper.changeManualCompletion(this.completion, event); + // @deprecated MANUAL_COMPLETION_CHANGED is deprecated since 4.0 use COMPLETION_CHANGED instead. CoreEvents.trigger(CoreEvents.MANUAL_COMPLETION_CHANGED, { completion: this.completion }); + CoreEvents.trigger(CoreEvents.COMPLETION_CHANGED, { completion: this.completion, type: CoreCourseCompletionType.MANUAL }); } /** * @inheritdoc */ ngOnDestroy(): void { - this.manualChangedObserver?.off(); + this.completionObserver?.off(); } } diff --git a/src/core/features/course/pages/contents/contents.html b/src/core/features/course/pages/contents/contents.html index 676c330b5..88716c7ea 100644 --- a/src/core/features/course/pages/contents/contents.html +++ b/src/core/features/course/pages/contents/contents.html @@ -15,7 +15,8 @@ + [moduleId]="moduleId" (completionChanged)="onCompletionChange($event)" class="core-course-format-{{course.format}}" + *ngIf="dataLoaded"> diff --git a/src/core/features/course/pages/index/index.html b/src/core/features/course/pages/index/index.html index b1776e63f..8ca38de7f 100644 --- a/src/core/features/course/pages/index/index.html +++ b/src/core/features/course/pages/index/index.html @@ -1,4 +1,4 @@ - + @@ -12,4 +12,31 @@ - + + + + + + + +

+ + +

+

{{ title }}

+
+ + + + + +
+
+ + +
+
+ +
+ diff --git a/src/core/features/course/pages/index/index.module.ts b/src/core/features/course/pages/index/index.module.ts index 1e1d26938..314528baa 100644 --- a/src/core/features/course/pages/index/index.module.ts +++ b/src/core/features/course/pages/index/index.module.ts @@ -17,7 +17,7 @@ import { RouterModule, ROUTES, Routes } from '@angular/router'; import { resolveModuleRoutes } from '@/app/app-routing.module'; import { CoreSharedModule } from '@/core/shared.module'; -import { CoreCourseIndexPage } from './index.page'; +import { CoreCourseIndexPage } from '.'; import { COURSE_INDEX_ROUTES } from './index-routing.module'; function buildRoutes(injector: Injector): Routes { diff --git a/src/core/features/course/pages/index/index.scss b/src/core/features/course/pages/index/index.scss new file mode 100644 index 000000000..da91e59c2 --- /dev/null +++ b/src/core/features/course/pages/index/index.scss @@ -0,0 +1,26 @@ +@import '~theme/globals.scss'; + +:host { + .core-course-thumb { + height: var(--core-courseimage-on-course-size); + min-height: var(--core-courseimage-on-course-size); + width: var(--core-courseimage-on-course-size); + min-width: var(--core-courseimage-on-course-size); + } + + @if ($core-show-courseimage-on-course) { + .core-course-thumb { + display: block; + } + } + + @if ($core-hide-progress-on-course) { + .core-course-progress { + display: none; + } + } + + h1 { + font-size: 20px; + } +} diff --git a/src/core/features/course/pages/index/index.page.ts b/src/core/features/course/pages/index/index.ts similarity index 69% rename from src/core/features/course/pages/index/index.page.ts rename to src/core/features/course/pages/index/index.ts index 096de4843..2ecf4abab 100644 --- a/src/core/features/course/pages/index/index.page.ts +++ b/src/core/features/course/pages/index/index.ts @@ -26,6 +26,8 @@ import { CoreUtils } from '@services/utils/utils'; import { CoreTextUtils } from '@services/utils/text'; import { CoreNavigator } from '@services/navigator'; import { CONTENTS_PAGE_NAME } from '@features/course/course.module'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreCollapsibleHeaderDirective } from '@directives/collapsible-header'; /** * Page that displays the list of courses the user is enrolled in. @@ -33,23 +35,28 @@ import { CONTENTS_PAGE_NAME } from '@features/course/course.module'; @Component({ selector: 'page-core-course-index', templateUrl: 'index.html', + styleUrls: ['index.scss'], }) export class CoreCourseIndexPage implements OnInit, OnDestroy { @ViewChild(CoreTabsOutletComponent) tabsComponent?: CoreTabsOutletComponent; + @ViewChild(CoreCollapsibleHeaderDirective) ionCollapsibleHeader?: CoreCollapsibleHeaderDirective; - title?: string; + title = ''; + category = ''; course?: CoreCourseAnyCourseData; tabs: CourseTab[] = []; loaded = false; + imageThumb?: string; + progress?: number; protected currentPagePath = ''; protected selectTabObserver: CoreEventObserver; protected firstTabName?: string; protected module?: CoreCourseModuleData; protected modParams?: Params; - protected isGuest?: boolean; - protected contentsTab: CoreTabsOutletTab = { + protected isGuest = false; + protected contentsTab: CoreTabsOutletTab & { pageParams: Params } = { page: CONTENTS_PAGE_NAME, title: 'core.course', pageParams: {}, @@ -60,10 +67,10 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy { if (!data.name) { // If needed, set sectionId and sectionNumber. They'll only be used if the content tabs hasn't been loaded yet. if (data.sectionId) { - this.contentsTab.pageParams!.sectionId = data.sectionId; + this.contentsTab.pageParams.sectionId = data.sectionId; } if (data.sectionNumber) { - this.contentsTab.pageParams!.sectionNumber = data.sectionNumber; + this.contentsTab.pageParams.sectionNumber = data.sectionNumber; } // Select course contents. @@ -79,7 +86,7 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy { } /** - * Component being initialized. + * @inheritdoc */ async ngOnInit(): Promise { // Increase route depth. @@ -87,12 +94,19 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy { CoreNavigator.increaseRouteDepth(path.replace(/(\/deep)+/, '')); - // Get params. - this.course = CoreNavigator.getRouteParam('course'); + try { + this.course = CoreNavigator.getRequiredRouteParam('course'); + } catch (error) { + CoreDomUtils.showErrorModal(error); + CoreNavigator.back(); + + return; + } + this.firstTabName = CoreNavigator.getRouteParam('selectedTab'); this.module = CoreNavigator.getRouteParam('module'); this.modParams = CoreNavigator.getRouteParam('modParams'); - this.isGuest = CoreNavigator.getRouteBooleanParam('isGuest'); + this.isGuest = !!CoreNavigator.getRouteBooleanParam('isGuest'); this.currentPagePath = CoreNavigator.getCurrentPath(); this.contentsTab.page = CoreTextUtils.concatenatePaths(this.currentPagePath, this.contentsTab.page); @@ -104,7 +118,7 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy { }; if (this.module) { - this.contentsTab.pageParams!.moduleId = this.module.id; + this.contentsTab.pageParams.moduleId = this.module.id; } this.tabs.push(this.contentsTab); @@ -112,20 +126,23 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy { await Promise.all([ this.loadCourseHandlers(), - this.loadTitle(), + this.loadBasinInfo(), ]); } /** * A tab was selected. */ - tabSelected(): void { - if (this.module) { - // Now that the first tab has been selected we can load the module. - CoreCourseHelper.openModule(this.module, this.course!.id, this.contentsTab.pageParams!.sectionId, this.modParams); + tabSelected(tabToSelect: CoreTabsOutletTab): void { + this.ionCollapsibleHeader?.setupContent(tabToSelect.id); - delete this.module; + if (!this.module || !this.course) { + return; } + // Now that the first tab has been selected we can load the module. + CoreCourseHelper.openModule(this.module, this.course.id, this.contentsTab.pageParams.sectionId, this.modParams); + + delete this.module; } /** @@ -134,8 +151,12 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy { * @return Promise resolved when done. */ protected async loadCourseHandlers(): Promise { + if (!this.course) { + return; + } + // Load the course handlers. - const handlers = await CoreCourseOptionsDelegate.getHandlersToDisplay(this.course!, false, this.isGuest); + const handlers = await CoreCourseOptionsDelegate.getHandlersToDisplay(this.course, false, this.isGuest); let tabToLoad: number | undefined; @@ -169,23 +190,34 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy { * * @return Promise resolved when done. */ - protected async loadTitle(): Promise { + protected async loadBasinInfo(): Promise { + if (!this.course) { + return; + } + // Get the title to display initially. - this.title = CoreCourseFormatDelegate.getCourseTitle(this.course!); + this.title = CoreCourseFormatDelegate.getCourseTitle(this.course); + this.category = 'categoryname' in this.course ? this.course.categoryname : ''; + + if ('overviewfiles' in this.course) { + this.imageThumb = this.course.overviewfiles?.[0]?.fileurl; + } + + this.updateProgress(); // Load sections. - const sections = await CoreUtils.ignoreErrors(CoreCourse.getSections(this.course!.id, false, true)); + const sections = await CoreUtils.ignoreErrors(CoreCourse.getSections(this.course.id, false, true)); if (!sections) { return; } // Get the title again now that we have sections. - this.title = CoreCourseFormatDelegate.getCourseTitle(this.course!, sections); + this.title = CoreCourseFormatDelegate.getCourseTitle(this.course, sections); } /** - * Page destroyed. + * @inheritdoc */ ngOnDestroy(): void { const path = CoreNavigator.getRouteFullPath(this.route.snapshot); @@ -208,6 +240,39 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy { this.tabsComponent?.ionViewDidLeave(); } + /** + * Open the course summary + */ + openCourseSummary(): void { + if (!this.course) { + return; + } + + CoreNavigator.navigateToSitePath( + '/course/' + this.course.id + '/preview', + { params: { course: this.course, avoidOpenCourse: true } }, + ); + } + + /** + * Update course progress. + */ + protected updateProgress(): void { + if ( + !this.course || + !('progress' in this.course) || + typeof this.course.progress !== 'number' || + this.course.progress < 0 || + this.course.completionusertracked === false + ) { + this.progress = undefined; + + return; + } + + this.progress = this.course.progress; + } + } type CourseTab = CoreTabsOutletTab & { diff --git a/src/core/features/course/services/course.ts b/src/core/features/course/services/course.ts index 170cbbef5..001452497 100644 --- a/src/core/features/course/services/course.ts +++ b/src/core/features/course/services/course.ts @@ -71,6 +71,11 @@ export enum CoreCourseModuleCompletionStatus { COMPLETION_COMPLETE_FAIL = 3, } +export enum CoreCourseCompletionType { + MANUAL = 0, + AUTO = 1, +} + /** * Completion tracking valid values. */ diff --git a/src/core/features/course/services/format-delegate.ts b/src/core/features/course/services/format-delegate.ts index dc2efc50b..967903d58 100644 --- a/src/core/features/course/services/format-delegate.ts +++ b/src/core/features/course/services/format-delegate.ts @@ -267,8 +267,8 @@ export class CoreCourseFormatDelegateService extends CoreDelegate ion-row { display: block; } +.core-underheader { + position: absolute; + top: 0; + left: 0; + right: 0; +} + ion-header[collapsible] { @include core-transition(all, 500ms); @@ -1247,7 +1254,14 @@ ion-header[collapsible] { .collapsible-title { overflow: visible; - *, h1, h2, .subheading { + --inner-padding-top: 0px; + --padding-top: 0px; + + ion-label { + margin-top: 0px; + } + + *, h1 { @include core-transition(all, 200ms, linear); } @@ -1255,9 +1269,12 @@ ion-header[collapsible] { overflow: visible !important; } - h1, h2, .subheading { + h1 { --max-width: none; } + h1.cloned { + opacity: 0 !important; + } } ion-app.ios .collapsible-title h1 { @@ -1269,7 +1286,7 @@ ion-app.md .collapsible-title h1 { } .collapsible-title.collapsible-title-collapsed { - ion-label, h1, h2, ion-row, ion-col, .subheading { + ion-label, h1, ion-row, ion-col { opacity: 0; } } @@ -1279,13 +1296,13 @@ ion-app.md .collapsible-title h1 { opacity: var(--collapse-opacity, 0); } - ion-label, h1, h2, ion-row, ion-col, .subheading { + ion-label, h1, ion-row, ion-col, .subheading { opacity: 1; } } .collapsible-title.collapsible-title-collapse-nowrap { - h1, h2, .subheading { + h1:not(.cloned) { max-width: var(--max-width); white-space: nowrap; overflow: hidden;