diff --git a/src/assets/img/completion/completion-auto-fail.svg b/src/assets/img/completion/completion-auto-fail.svg new file mode 100644 index 000000000..771adf36f --- /dev/null +++ b/src/assets/img/completion/completion-auto-fail.svg @@ -0,0 +1,18 @@ + + + +]> + + + + + + diff --git a/src/assets/img/completion/completion-auto-n-override.svg b/src/assets/img/completion/completion-auto-n-override.svg new file mode 100644 index 000000000..6100638d0 --- /dev/null +++ b/src/assets/img/completion/completion-auto-n-override.svg @@ -0,0 +1,3 @@ + +]> \ No newline at end of file diff --git a/src/assets/img/completion/completion-auto-n.svg b/src/assets/img/completion/completion-auto-n.svg new file mode 100644 index 000000000..6a8bc6222 --- /dev/null +++ b/src/assets/img/completion/completion-auto-n.svg @@ -0,0 +1,15 @@ + + + +]> + + + + + diff --git a/src/assets/img/completion/completion-auto-pass.svg b/src/assets/img/completion/completion-auto-pass.svg new file mode 100644 index 000000000..44df83f15 --- /dev/null +++ b/src/assets/img/completion/completion-auto-pass.svg @@ -0,0 +1,17 @@ + + + +]> + + + + + + diff --git a/src/assets/img/completion/completion-auto-y-override.svg b/src/assets/img/completion/completion-auto-y-override.svg new file mode 100644 index 000000000..13cf5d700 --- /dev/null +++ b/src/assets/img/completion/completion-auto-y-override.svg @@ -0,0 +1,3 @@ + +]> \ No newline at end of file diff --git a/src/assets/img/completion/completion-auto-y.svg b/src/assets/img/completion/completion-auto-y.svg new file mode 100644 index 000000000..14822e173 --- /dev/null +++ b/src/assets/img/completion/completion-auto-y.svg @@ -0,0 +1,17 @@ + + + +]> + + + + + + diff --git a/src/assets/img/completion/completion-manual-n-override.svg b/src/assets/img/completion/completion-manual-n-override.svg new file mode 100644 index 000000000..cccfb99cd --- /dev/null +++ b/src/assets/img/completion/completion-manual-n-override.svg @@ -0,0 +1,3 @@ + +]> \ No newline at end of file diff --git a/src/assets/img/completion/completion-manual-n.svg b/src/assets/img/completion/completion-manual-n.svg new file mode 100644 index 000000000..f7750e25a --- /dev/null +++ b/src/assets/img/completion/completion-manual-n.svg @@ -0,0 +1,14 @@ + + + +]> + + + + + diff --git a/src/assets/img/completion/completion-manual-y-override.svg b/src/assets/img/completion/completion-manual-y-override.svg new file mode 100644 index 000000000..69270ba3e --- /dev/null +++ b/src/assets/img/completion/completion-manual-y-override.svg @@ -0,0 +1,3 @@ + +]> \ No newline at end of file diff --git a/src/assets/img/completion/completion-manual-y.svg b/src/assets/img/completion/completion-manual-y.svg new file mode 100644 index 000000000..3b91bdbc7 --- /dev/null +++ b/src/assets/img/completion/completion-manual-y.svg @@ -0,0 +1,17 @@ + + + +]> + + + + + + diff --git a/src/core/features/block/components/course-blocks/course-blocks.ts b/src/core/features/block/components/course-blocks/course-blocks.ts index c95ab480c..a1eb6b785 100644 --- a/src/core/features/block/components/course-blocks/course-blocks.ts +++ b/src/core/features/block/components/course-blocks/course-blocks.ts @@ -18,6 +18,7 @@ import { CoreDomUtils } from '@services/utils/dom'; import { CoreCourse, CoreCourseBlock } from '@features/course/services/course'; import { CoreBlockHelper } from '../../services/block-helper'; import { CoreBlockComponent } from '../block/block'; +import { CoreUtils } from '@services/utils/utils'; /** * Component that displays the list of course blocks. @@ -108,4 +109,15 @@ export class CoreBlockCourseBlocksComponent implements OnInit { } } + /** + * Refresh data. + * + * @return Promise resolved when done. + */ + async doRefresh(): Promise { + await CoreUtils.instance.ignoreErrors(this.invalidateBlocks()); + + await this.loadContent(); + } + } diff --git a/src/core/features/course/components/components.module.ts b/src/core/features/course/components/components.module.ts index 4350c88e3..24637f512 100644 --- a/src/core/features/course/components/components.module.ts +++ b/src/core/features/course/components/components.module.ts @@ -19,11 +19,17 @@ import { TranslateModule } from '@ngx-translate/core'; import { CoreSharedModule } from '@/core/shared.module'; import { CoreBlockComponentsModule } from '@features/block/components/components.module'; +import { CoreCourseFormatComponent } from './format/format'; +import { CoreCourseModuleComponent } from './module/module'; +import { CoreCourseModuleCompletionComponent } from './module-completion/module-completion'; import { CoreCourseModuleDescriptionComponent } from './module-description/module-description'; import { CoreCourseUnsupportedModuleComponent } from './unsupported-module/unsupported-module'; @NgModule({ declarations: [ + CoreCourseFormatComponent, + CoreCourseModuleComponent, + CoreCourseModuleCompletionComponent, CoreCourseModuleDescriptionComponent, CoreCourseUnsupportedModuleComponent, ], @@ -35,6 +41,9 @@ import { CoreCourseUnsupportedModuleComponent } from './unsupported-module/unsup CoreSharedModule, ], exports: [ + CoreCourseFormatComponent, + CoreCourseModuleComponent, + CoreCourseModuleCompletionComponent, CoreCourseModuleDescriptionComponent, CoreCourseUnsupportedModuleComponent, ], diff --git a/src/core/features/course/components/format/core-course-format.html b/src/core/features/course/components/format/core-course-format.html new file mode 100644 index 000000000..a4016d942 --- /dev/null +++ b/src/core/features/course/components/format/core-course-format.html @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + +
+ + + + + + {{ 'core.course.sections' | translate }} + + + + + +
+
+ + + + +
+ +
+ + + + + + + {{ 'core.course.hiddenfromstudents' | translate }} + + + {{ 'core.notavailable' | translate }} + + + + + + + +
+
+ + +
+ + + + + +
+ + +
+ + + + + + + + + +
+ + + + + + + + + + + + + +
+
+
+ + + +
+ + + + + +

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

+
+ + + + + + + + + +
+
+ + + +
+ + + {{section.count}} / {{section.total}} + + + + +
+
\ No newline at end of file diff --git a/src/core/features/course/components/format/format.scss b/src/core/features/course/components/format/format.scss new file mode 100644 index 000000000..672fc732d --- /dev/null +++ b/src/core/features/course/components/format/format.scss @@ -0,0 +1,84 @@ +// ion-app.app-root ion-badge.core-course-download-section-progress { +// display: block; +// @include float(start); +// @include margin(12px, 12px, null, 12px); +// } + +:host { + + .core-format-progress-list { + margin-bottom: 0; + + .item { + background: transparent; + + .label { + margin-top: 0; + margin-bottom: 0; + } + + progress { + .progress-bar-fallback, + &[value]::-webkit-progress-bar { + background-color: var(--white); + } + } + } + } + + .core-course-thumb { + display: none; + height: 150px; + width: 100%; + overflow: hidden; + cursor: pointer; + pointer-events: auto; + position: relative; + background: white; + + img { + position: absolute; + top: 0; + bottom: 0; + margin: auto; + width: 100%; + } + } + +// .item-divider { +// .label { +// margin-top: 0; +// margin-bottom: 0; +// } + +// core-format-text { +// line-height: 44px; +// } + +// ion-badge core-format-text { +// line-height: normal; +// margin-bottom: 9px; +// } + +// &.core-section-download .label{ +// @include margin(null, 0, null, null); +// } +// } + +// div.core-section-download { +// @include padding(null, 0, null, null); +// } + +// .core-button-selector-row { +// @include safe-area-padding-start($content-padding !important, $content-padding); +// } + +// .core-course-section-nav-buttons { +// .button-inner 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 new file mode 100644 index 000000000..3bbad7a01 --- /dev/null +++ b/src/core/features/course/components/format/format.ts @@ -0,0 +1,617 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + Component, + Input, + OnInit, + OnChanges, + OnDestroy, + SimpleChange, + Output, + EventEmitter, + ViewChildren, + QueryList, + Type, + ViewChild, +} from '@angular/core'; + +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component'; +import { CoreCourseAnyCourseData } from '@features/courses/services/courses'; +import { + CoreCourse, + CoreCourseModuleCompletionData, + CoreCourseModuleData, + CoreCourseProvider, +} from '@features/course/services/course'; +// import { CoreCourseHelper } from '@features/course/services/course-helper'; +import { CoreCourseFormatDelegate } from '@features/course/services/format-delegate'; +import { CoreEventObserver, CoreEvents, CoreEventSectionStatusChangedData, CoreEventSelectCourseTabData } from '@singletons/events'; +import { IonContent, IonRefresher } from '@ionic/angular'; +import { CoreUtils } from '@services/utils/utils'; +// import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; +import { CoreBlockCourseBlocksComponent } from '@features/block/components/course-blocks/course-blocks'; +import { CoreCourseSectionFormatted } from '@features/course/services/course-helper'; +import { CoreCourseModuleStatusChangedData } from '../module/module'; + +/** + * Component to display course contents using a certain format. If the format isn't found, use default one. + * + * The inputs of this component will be shared with the course format components. Please use CoreCourseFormatDelegate + * to register your handler for course formats. + * + * Example usage: + * + * + */ +@Component({ + selector: 'core-course-format', + templateUrl: 'core-course-format.html', +}) +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?: CoreCourseSectionFormatted[]; // List of course sections. + @Input() downloadEnabled?: boolean; // Whether the download of sections and modules is enabled. + @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. + @Output() completionChanged = new EventEmitter(); // Notify when any module completion changes. + + @ViewChildren(CoreDynamicComponent) dynamicComponents?: QueryList; + @ViewChild(CoreBlockCourseBlocksComponent) courseBlocksComponent?: CoreBlockCourseBlocksComponent; + + // All the possible component classes. + courseFormatComponent?: Type; + courseSummaryComponent?: Type; + sectionSelectorComponent?: Type; + singleSectionComponent?: Type; + allSectionsComponent?: Type; + + canLoadMore = false; + showSectionId = 0; + sectionSelectorExpanded = false; + data: Record = {}; // Data to pass to the components. + + displaySectionSelector?: boolean; + displayBlocks?: boolean; + selectedSection?: CoreCourseSectionFormatted; + previousSection?: CoreCourseSectionFormatted; + nextSection?: CoreCourseSectionFormatted; + allSectionsId: number = CoreCourseProvider.ALL_SECTIONS_ID; + stealthModulesSectionId: number = CoreCourseProvider.STEALTH_MODULES_SECTION_ID; + loaded = false; + hasSeveralSections?: boolean; + imageThumb?: string; + progress?: number; + + protected sectionStatusObserver?: CoreEventObserver; + protected selectTabObserver?: CoreEventObserver; + protected lastCourseFormat?: string; + + constructor( + protected content: IonContent, + ) { + // Pass this instance to all components so they can use its methods and properties. + this.data.coreCourseFormatComponent = this; + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + // Listen for section status changes. + this.sectionStatusObserver = CoreEvents.on( + CoreEvents.SECTION_STATUS_CHANGED, + async (data) => { + if (!this.downloadEnabled || !this.sections?.length || !data.sectionId || data.courseId != this.course?.id) { + return; + } + + // @todo Check if the affected section is being downloaded. + // If so, we don't update section status because it'll already be updated when the download finishes. + // const downloadId = CoreCourseHelper.instance.getSectionDownloadId({ id: data.sectionId }); + // if (prefetchDelegate.isBeingDownloaded(downloadId)) { + // return; + // } + + // Get the affected section. + // const section = this.sections.find(section => section.id == data.sectionId); + // if (!section) { + // return; + // } + + // Recalculate the status. + // await CoreCourseHelper.instance.calculateSectionStatus(section, this.course.id, false); + + // if (section.isDownloading && !prefetchDelegate.isBeingDownloaded(downloadId)) { + // // All the modules are now downloading, set a download all promise. + // this.prefetch(section); + // } + }, + CoreSites.instance.getCurrentSiteId(), + ); + + // Listen for select course tab events to select the right section if needed. + this.selectTabObserver = CoreEvents.on(CoreEvents.SELECT_COURSE_TAB, (data) => { + if (data.name) { + return; + } + + let section: CoreCourseSectionFormatted | undefined; + + if (typeof data.sectionId != 'undefined' && data.sectionId != null && this.sections) { + section = this.sections.find((section) => section.id == data.sectionId); + } else if (typeof data.sectionNumber != 'undefined' && data.sectionNumber != null && this.sections) { + section = this.sections.find((section) => section.section == data.sectionNumber); + } + + if (section) { + this.sectionChanged(section); + } + }); + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: { [name: string]: SimpleChange }): void { + this.setInputData(); + + if (changes.course && this.course) { + // Course has changed, try to get the components. + this.getComponents(); + + this.displaySectionSelector = CoreCourseFormatDelegate.instance.displaySectionSelector(this.course); + this.displayBlocks = CoreCourseFormatDelegate.instance.displayBlocks(this.course); + this.progress = 'progress' in this.course && this.course.progress !== undefined && this.course.progress >= 0 && + this.course.completionusertracked !== false ? this.course.progress : undefined; + if ('overviewfiles' in this.course) { + this.imageThumb = this.course.overviewfiles?.[0]?.fileurl; + } + } + + if (changes.sections && this.sections) { + this.treatSections(this.sections); + } + + if (this.downloadEnabled && (changes.downloadEnabled || changes.sections)) { + this.calculateSectionsStatus(false); + } + } + + /** + * Set the input data for components. + */ + protected setInputData(): void { + this.data.course = this.course; + this.data.sections = this.sections; + this.data.initialSectionId = this.initialSectionId; + this.data.initialSectionNumber = this.initialSectionNumber; + this.data.downloadEnabled = this.downloadEnabled; + this.data.moduleId = this.moduleId; + this.data.completionChanged = this.completionChanged; + } + + /** + * Get the components classes. + */ + protected async getComponents(): Promise { + if (!this.course || this.course.format == this.lastCourseFormat) { + return; + } + + // Format has changed or it's the first time, load all the components. + this.lastCourseFormat = this.course.format; + + await Promise.all([ + this.loadCourseFormatComponent(), + this.loadCourseSummaryComponent(), + this.loadSectionSelectorComponent(), + this.loadSingleSectionComponent(), + this.loadAllSectionsComponent(), + ]); + } + + /** + * Load course format component. + * + * @return Promise resolved when done. + */ + protected async loadCourseFormatComponent(): Promise { + this.courseFormatComponent = await CoreCourseFormatDelegate.instance.getCourseFormatComponent(this.course!); + } + + /** + * Load course summary component. + * + * @return Promise resolved when done. + */ + protected async loadCourseSummaryComponent(): Promise { + this.courseSummaryComponent = await CoreCourseFormatDelegate.instance.getCourseSummaryComponent(this.course!); + } + + /** + * Load section selector component. + * + * @return Promise resolved when done. + */ + protected async loadSectionSelectorComponent(): Promise { + this.sectionSelectorComponent = await CoreCourseFormatDelegate.instance.getSectionSelectorComponent(this.course!); + } + + /** + * Load single section component. + * + * @return Promise resolved when done. + */ + protected async loadSingleSectionComponent(): Promise { + this.singleSectionComponent = await CoreCourseFormatDelegate.instance.getSingleSectionComponent(this.course!); + } + + /** + * Load all sections component. + * + * @return Promise resolved when done. + */ + protected async loadAllSectionsComponent(): Promise { + this.allSectionsComponent = await CoreCourseFormatDelegate.instance.getAllSectionsComponent(this.course!); + } + + /** + * Treat received sections. + * + * @param sections Sections to treat. + * @return Promise resolved when done. + */ + protected async treatSections(sections: CoreCourseSectionFormatted[]): Promise { + const hasAllSections = sections[0].id == CoreCourseProvider.ALL_SECTIONS_ID; + this.hasSeveralSections = sections.length > 2 || (sections.length == 2 && !hasAllSections); + + if (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!)); + + if (!newSection) { + // Section not found, calculate which one to use. + newSection = await CoreCourseFormatDelegate.instance.getCurrentSection(this.course!, sections); + } + + this.sectionChanged(newSection); + + return; + } + + // There is no selected section yet, calculate which one to load. + if (!this.hasSeveralSections) { + // Always load "All sections" to display the section title. If it isn't there just load the section. + this.loaded = true; + this.sectionChanged(sections[0]); + } else if (this.initialSectionId || this.initialSectionNumber) { + // We have an input indicating the section ID to load. Search the section. + const section = sections.find((section) => { + if (section.id != this.initialSectionId && (!section.section || section.section != this.initialSectionNumber)) { + return false; + } + }); + + // Don't load the section if it cannot be viewed by the user. + if (section && this.canViewSection(section)) { + this.loaded = true; + this.sectionChanged(section); + } + } + + if (!this.loaded) { + // No section specified, not found or not visible, get current section. + const section = await CoreCourseFormatDelegate.instance.getCurrentSection(this.course!, sections); + + this.loaded = true; + this.sectionChanged(section); + } + + return; + } + + /** + * Display the section selector modal. + * + * @param event Event. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + showSectionSelector(event?: MouseEvent): void { + if (this.sectionSelectorExpanded) { + return; + } + + // @todo this.sectionSelectorExpanded = true; + // const modal = this.modalCtrl.create('CoreCourseSectionSelectorPage', + // {course: this.course, sections: this.sections, selected: this.selectedSection}); + // modal.onDidDismiss((newSection) => { + // if (newSection) { + // this.sectionChanged(newSection); + // } + + // this.sectionSelectorExpanded = false; + // }); + + // modal.present({ + // ev: event + // }); + } + + /** + * Function called when selected section changes. + * + * @param newSection The new selected section. + */ + sectionChanged(newSection: CoreCourseSectionFormatted): void { + const previousValue = this.selectedSection; + this.selectedSection = newSection; + this.data.section = this.selectedSection; + + 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!)); + + let j: number; + for (j = i - 1; j >= 1; j--) { + if (this.canViewSection(this.sections![j])) { + break; + } + } + this.previousSection = j >= 1 ? this.sections![j] : undefined; + + 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; + } else { + this.previousSection = undefined; + this.nextSection = undefined; + this.canLoadMore = false; + this.showSectionId = 0; + this.showMoreActivities(); + // @todo CoreCourseHelper.instance.calculateSectionsStatus(this.sections, this.course.id, false, false); + } + + if (this.moduleId && typeof previousValue == 'undefined') { + setTimeout(() => { + CoreDomUtils.instance.scrollToElementBySelector(this.content, '#core-course-module-' + this.moduleId); + }, 200); + } else { + this.content.scrollToTop(0); + } + + if (!previousValue || previousValue.id != newSection.id) { + // First load or section changed, add log in Moodle. + CoreUtils.instance.ignoreErrors( + CoreCourse.instance.logView(this.course!.id, newSection.section, undefined, this.course!.fullname), + ); + } + } + + /** + * Compare if two sections are equal. + * + * @param section1 First section. + * @param section2 Second section. + * @return Whether they're equal. + */ + compareSections(section1: CoreCourseSectionFormatted, section2: CoreCourseSectionFormatted): boolean { + return section1 && section2 ? section1.id === section2.id : section1 === section2; + } + + /** + * Calculate the status of sections. + * + * @param refresh If refresh or not. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected calculateSectionsStatus(refresh?: boolean): void { + // @todo CoreCourseHelper.instance.calculateSectionsStatus(this.sections, this.course.id, refresh).catch(() => { + // // Ignore errors (shouldn't happen). + // }); + } + + /** + * Confirm and prefetch a section. If the section is "all sections", prefetch all the sections. + * + * @param section Section to download. + * @param refresh Refresh clicked (not used). + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + prefetch(section: CoreCourseSectionFormatted): void { + // section.isCalculating = true; + // @todo CoreCourseHelper.instance.confirmDownloadSizeSection(this.course.id, section, this.sections).then(() => { + // this.prefetchSection(section, true); + // }, (error) => { + // // User cancelled or there was an error calculating the size. + // if (error) { + // CoreDomUtils.instance.showErrorModal(error); + // } + // }).finally(() => { + // section.isCalculating = false; + // }); + } + + /** + * Prefetch a section. @todo + * + * @param section The section to download. + * @param manual Whether the prefetch was started manually or it was automatically started because all modules + * are being downloaded. + */ + // protected prefetchSection(section: Section, manual?: boolean): void { + // CoreCourseHelper.instance.prefetchSection(section, this.course.id, this.sections).catch((error) => { + // // Don't show error message if it's an automatic download. + // if (!manual) { + // return; + // } + + // CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errordownloadingsection', true); + // }); + // } + + /** + * Refresh the data. + * + * @param refresher Refresher. + * @param done Function to call when done. + * @param afterCompletionChange Whether the refresh is due to a completion change. + * @return Promise resolved when done. + */ + async doRefresh(refresher?: CustomEvent, done?: () => void, afterCompletionChange?: boolean): Promise { + const promises = this.dynamicComponents?.map(async (component) => { + await component.callComponentFunction('doRefresh', [refresher, done, afterCompletionChange]); + }) || []; + + if (this.courseBlocksComponent) { + promises.push(this.courseBlocksComponent.doRefresh()); + } + + await Promise.all(promises); + } + + /** + * Show more activities (only used when showing all the sections at the same time). + * + * @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading. + */ + showMoreActivities(infiniteComplete?: () => void): void { + this.canLoadMore = false; + + const sections = this.sections || []; + let modulesLoaded = 0; + let i: number; + for (i = this.showSectionId + 1; i < sections.length; i++) { + if (!sections[i].hasContent || !sections[i].modules) { + continue; + } + + modulesLoaded += sections[i].modules.reduce((total, module) => module.visibleoncoursepage !== 0 ? total + 1 : total, 0); + + if (modulesLoaded >= CoreCourseFormatComponent.LOAD_MORE_ACTIVITIES) { + break; + } + } + + this.showSectionId = i; + this.canLoadMore = i < sections.length; + + if (this.canLoadMore) { + // Check if any of the following sections have any content. + let thereAreMore = false; + for (i++; i < sections.length; i++) { + if (sections[i].hasContent && sections[i].modules && sections[i].modules?.length > 0) { + thereAreMore = true; + break; + } + } + this.canLoadMore = thereAreMore; + } + + infiniteComplete && infiniteComplete(); + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.sectionStatusObserver && this.sectionStatusObserver.off(); + this.selectTabObserver && this.selectTabObserver.off(); + } + + /** + * User entered the page that contains the component. + */ + ionViewDidEnter(): void { + this.dynamicComponents?.forEach((component) => { + component.callComponentFunction('ionViewDidEnter'); + }); + + // @todo if (this.downloadEnabled) { + // // The download status of a section might have been changed from within a module page. + // if (this.selectedSection && this.selectedSection.id !== CoreCourseProvider.ALL_SECTIONS_ID) { + // CoreCourseHelper.instance.calculateSectionStatus(this.selectedSection, this.course.id, false, false); + // } else { + // CoreCourseHelper.instance.calculateSectionsStatus(this.sections, this.course.id, false, false); + // } + // } + } + + /** + * User left the page that contains the component. + */ + ionViewDidLeave(): void { + this.dynamicComponents?.forEach((component) => { + component.callComponentFunction('ionViewDidLeave'); + }); + } + + /** + * Check whether a section can be viewed. + * + * @param section The section to check. + * @return Whether the section can be viewed. + */ + canViewSection(section: CoreCourseSectionFormatted): boolean { + return section.uservisible !== false && !section.hiddenbynumsections && + 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 == 'undefined') { + 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)); + + const moduleProgressPercent = 100 / (completionModules || 1); + // Use min/max here to avoid floating point rounding errors over/under-flowing the progress bar. + if (completionData.state === CoreCourseProvider.COMPLETION_COMPLETE) { + this.course.progress = Math.min(100, this.course.progress + moduleProgressPercent); + } else { + this.course.progress = Math.max(0, this.course.progress - moduleProgressPercent); + } + } + + /** + * Recalculate the download status of each section, in response to a module being downloaded. + * + * @param eventData + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onModuleStatusChange(eventData: CoreCourseModuleStatusChangedData): void { + // @todo CoreCourseHelper.instance.calculateSectionsStatus(this.sections, this.course.id, false, false); + } + +} diff --git a/src/core/features/course/components/module-completion/core-course-module-completion.html b/src/core/features/course/components/module-completion/core-course-module-completion.html new file mode 100644 index 000000000..ea6f994ee --- /dev/null +++ b/src/core/features/course/components/module-completion/core-course-module-completion.html @@ -0,0 +1,3 @@ + diff --git a/src/core/features/course/components/module-completion/module-completion.scss b/src/core/features/course/components/module-completion/module-completion.scss new file mode 100644 index 000000000..664cb4eef --- /dev/null +++ b/src/core/features/course/components/module-completion/module-completion.scss @@ -0,0 +1,13 @@ +:host { + button { + display: block; + background-color: transparent; + + img { + padding: 5px; + width: 30px; + vertical-align: middle; + max-width: none; + } + } +} diff --git a/src/core/features/course/components/module-completion/module-completion.ts b/src/core/features/course/components/module-completion/module-completion.ts new file mode 100644 index 000000000..f3e97205b --- /dev/null +++ b/src/core/features/course/components/module-completion/module-completion.ts @@ -0,0 +1,176 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, Output, EventEmitter, OnChanges, SimpleChange } from '@angular/core'; + +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUser } from '@features/user/services/user'; +import { CoreCourse, CoreCourseProvider } from '@features/course/services/course'; +import { CoreFilterHelper } from '@features/filter/services/filter-helper'; +import { CoreCourseModuleCompletionDataFormatted } from '@features/course/services/course-helper'; +import { Translate } from '@singletons'; + +/** + * Component to handle activity completion. It shows a checkbox with the current status, and allows manually changing + * the completion if it's allowed. + * + * Example usage: + * + * + */ +@Component({ + selector: 'core-course-module-completion', + templateUrl: 'core-course-module-completion.html', + styleUrls: ['module-completion.scss'], +}) +export class CoreCourseModuleCompletionComponent implements OnChanges { + + @Input() completion?: CoreCourseModuleCompletionDataFormatted; // The completion status. + @Input() moduleId?: number; // The name of the module this completion affects. + @Input() moduleName?: string; // The name of the module this completion affects. + @Output() completionChanged = new EventEmitter(); // Notify when completion changes. + + completionImage?: string; + completionDescription?: string; + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: { [name: string]: SimpleChange }): void { + if (changes.completion && this.completion) { + this.showStatus(); + } + } + + /** + * Completion clicked. + * + * @param e The click event. + */ + async completionClicked(e: Event): Promise { + if (!this.completion) { + return; + } + + if (typeof this.completion.cmid == 'undefined' || this.completion.tracking !== 1) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + const modal = await CoreDomUtils.instance.showModalLoading(); + this.completion.state = this.completion.state === 1 ? 0 : 1; + + try { + const response = await CoreCourse.instance.markCompletedManually( + this.completion.cmid, + this.completion.state === 1, + this.completion.courseId!, + this.completion.courseName, + ); + + if (this.completion.valueused === false) { + this.showStatus(); + if (response.offline) { + this.completion.offline = true; + } + } + this.completionChanged.emit(this.completion); + } catch (error) { + this.completion.state = this.completion.state === 1 ? 0 : 1; + CoreDomUtils.instance.showErrorModalDefault(error, 'core.errorchangecompletion', true); + } finally { + modal.dismiss(); + } + } + + /** + * Set image and description to show as completion icon. + */ + protected async showStatus(): Promise { + if (!this.completion) { + return; + } + + const moduleName = this.moduleName || ''; + let langKey: string | undefined; + let image: string | undefined; + + if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_MANUAL && + this.completion.state === CoreCourseProvider.COMPLETION_INCOMPLETE) { + image = 'completion-manual-n'; + langKey = 'core.completion-alt-manual-n'; + } else if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_MANUAL && + this.completion.state === CoreCourseProvider.COMPLETION_COMPLETE) { + image = 'completion-manual-y'; + langKey = 'core.completion-alt-manual-y'; + } else if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_AUTOMATIC && + this.completion.state === CoreCourseProvider.COMPLETION_INCOMPLETE) { + image = 'completion-auto-n'; + langKey = 'core.completion-alt-auto-n'; + } else if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_AUTOMATIC && + this.completion.state === CoreCourseProvider.COMPLETION_COMPLETE) { + image = 'completion-auto-y'; + langKey = 'core.completion-alt-auto-y'; + } else if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_AUTOMATIC && + this.completion.state === CoreCourseProvider.COMPLETION_COMPLETE_PASS) { + image = 'completion-auto-pass'; + langKey = 'core.completion-alt-auto-pass'; + } else if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_AUTOMATIC && + this.completion.state === CoreCourseProvider.COMPLETION_COMPLETE_FAIL) { + image = 'completion-auto-fail'; + langKey = 'core.completion-alt-auto-fail'; + } + + if (image) { + if (this.completion.overrideby > 0) { + image += '-override'; + } + this.completionImage = 'assets/img/completion/' + image + '.svg'; + } + + if (!moduleName || !this.moduleId || !langKey) { + return; + } + + const result = await CoreFilterHelper.instance.getFiltersAndFormatText( + moduleName, + 'module', + this.moduleId, + { clean: true, singleLine: true, shortenLength: 50, courseId: this.completion.courseId }, + ); + + let translateParams: Record = { + $a: result.text, + }; + + if (this.completion.overrideby > 0) { + langKey += '-override'; + + const profile = await CoreUser.instance.getProfile(this.completion.overrideby, this.completion.courseId, true); + + translateParams = { + $a: { + overrideuser: profile.fullname, + modname: result.text, + }, + }; + } + + this.completionDescription = Translate.instance.instant(langKey, translateParams); + } + +} diff --git a/src/core/features/course/components/module-description/core-course-module-description.html b/src/core/features/course/components/module-description/core-course-module-description.html index bd8df6e14..e07546c5a 100644 --- a/src/core/features/course/components/module-description/core-course-module-description.html +++ b/src/core/features/course/components/module-description/core-course-module-description.html @@ -1,11 +1,15 @@ - - + + + + - {{ note }} + + {{ note }} + \ No newline at end of file diff --git a/src/core/features/course/components/module/core-course-module.html b/src/core/features/course/components/module/core-course-module.html new file mode 100644 index 000000000..1eedc171a --- /dev/null +++ b/src/core/features/course/components/module/core-course-module.html @@ -0,0 +1,73 @@ + + + + + +
+ + + + +
+ + + + +
+ + + + + + +
+
+
+ +
+ + + + + {{ 'core.course.hiddenfromstudents' | translate }} + + + {{ 'core.course.hiddenoncoursepage' | translate }} + +
+ {{ 'core.restricted' | translate }} + + +
+ + {{ 'core.course.manualcompletionnotsynced' | translate }} + +
+ + + +
+
+ + + + + \ No newline at end of file diff --git a/src/core/features/course/components/module/module.scss b/src/core/features/course/components/module/module.scss new file mode 100644 index 000000000..78439793e --- /dev/null +++ b/src/core/features/course/components/module/module.scss @@ -0,0 +1,162 @@ +:host { + background: white; + display: block; + + .item.core-course-module-handler { + align-items: flex-start; + min-height: 52px; + cursor: pointer; + +// &.item .item-inner { +// @include safe-area-padding(null, 0px, null, null); +// } +// .label { +// @include margin(0, 0, 0, null); +// } + .core-module-icon { + align-items: flex-start; + width: 24px; + height: 24px; + margin-top: 11px; + } + +// &.item-ios:active, +// &.item-ios.activated { +// background-color: $list-ios-activated-background-color; +// } +// &.item-md:active, +// &.item-md.activated { +// background-color: $list-md-activated-background-color; +// } + } + + .core-module-title { + display: flex; + flex-flow: row; + align-items: flex-start; + + core-format-text { + flex-grow: 2; + } + .core-module-buttons, + .buttons.core-module-buttons { + margin: 0; + } + + .core-module-buttons, + .core-module-buttons-more { + display: flex; + flex-flow: row; + align-items: center; + z-index: 1; + justify-content: space-around; + align-content: center; + } + + .core-module-buttons core-course-module-completion, + .core-module-buttons-more button { + cursor: pointer; + pointer-events: auto; + } + + .core-module-buttons core-course-module-completion { + text-align: center; + } + } + + .core-module-more-info { + // ion-badge { + // @include text-align('start'); + // } + + .core-module-availabilityinfo { + font-size: 90%; + ul { + margin-block-start: 0.5em; + } + } + } + + .core-not-clickable { + cursor: initial; + +// &:active, +// &.activated { +// background-color: $list-background-color; +// } + } + + .core-module-loading { + width: 100%; + text-align: center; + padding-top: 10px; + clear: both; +// @include darkmode() { +// color: $core-dark-text-color; +// } + } + +// @include darkmode() { +// .item.core-course-module-handler { +// background: $core-dark-item-bg-color; +// &.item-ios:active, +// &.item-ios.activated, +// &.item-md:active, +// &.item-md.activated { +// background-color: $core-dark-background-color; +// } +// } + +// .core-not-clickable:active, +// .core-not-clickable.activated { +// background-color: $core-dark-item-bg-color; +// } +// } +} + +// ion-app.app-root.md core-course-module { +// .core-module-description { +// @include padding(null, $label-md-margin-end, null, null); +// margin-bottom: $label-md-margin-bottom; + +// .core-show-more { +// @include padding(null, $label-md-margin-end, null, null); +// } +// } + +// .core-module-title core-format-text { +// padding-top: $label-md-margin-top + 3; +// } +// .button-md { +// margin-top: 8px; +// margin-bottom: 8px; +// } +// .core-module-buttons-more { +// min-height: 52px; +// min-width: 53px; +// } +// } + +// ion-app.app-root.ios core-course-module { +// .core-module-description { +// @include padding(null, $label-ios-margin-end, null, null); +// margin-bottom: $label-md-margin-bottom; + +// .core-show-more { +// @include padding(null, $label-ios-margin-end, null, null); +// } +// } + +// .core-module-title core-format-text { +// padding-top: $label-ios-margin-top + 3; +// } + +// .core-module-buttons-more { +// min-height: 53px; +// min-width: 58px; +// } +// } + +// ion-app.app-root .core-course-module-handler.item [item-start] + .item-inner { +// @include margin-horizontal(4px, null); +// } \ No newline at end of file diff --git a/src/core/features/course/components/module/module.ts b/src/core/features/course/components/module/module.ts new file mode 100644 index 000000000..f0ac61af0 --- /dev/null +++ b/src/core/features/course/components/module/module.ts @@ -0,0 +1,206 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, Output, EventEmitter, OnInit, OnDestroy } from '@angular/core'; + +// import { CoreSites } from '@services/sites'; +// import { CoreDomUtils } from '@services/utils/dom'; +// import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreCourseModuleDataFormatted, CoreCourseSectionFormatted } from '@features/course/services/course-helper'; +import { CoreCourse, CoreCourseModuleCompletionData } from '@features/course/services/course'; +import { CoreCourseModuleHandlerButton } from '@features/course/services/module-delegate'; +// import { CoreCourseModulePrefetchDelegate, CoreCourseModulePrefetchHandler } from '../../providers/module-prefetch-delegate'; + +/** + * Component to display a module entry in a list of modules. + * + * Example usage: + * + * + */ +@Component({ + selector: 'core-course-module', + templateUrl: 'core-course-module.html', +}) +export class CoreCourseModuleComponent implements OnInit, OnDestroy { + + @Input() module?: CoreCourseModuleDataFormatted; // The module to render. + @Input() courseId?: number; // The course the module belongs to. + @Input() section?: CoreCourseSectionFormatted; // The section the module belongs to. + // eslint-disable-next-line @angular-eslint/no-input-rename + @Input('downloadEnabled') set enabled(value: boolean) { + this.downloadEnabled = value; + + if (!this.module?.handlerData?.showDownloadButton || !this.downloadEnabled || this.statusCalculated) { + return; + } + + // First time that the download is enabled. Initialize the data. + this.statusCalculated = true; + this.spinner = true; // Show spinner while calculating the status. + + // Get current status to decide which icon should be shown. + // @todo this.prefetchDelegate.getModuleStatus(this.module, this.courseId).then(this.showStatus.bind(this)); + }; + + @Output() completionChanged = new EventEmitter(); // Notify when module completion changes. + @Output() statusChanged = new EventEmitter(); // Notify when the download status changes. + + downloadStatus?: string; + canCheckUpdates?: boolean; + spinner?: boolean; // Whether to display a loading spinner. + downloadEnabled?: boolean; // Whether the download of sections and modules is enabled. + modNameTranslated = ''; + + // protected prefetchHandler: CoreCourseModulePrefetchHandler; + // protected statusObserver?: CoreEventObserver; + protected statusCalculated = false; + protected isDestroyed = false; + + /** + * Component being initialized. + */ + ngOnInit(): void { + if (!this.module) { + return; + } + + this.courseId = this.courseId || this.module.course; + this.modNameTranslated = CoreCourse.instance.translateModuleName(this.module.modname) || ''; + + if (!this.module.handlerData) { + return; + } + + this.module.handlerData.a11yTitle = this.module.handlerData.a11yTitle ?? this.module.handlerData.title; + + if (this.module.handlerData.showDownloadButton) { + // @todo Listen for changes on this module status, even if download isn't enabled. + // this.prefetchHandler = this.prefetchDelegate.getPrefetchHandlerFor(this.module); + // this.canCheckUpdates = this.prefetchDelegate.canCheckUpdates(); + + // this.statusObserver = this.eventsProvider.on(CoreEvents.PACKAGE_STATUS_CHANGED, (data) => { + // if (data.componentId === this.module.id && this.prefetchHandler && + // data.component === this.prefetchHandler.component) { + + // // Call determineModuleStatus to get the right status to display. + // const status = this.prefetchDelegate.determineModuleStatus(this.module, data.status); + + // if (this.downloadEnabled) { + // // Download is enabled, show the status. + // this.showStatus(status); + // } else if (this.module.handlerData.updateStatus) { + // // Download isn't enabled but the handler defines a updateStatus function, call it anyway. + // this.module.handlerData.updateStatus(status); + // } + // } + // }, this.sitesProvider.getCurrentSiteId()); + } + } + + /** + * Function called when the module is clicked. + * + * @param event Click event. + */ + moduleClicked(event: Event): void { + if (this.module?.uservisible !== false && this.module?.handlerData?.action) { + this.module.handlerData.action(event, this.module, this.courseId!); + } + } + + /** + * Function called when a button is clicked. + * + * @param event Click event. + * @param button The clicked button. + */ + buttonClicked(event: Event, button: CoreCourseModuleHandlerButton): void { + if (!button || !button.action) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + button.action(event, this.module!, this.courseId!); + } + + /** + * @todo Download the module. + * + * @param refresh Whether it's refreshing. + */ + // download(refresh: boolean): void { + // if (!this.prefetchHandler) { + // return; + // } + + // // Show spinner since this operation might take a while. + // this.spinner = true; + + // // Get download size to ask for confirm if it's high. + // this.prefetchHandler.getDownloadSize(this.module, this.courseId, true).then((size) => { + // return this.courseHelper.prefetchModule(this.prefetchHandler, this.module, size, this.courseId, refresh); + // }).then(() => { + // const eventData = { + // sectionId: this.section.id, + // moduleId: this.module.id, + // courseId: this.courseId + // }; + // this.statusChanged.emit(eventData); + // }).catch((error) => { + // // Error, hide spinner. + // this.spinner = false; + // if (!this.isDestroyed) { + // this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true); + // } + // }); + // } + + /** + * Show download buttons according to module status. + * + * @param status Module status. + */ + protected showStatus(status: string): void { + if (!status) { + return; + } + + this.spinner = false; + this.downloadStatus = status; + + this.module?.handlerData?.updateStatus?.(status); + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + // this.statusObserver?.off(); + this.module?.handlerData?.onDestroy?.(); + this.isDestroyed = true; + } + +} + +/** + * Data sent to the status changed output. + */ +export type CoreCourseModuleStatusChangedData = { + moduleId: number; + courseId: number; + sectionId?: number; +}; diff --git a/src/core/features/course/pages/contents/contents.html b/src/core/features/course/pages/contents/contents.html index fb0570236..a779fc7fd 100644 --- a/src/core/features/course/pages/contents/contents.html +++ b/src/core/features/course/pages/contents/contents.html @@ -21,9 +21,9 @@ - + \ No newline at end of file diff --git a/src/core/features/course/pages/contents/contents.ts b/src/core/features/course/pages/contents/contents.ts index 32a7e6149..8fc4c86cc 100644 --- a/src/core/features/course/pages/contents/contents.ts +++ b/src/core/features/course/pages/contents/contents.ts @@ -35,7 +35,6 @@ import { } from '@features/course/services/course-options-delegate'; // import { CoreCourseSyncProvider } from '../../providers/sync'; // import { CoreCourseFormatComponent } from '../../components/format/format'; -import { CoreFilterHelper } from '@features/filter/services/filter-helper'; import { CoreEvents, CoreEventObserver, @@ -58,7 +57,7 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy { // @ViewChild(CoreCourseFormatComponent) formatComponent: CoreCourseFormatComponent; course!: CoreCourseAnyCourseData; - sections?: Section[]; + sections?: CoreCourseSectionFormatted[]; sectionId?: number; sectionNumber?: number; courseMenuHandlers: CoreCourseOptionsMenuHandlerToDisplay[] = []; @@ -211,11 +210,6 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy { this.course = result.course; } - // @todo: Get the overview files. Maybe move it to format component? - // if ('overviewfiles' in this.course && this.course.overviewfiles) { - // this.course.imageThumb = this.course.overviewfiles[0] && this.course.overviewfiles[0].fileurl; - // } - if (sync) { // Try to synchronize the course data. // @todo return this.syncProvider.syncCourse(this.course.id).then((result) => { @@ -281,19 +275,6 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy { this.course.fullname, true, ); - - // Format the name of each section. - result.sections.forEach(async (section: Section) => { - const result = await CoreFilterHelper.instance.getFiltersAndFormatText( - section.name.trim(), - 'course', - this.course.id, - { clean: true, singleLine: true }, - ); - - section.formattedName = result.text; - }); - this.sections = result.sections; if (CoreCourseFormatDelegate.instance.canViewAllSections(this.course)) { @@ -527,7 +508,3 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy { } } - -type Section = CoreCourseSectionFormatted & { - formattedName?: string; -}; diff --git a/src/core/features/course/services/course-helper.ts b/src/core/features/course/services/course-helper.ts index 17b8bc3e9..1552ce651 100644 --- a/src/core/features/course/services/course-helper.ts +++ b/src/core/features/course/services/course-helper.ts @@ -593,7 +593,7 @@ export class CoreCourseHelperProvider { * @param siteId Site ID. If not defined, current site. * @return Promise resolved when done. */ - async loadOfflineCompletion(courseId: number, sections: CoreCourseSection[], siteId?: string): Promise { + async loadOfflineCompletion(courseId: number, sections: CoreCourseSectionFormatted[], siteId?: string): Promise { const offlineCompletions = await CoreCourseOffline.instance.getCourseManualCompletions(courseId, siteId); if (!offlineCompletions || !offlineCompletions.length) { @@ -620,7 +620,7 @@ export class CoreCourseHelperProvider { offlineCompletion.timecompleted >= module.completiondata.timecompleted * 1000) { // The module has offline completion. Load it. module.completiondata.state = offlineCompletion.completed; - // @todo module.completiondata.offline = true; + module.completiondata.offline = true; // If all completions have been loaded, stop. loaded++; @@ -776,7 +776,7 @@ export class CoreCourseHelperProvider { * @return Section download ID. * @todo section type. */ - getSectionDownloadId(section: CoreCourseSection): string { + getSectionDownloadId(section: {id: number}): string { return 'Section-' + section.id; } @@ -1097,4 +1097,5 @@ export type CoreCourseModuleCompletionDataFormatted = CoreCourseModuleCompletion courseName?: string; tracking?: number; cmid?: number; + offline?: boolean; }; diff --git a/src/core/features/course/services/course.ts b/src/core/features/course/services/course.ts index 1969a8333..1634572e0 100644 --- a/src/core/features/course/services/course.ts +++ b/src/core/features/course/services/course.ts @@ -953,7 +953,20 @@ export class CoreCourseProvider { completed: completed, }; - return site.write('core_completion_update_activity_completion_status_manually', params); + const result = await site.write( + 'core_completion_update_activity_completion_status_manually', + params, + ); + + if (!result.status) { + if (result.warnings && result.warnings.length) { + throw new CoreWSError(result.warnings[0]); + } else { + throw new CoreError('Cannot change completion.'); + } + } + + return result; } /** diff --git a/src/core/features/course/services/module-delegate.ts b/src/core/features/course/services/module-delegate.ts index 9dbd5cd24..60b3e0e9c 100644 --- a/src/core/features/course/services/module-delegate.ts +++ b/src/core/features/course/services/module-delegate.ts @@ -209,11 +209,15 @@ export interface CoreCourseModuleHandlerButton { /** * The name of the button icon to use in iOS instead of "icon". + * + * @deprecated since 3.9.5. Now the icon must be the same for all platforms. */ iosIcon?: string; /** * The name of the button icon to use in MaterialDesign instead of "icon". + * + * @deprecated since 3.9.5. Now the icon must be the same for all platforms. */ mdIcon?: string; diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts index 317b879f7..db1bdc8d4 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -286,3 +286,11 @@ export type CoreEventSelectCourseTabData = CoreEventSiteData & { export type CoreEventCompletionModuleViewedData = CoreEventSiteData & { courseId?: number; }; + +/** + * Data passed to SECTION_STATUS_CHANGED event. + */ +export type CoreEventSectionStatusChangedData = CoreEventSiteData & { + courseId: number; + sectionId?: number; +}; diff --git a/src/theme/app.scss b/src/theme/app.scss index 705825efe..3bb2d68c2 100644 --- a/src/theme/app.scss +++ b/src/theme/app.scss @@ -335,3 +335,11 @@ ion-select.core-button-select, overflow-x: scroll; flex-direction: row; } + +// Text for accessibility, hidden from the view. +.accesshide { + position: absolute; + left: -10000px; + font-weight: normal; + font-size: 1em; +}