Merge pull request #2666 from dpalou/MOBILE-3659

Mobile 3659
main
Dani Palou 2021-01-29 14:03:25 +01:00 committed by GitHub
commit 69bc2c7480
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
163 changed files with 11942 additions and 772 deletions

View File

@ -201,6 +201,7 @@ const appConfig = {
'no-duplicate-imports': 'error',
'no-empty': 'error',
'no-eval': 'error',
'no-fallthrough': 'off',
'no-invalid-this': 'error',
'no-irregular-whitespace': 'error',
'no-multiple-empty-lines': 'error',

View File

@ -20,8 +20,8 @@ import { CoreSites } from '@services/sites';
import { CoreUser, CoreUserProfile } from '@features/user/services/user';
import { AddonBadges, AddonBadgesUserBadge } from '../../services/badges';
import { CoreUtils } from '@services/utils/utils';
import { ActivatedRoute } from '@angular/router';
import { CoreCourses, CoreEnrolledCourseData } from '@features/courses/services/courses';
import { CoreNavigator } from '@services/navigator';
/**
* Page that displays the list of calendar events.
@ -42,18 +42,13 @@ export class AddonBadgesIssuedBadgePage implements OnInit {
badgeLoaded = false;
currentTime = 0;
constructor(
protected route: ActivatedRoute,
) { }
/**
* View loaded.
*/
ngOnInit(): void {
this.courseId = parseInt(this.route.snapshot.queryParams['courseId'], 10) || this.courseId; // Use 0 for site badges.
this.userId = this.route.snapshot.queryParams['userId'] ||
CoreSites.instance.getCurrentSite()?.getUserId();
this.badgeHash = this.route.snapshot.queryParams['badgeHash'];
this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId') || this.courseId; // Use 0 for site badges.
this.userId = CoreNavigator.instance.getRouteNumberParam('userId') || CoreSites.instance.getCurrentSite()!.getUserId();
this.badgeHash = CoreNavigator.instance.getRouteParam('badgeHash') || '';
this.fetchIssuedBadge().finally(() => {
this.badgeLoaded = true;

View File

@ -20,7 +20,6 @@ import { CoreDomUtils } from '@services/utils/dom';
import { CoreSites } from '@services/sites';
import { CoreUtils } from '@services/utils/utils';
import { CoreNavigator } from '@services/navigator';
import { ActivatedRoute } from '@angular/router';
// @todo import { CoreSplitViewComponent } from '@components/split-view/split-view';
/**
@ -42,18 +41,13 @@ export class AddonBadgesUserBadgesPage implements OnInit {
currentTime = 0;
badgeHash!: string;
constructor(
protected route: ActivatedRoute,
) { }
/**
* View loaded.
*/
ngOnInit(): void {
this.courseId = parseInt(this.route.snapshot.queryParams['courseId'], 10) || this.courseId; // Use 0 for site badges.
this.userId = this.route.snapshot.queryParams['userId'] ||
CoreSites.instance.getCurrentSite()?.getUserId();
this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId') || this.courseId; // Use 0 for site badges.
this.userId = CoreNavigator.instance.getRouteNumberParam('userId') || CoreSites.instance.getCurrentSite()!.getUserId();
this.fetchBadges().finally(() => {
// @todo splitview

View File

@ -14,10 +14,10 @@
import { Component, OnInit, Input } from '@angular/core';
import { CoreSites } from '@services/sites';
import { CoreCourse, CoreCourseSection } from '@features/course/services/course';
import { CoreCourseHelper } from '@features/course/services/course-helper';
import { CoreCourse } from '@features/course/services/course';
import { CoreCourseHelper, CoreCourseSection } from '@features/course/services/course-helper';
import { CoreSiteHome, FrontPageItemNames } from '@features/sitehome/services/sitehome';
// @todo import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate';
import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate';
import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component';
/**
@ -63,7 +63,7 @@ export class AddonBlockSiteMainMenuComponent extends CoreBlockBaseComponent impl
if (this.mainMenuBlock && this.mainMenuBlock.modules) {
// Invalidate modules prefetch data.
// @todo promises.push(this.prefetchDelegate.invalidateModules(this.mainMenuBlock.modules, this.siteHomeId));
promises.push(CoreCourseModulePrefetchDelegate.instance.invalidateModules(this.mainMenuBlock.modules, this.siteHomeId));
}
await Promise.all(promises);
@ -77,8 +77,8 @@ export class AddonBlockSiteMainMenuComponent extends CoreBlockBaseComponent impl
protected async fetchContent(): Promise<void> {
const sections = await CoreCourse.instance.getSections(this.siteHomeId, false, true);
this.mainMenuBlock = sections.find((section) => section.section == 0);
if (!this.mainMenuBlock) {
const mainMenuBlock = sections.find((section) => section.section == 0);
if (!mainMenuBlock) {
return;
}
@ -91,10 +91,17 @@ export class AddonBlockSiteMainMenuComponent extends CoreBlockBaseComponent impl
const items = config.frontpageloggedin.split(',');
const hasNewsItem = items.find((item) => parseInt(item, 10) == FrontPageItemNames['NEWS_ITEMS']);
const hasContent = CoreCourseHelper.instance.sectionHasContent(this.mainMenuBlock);
CoreCourseHelper.instance.addHandlerDataForModules([this.mainMenuBlock], this.siteHomeId, undefined, undefined, true);
const result = await CoreCourseHelper.instance.addHandlerDataForModules(
[mainMenuBlock],
this.siteHomeId,
undefined,
undefined,
true,
);
if (!hasNewsItem || !hasContent) {
this.mainMenuBlock = result.sections[0];
if (!hasNewsItem || !this.mainMenuBlock.hasContent) {
return;
}

View File

@ -149,6 +149,9 @@
display: inline-block;
vertical-align: bottom;
}
.core-module-icon[slot="start"] {
padding: 6px;
}
}
:host-context([dir=rtl]) {

View File

@ -37,7 +37,7 @@ import { AddonCalendarFilterPopoverComponent } from '../../components/filter/fil
import moment from 'moment';
import { Network, NgZone } from '@singletons';
import { CoreNavigator } from '@services/navigator';
import { ActivatedRoute, Params } from '@angular/router';
import { Params } from '@angular/router';
import { Subscription } from 'rxjs';
import { CoreUtils } from '@services/utils/utils';
@ -101,7 +101,6 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy {
};
constructor(
protected route: ActivatedRoute,
private popoverCtrl: PopoverController,
) {
this.currentSiteId = CoreSites.instance.getCurrentSiteId();
@ -235,19 +234,18 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy {
CoreUtils.instance.enumKeys(AddonCalendarEventType).forEach((name) => {
const value = AddonCalendarEventType[name];
const filter = this.route.snapshot.queryParams[name];
this.filter[name] = typeof filter == 'undefined' ? true : filter;
this.filter[name] = CoreNavigator.instance.getRouteBooleanParam(name) ?? true;
types.push(value);
});
this.filter.courseId = parseInt(this.route.snapshot.queryParams['courseId'], 10) || -1;
this.filter.categoryId = parseInt(this.route.snapshot.queryParams['categoryId'], 10) || undefined;
this.filter.courseId = CoreNavigator.instance.getRouteNumberParam('courseId') || -1;
this.filter.categoryId = CoreNavigator.instance.getRouteNumberParam('categoryId');
this.filter.filtered = typeof this.filter.courseId != 'undefined' || types.some((name) => !this.filter[name]);
const now = new Date();
this.year = this.route.snapshot.queryParams['year'] || now.getFullYear();
this.month = this.route.snapshot.queryParams['month'] || (now.getMonth() + 1);
this.day = this.route.snapshot.queryParams['day'] || now.getDate();
this.year = CoreNavigator.instance.getRouteNumberParam('year') || now.getFullYear();
this.month = CoreNavigator.instance.getRouteNumberParam('month') || (now.getMonth() + 1);
this.day = CoreNavigator.instance.getRouteNumberParam('day') || now.getDate();
this.calculateCurrentMoment();
this.calculateIsCurrentDay();

View File

@ -14,7 +14,7 @@
import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core';
import { FormControl, FormGroup, FormBuilder, Validators } from '@angular/forms';
import { IonRefresher, NavController } from '@ionic/angular';
import { IonRefresher } from '@ionic/angular';
import { CoreEvents } from '@singletons/events';
import { CoreGroup, CoreGroups } from '@services/groups';
import { CoreSites } from '@services/sites';
@ -40,9 +40,9 @@ import { AddonCalendarSync, AddonCalendarSyncProvider } from '../../services/cal
import { CoreSite } from '@classes/site';
import { Translate } from '@singletons';
import { CoreFilterHelper } from '@features/filter/services/filter-helper';
import { ActivatedRoute } from '@angular/router';
import { AddonCalendarOfflineEventDBRecord } from '../../services/database/calendar-offline';
import { CoreError } from '@classes/errors/error';
import { CoreNavigator } from '@services/navigator';
/**
* Page that displays a form to create/edit an event.
@ -90,8 +90,6 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy {
protected gotEventData = false;
constructor(
protected navCtrl: NavController,
protected route: ActivatedRoute,
protected fb: FormBuilder,
) {
@ -128,11 +126,11 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy {
* Component being initialized.
*/
ngOnInit(): void {
this.eventId = this.route.snapshot.queryParams['eventId'];
this.courseId = parseInt(this.route.snapshot.queryParams['courseId'], 10) || 0;
this.eventId = CoreNavigator.instance.getRouteNumberParam('eventId');
this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId') || 0;
this.title = this.eventId ? 'addon.calendar.editevent' : 'addon.calendar.newevent';
const timestamp = parseInt(this.route.snapshot.queryParams['timestamp'], 10);
const timestamp = CoreNavigator.instance.getRouteNumberParam('timestamp');
const currentDate = CoreTimeUtils.instance.toDatetimeFormat(timestamp);
this.form.addControl('timestart', this.fb.control(currentDate, Validators.required));
this.form.addControl('timedurationuntil', this.fb.control(currentDate));
@ -578,7 +576,7 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy {
this.originalData = CoreUtils.instance.clone(this.form.value);
} else {*/
this.originalData = undefined; // Avoid asking for confirmation.
this.navCtrl.pop();
CoreNavigator.instance.back();
}
/**

View File

@ -43,7 +43,6 @@ import { Subscription } from 'rxjs';
import { CoreNavigator } from '@services/navigator';
import { CoreUtils } from '@services/utils/utils';
import { AddonCalendarReminderDBRecord } from '../../services/database/calendar';
import { ActivatedRoute } from '@angular/router';
/**
* Page that displays a single calendar event.
@ -86,11 +85,7 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
syncIcon = 'spinner'; // Sync icon.
isSplitViewOn = false;
constructor(
protected route: ActivatedRoute,
// @Optional() private svComponent: CoreSplitViewComponent,
) {
constructor() {
this.notificationsEnabled = CoreLocalNotifications.instance.isAvailable();
this.siteHomeId = CoreSites.instance.getCurrentSiteHomeId();
this.currentSiteId = CoreSites.instance.getCurrentSiteId();
@ -150,8 +145,15 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
* View loaded.
*/
ngOnInit(): void {
this.eventId = this.route.snapshot.queryParams['id'];
const eventId = CoreNavigator.instance.getRouteNumberParam('id');
if (!eventId) {
CoreDomUtils.instance.showErrorModal('Event ID not supplied.');
CoreNavigator.instance.back();
return;
}
this.eventId = eventId;
this.syncIcon = 'spinner';
this.fetchEvent();

View File

@ -168,12 +168,12 @@ export class AddonCalendarIndexPage implements OnInit, OnDestroy {
ngOnInit(): void {
this.notificationsEnabled = CoreLocalNotifications.instance.isAvailable();
this.route.queryParams.subscribe(params => {
this.eventId = parseInt(params['eventId'], 10) || undefined;
this.filter.courseId = parseInt(params['courseId'], 10) || -1;
this.year = parseInt(params['year'], 10) || undefined;
this.month = parseInt(params['month'], 10) || undefined;
this.loadUpcoming = !!params['upcoming'];
this.route.queryParams.subscribe(() => {
this.eventId = CoreNavigator.instance.getRouteNumberParam('eventId');
this.filter.courseId = CoreNavigator.instance.getRouteNumberParam('courseId') || -1;
this.year = CoreNavigator.instance.getRouteNumberParam('year');
this.month = CoreNavigator.instance.getRouteNumberParam('month');
this.loadUpcoming = !!CoreNavigator.instance.getRouteBooleanParam('upcoming');
this.showCalendar = !this.loadUpcoming;
this.filter.filtered = this.filter.courseId > 0;

View File

@ -34,7 +34,7 @@ import { CoreApp } from '@services/app';
import moment from 'moment';
import { CoreConstants } from '@/core/constants';
import { AddonCalendarFilterPopoverComponent } from '../../components/filter/filter';
import { ActivatedRoute, Params } from '@angular/router';
import { Params } from '@angular/router';
import { Subscription } from 'rxjs';
import { Network, NgZone } from '@singletons';
import { CoreCoursesHelper } from '@features/courses/services/courses-helper';
@ -102,7 +102,6 @@ export class AddonCalendarListPage implements OnInit, OnDestroy {
};
constructor(
protected route: ActivatedRoute,
private popoverCtrl: PopoverController,
) {
@ -248,8 +247,8 @@ export class AddonCalendarListPage implements OnInit, OnDestroy {
* View loaded.
*/
async ngOnInit(): Promise<void> {
this.eventId = this.route.snapshot.queryParams['eventId'] || undefined;
this.filter.courseId = this.route.snapshot.queryParams['courseId'];
this.eventId = CoreNavigator.instance.getRouteNumberParam('eventId');
this.filter.courseId = CoreNavigator.instance.getRouteNumberParam('courseId') || -1;
if (this.eventId) {
// There is an event to load, open the event in a new state.

View File

@ -61,7 +61,6 @@ export class AddonFilterMediaPluginHandlerService extends CoreFilterDefaultHandl
* Treat video filters. Currently only treating youtube video using video JS.
*
* @param el Video element.
* @param navCtrl NavController to use.
*/
protected treatVideoFilters(video: HTMLElement): void {
// Treat Video JS Youtube video links and translate them to iframes.

View File

@ -74,7 +74,6 @@ export class AddonNotificationsActionsComponent implements OnInit {
* Default action. Open in browser.
*
* @param siteId Site ID to use.
* @param navCtrl NavController.
*/
protected async openInBrowser(siteId?: string): Promise<void> {
const url = <string> this.data?.appurl || this.contextUrl;

View File

@ -13,7 +13,7 @@
// limitations under the License.
import { Component, OnInit, OnDestroy } from '@angular/core';
import { IonRefresher, NavController } from '@ionic/angular';
import { IonRefresher } from '@ionic/angular';
import { CoreConfig } from '@services/config';
import { CoreLocalNotifications } from '@services/local-notifications';
@ -61,10 +61,7 @@ export class AddonNotificationsSettingsPage implements OnInit, OnDestroy {
protected updateTimeout?: number;
constructor(
protected navCtrl: NavController,
// @Optional() protected svComponent: CoreSplitViewComponent,
) {
constructor() { // @todo @Optional() protected svComponent: CoreSplitViewComponent,
this.notifPrefsEnabled = AddonNotifications.instance.isNotificationPreferencesEnabled();
this.canChangeSound = CoreLocalNotifications.instance.canDisableSound();
}

View File

@ -13,8 +13,7 @@
// limitations under the License.
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { IonRefresher, NavController } from '@ionic/angular';
import { IonRefresher } from '@ionic/angular';
import { Md5 } from 'ts-md5/dist/md5';
import { CoreApp } from '@services/app';
@ -32,6 +31,7 @@ import {
} from '@/addons/privatefiles/services/privatefiles';
import { AddonPrivateFilesHelper } from '@/addons/privatefiles/services/privatefiles-helper';
import { CoreUtils } from '@services/utils/utils';
import { CoreNavigator } from '@services/navigator';
/**
* Page that displays the list of files.
@ -58,10 +58,7 @@ export class AddonPrivateFilesIndexPage implements OnInit, OnDestroy {
protected updateSiteObserver: CoreEventObserver;
constructor(
protected route: ActivatedRoute,
protected navCtrl: NavController,
) {
constructor() {
// Update visibility if current site info is updated.
this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => {
this.setVisibility();
@ -72,17 +69,18 @@ export class AddonPrivateFilesIndexPage implements OnInit, OnDestroy {
* Component being initialized.
*/
ngOnInit(): void {
this.root = this.route.snapshot.queryParams['root'];
this.root = CoreNavigator.instance.getRouteParam('root');
const contextId = CoreNavigator.instance.getRouteNumberParam('contextid');
if (this.route.snapshot.queryParams['contextid']) {
if (contextId) {
// Loading a certain folder.
this.path = {
contextid: this.route.snapshot.queryParams['contextid'],
component: this.route.snapshot.queryParams['component'],
filearea: this.route.snapshot.queryParams['filearea'],
itemid: this.route.snapshot.queryParams['itemid'],
filepath: this.route.snapshot.queryParams['filepath'],
filename: this.route.snapshot.queryParams['filename'],
contextid: contextId,
component: CoreNavigator.instance.getRouteParam<string>('component')!,
filearea: CoreNavigator.instance.getRouteParam<string>('filearea')!,
itemid: CoreNavigator.instance.getRouteNumberParam('itemid')!,
filepath: CoreNavigator.instance.getRouteParam<string>('filepath')!,
filename: CoreNavigator.instance.getRouteParam<string>('filename')!,
};
}
@ -254,10 +252,7 @@ export class AddonPrivateFilesIndexPage implements OnInit, OnDestroy {
const hash = <string> Md5.hashAsciiStr(JSON.stringify(params));
this.navCtrl.navigateForward([`../${hash}`], {
relativeTo: this.route,
queryParams: params,
});
CoreNavigator.instance.navigate(`../${hash}`, { params });
}
/**

View File

@ -13,7 +13,6 @@
// limitations under the License.
import { Observable } from 'rxjs';
import { NavController } from '@ionic/angular';
import { AppComponent } from '@/app/app.component';
import { CoreApp } from '@services/app';
@ -22,11 +21,12 @@ import { CoreLangProvider } from '@services/lang';
import { Network, Platform, NgZone } from '@singletons';
import { mock, mockSingleton, renderComponent, RenderConfig } from '@/testing/utils';
import { CoreNavigator, CoreNavigatorService } from '@services/navigator';
describe('AppComponent', () => {
let langProvider: CoreLangProvider;
let navController: NavController;
let navigator: CoreNavigatorService;
let config: Partial<RenderConfig>;
beforeEach(() => {
@ -35,12 +35,11 @@ describe('AppComponent', () => {
mockSingleton(Platform, { ready: () => Promise.resolve() });
mockSingleton(NgZone, { run: jest.fn() });
navigator = mockSingleton(CoreNavigator, ['navigate']);
langProvider = mock<CoreLangProvider>(['clearCustomStrings']);
navController = mock<NavController>(['navigateRoot']);
config = {
providers: [
{ provide: CoreLangProvider, useValue: langProvider },
{ provide: NavController, useValue: navController },
],
};
});
@ -59,7 +58,7 @@ describe('AppComponent', () => {
CoreEvents.trigger(CoreEvents.LOGOUT);
expect(langProvider.clearCustomStrings).toHaveBeenCalled();
expect(navController.navigateRoot).toHaveBeenCalledWith('/login/sites');
expect(navigator.navigate).toHaveBeenCalledWith('/login/sites', { reset: true });
});
it.todo('shows loading while app isn\'t ready');

View File

@ -13,7 +13,6 @@
// limitations under the License.
import { Component, OnInit } from '@angular/core';
import { NavController } from '@ionic/angular';
import { CoreLangProvider } from '@services/lang';
import { CoreLoginHelperProvider } from '@features/login/services/login-helper';
@ -27,6 +26,7 @@ import {
import { Network, NgZone, Platform } from '@singletons';
import { CoreApp } from '@services/app';
import { CoreSites } from '@services/sites';
import { CoreNavigator } from '@services/navigator';
@Component({
selector: 'app-root',
@ -37,7 +37,6 @@ export class AppComponent implements OnInit {
constructor(
protected langProvider: CoreLangProvider,
protected navCtrl: NavController,
protected loginHelper: CoreLoginHelperProvider,
) {
}
@ -56,7 +55,7 @@ export class AppComponent implements OnInit {
ngOnInit(): void {
CoreEvents.on(CoreEvents.LOGOUT, () => {
// Go to sites page when user is logged out.
this.navCtrl.navigateRoot('/login/sites');
CoreNavigator.instance.navigate('/login/sites', { reset: true });
// Unload lang custom strings.
this.langProvider.clearCustomStrings();

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 15.1.0, SVG Export Plug-In -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">
]>
<svg version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/"
x="0px" y="0px" width="16px" height="16px" viewBox="0 0 16 16" style="overflow:visible;enable-background:new 0 0 16 16;"
xml:space="preserve" preserveAspectRatio="xMinYMid meet">
<defs>
</defs>
<path style="fill:#999999;" d="M10,0v2H6V0H10z M11,2h1c1.1,0,2,0.9,2,2v1h2V2c0-1.1-0.9-2-2-2h-3V2z M16,6h-2v4h2V6z M14,11v1
c0,1.1-0.9,2-2,2h-1v2h3c1.1,0,2-0.9,2-2v-3H14z M10,16v-2H6v2H10z M5,14H4c-1.1,0-2-0.9-2-2v-1H0v3c0,1.1,0.9,2,2,2h3V14z M0,10h2
V6H0V10z M2,5V4c0-1.1,0.9-2,2-2h1V0H2C0.9,0,0,0.9,0,2v3H2z"/>
<path style="fill:#FF403C;" d="M10.2,8l2.6-2.6c0.4-0.4,0.4-1,0-1.4L12,3.3c-0.4-0.4-1-0.4-1.4,0L8,5.9L5.4,3.3
c-0.4-0.4-1-0.4-1.4,0L3.3,4c-0.4,0.4-0.4,1,0,1.4L5.9,8l-2.6,2.6C3,11,3,11.6,3.3,12l0.7,0.7c0.4,0.4,1,0.4,1.4,0L8,10.2l2.5,2.5
c0.4,0.4,1,0.4,1.4,0l0.7-0.7c0.4-0.4,0.4-1,0-1.4L10.2,8z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,3 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">
]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M10 0v2H6V0h4zm1 2h1c1.1 0 2 .9 2 2v1h2V2c0-1.1-.9-2-2-2h-3v2zm5 4h-2v4h2V6zm-2 5v1c0 1.1-.9 2-2 2h-1v2h3c1.1 0 2-.9 2-2v-3h-2zm-4 5v-2H6v2h4zm-5-2H4c-1.1 0-2-.9-2-2v-1H0v3c0 1.1.9 2 2 2h3v-2zm-5-4h2V6H0v4zm2-5V4c0-1.1.9-2 2-2h1V0H2C.9 0 0 .9 0 2v3h2z" fill="#FF2727"/></svg>

After

Width:  |  Height:  |  Size: 579 B

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 15.1.0, SVG Export Plug-In -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">
]>
<svg version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/"
x="0px" y="0px" width="16px" height="16px" viewBox="0 0 16 16" style="overflow:visible;enable-background:new 0 0 16 16;"
xml:space="preserve" preserveAspectRatio="xMinYMid meet">
<defs>
</defs>
<path style="fill:#999999;" d="M10,0v2H6V0H10z M11,2h1c1.1,0,2,0.9,2,2v1h2V2c0-1.1-0.9-2-2-2h-3V2z M16,6h-2v4h2V6z M14,11v1
c0,1.1-0.9,2-2,2h-1v2h3c1.1,0,2-0.9,2-2v-3H14z M10,16v-2H6v2H10z M5,14H4c-1.1,0-2-0.9-2-2v-1H0v3c0,1.1,0.9,2,2,2h3V14z M0,10h2
V6H0V10z M2,5V4c0-1.1,0.9-2,2-2h1V0H2C0.9,0,0,0.9,0,2v3H2z"/>
</svg>

After

Width:  |  Height:  |  Size: 955 B

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 15.1.0, SVG Export Plug-In -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">
]>
<svg version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/"
x="0px" y="0px" width="16px" height="16px" viewBox="0 0 16 16" style="overflow:visible;enable-background:new 0 0 16 16;"
xml:space="preserve" preserveAspectRatio="xMinYMid meet">
<defs>
</defs>
<path style="fill:#999999;" d="M10,0v2H6V0H10z M11,2h1c0.4,0,0.8,0.1,1.1,0.3c0.3-0.3,0.8-0.4,1.2-0.4c0.5,0,1,0.2,1.4,0.6L16,2.8
V2c0-1.1-0.9-2-2-2h-3V2z M14,10h2V6.4l-2,2V10z M14,11v1c0,1.1-0.9,2-2,2h-1v2h3c1.1,0,2-0.9,2-2v-3H14z M10,16v-2H6v2H10z M5,14H4
c-1.1,0-2-0.9-2-2v-1H0v3c0,1.1,0.9,2,2,2h3V14z M0,10h2V6H0V10z M2,5V4c0-1.1,0.9-2,2-2h1V0H2C0.9,0,0,0.9,0,2v3H2z"/>
<path style="fill:#99CC33;" d="M15.7,3.9L15,3.2c-0.4-0.4-1-0.4-1.4,0l-6,6L5.4,7C5,6.7,4.4,6.7,4,7L3.3,7.7c-0.4,0.4-0.4,1,0,1.4
l3.6,3.6c0.4,0.4,1,0.4,1.4,0l7.4-7.4C16.1,4.9,16.1,4.3,15.7,3.9z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,3 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">
]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M10 0v2H6V0h4zm1 2h1c.4 0 .8.1 1.1.3.3-.3.8-.4 1.2-.4.5 0 1 .2 1.4.6l.3.3V2c0-1.1-.9-2-2-2h-3v2zm3 8h2V6.4l-2 2V10zm0 1v1c0 1.1-.9 2-2 2h-1v2h3c1.1 0 2-.9 2-2v-3h-2zm-4 5v-2H6v2h4zm-5-2H4c-1.1 0-2-.9-2-2v-1H0v3c0 1.1.9 2 2 2h3v-2zm-5-4h2V6H0v4zm2-5V4c0-1.1.9-2 2-2h1V0H2C.9 0 0 .9 0 2v3h2z" fill="#FF2727"/><path d="M15.7 3.9l-.7-.7c-.4-.4-1-.4-1.4 0l-6 6L5.4 7c-.4-.3-1-.3-1.4 0l-.7.7c-.4.4-.4 1 0 1.4l3.6 3.6c.4.4 1 .4 1.4 0l7.4-7.4c.4-.4.4-1 0-1.4z" fill="#76A1F0"/></svg>

After

Width:  |  Height:  |  Size: 779 B

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 15.1.0, SVG Export Plug-In -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">
]>
<svg version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/"
x="0px" y="0px" width="16px" height="16px" viewBox="0 0 16 16" style="overflow:visible;enable-background:new 0 0 16 16;"
xml:space="preserve" preserveAspectRatio="xMinYMid meet">
<defs>
</defs>
<path style="fill:#999999;" d="M10,0v2H6V0H10z M11,2h1c0.4,0,0.8,0.1,1.1,0.3c0.3-0.3,0.8-0.4,1.2-0.4c0.5,0,1,0.2,1.4,0.6L16,2.8
V2c0-1.1-0.9-2-2-2h-3V2z M14,10h2V6.4l-2,2V10z M14,11v1c0,1.1-0.9,2-2,2h-1v2h3c1.1,0,2-0.9,2-2v-3H14z M10,16v-2H6v2H10z M5,14H4
c-1.1,0-2-0.9-2-2v-1H0v3c0,1.1,0.9,2,2,2h3V14z M0,10h2V6H0V10z M2,5V4c0-1.1,0.9-2,2-2h1V0H2C0.9,0,0,0.9,0,2v3H2z"/>
<path style="fill:#76A1F0;" d="M15.7,3.9L15,3.2c-0.4-0.4-1-0.4-1.4,0l-6,6L5.4,7C5,6.7,4.4,6.7,4,7L3.3,7.7c-0.4,0.4-0.4,1,0,1.4
l3.6,3.6c0.4,0.4,1,0.4,1.4,0l7.4-7.4C16.1,4.9,16.1,4.3,15.7,3.9z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,3 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">
]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M14 0H2C.9 0 0 .9 0 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V2c0-1.1-.9-2-2-2zm0 12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h8c1.1 0 2 .9 2 2v8z" fill="#FF2727"/></svg>

After

Width:  |  Height:  |  Size: 476 B

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 15.1.0, SVG Export Plug-In -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">
]>
<svg version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/"
x="0px" y="0px" width="16px" height="16px" viewBox="0 0 16 16" style="overflow:visible;enable-background:new 0 0 16 16;"
xml:space="preserve" preserveAspectRatio="xMinYMid meet">
<defs>
</defs>
<path style="fill:#999999;" d="M14,0H2C0.9,0,0,0.9,0,2v12c0,1.1,0.9,2,2,2h12c1.1,0,2-0.9,2-2V2C16,0.9,15.1,0,14,0z M14,12
c0,1.1-0.9,2-2,2H4c-1.1,0-2-0.9-2-2V4c0-1.1,0.9-2,2-2h8c1.1,0,2,0.9,2,2V12z"/>
</svg>

After

Width:  |  Height:  |  Size: 841 B

View File

@ -0,0 +1,3 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">
]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M14 8.4V12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h8c.4 0 .8.1 1.1.3.3-.3.8-.4 1.2-.4.5 0 1 .2 1.4.6l.3.3V2c0-1.1-.9-2-2-2H2C.9 0 0 .9 0 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V6.4l-2 2z" fill="#FF2727"/><path d="M15.7 3.9l-.7-.7c-.4-.4-1-.4-1.4 0l-6 6L5.4 7c-.4-.3-1-.3-1.4 0l-.7.7c-.4.4-.4 1 0 1.4l3.6 3.6c.4.4 1 .4 1.4 0l7.4-7.4c.4-.4.4-1 0-1.4z" fill="#76A1F0"/></svg>

After

Width:  |  Height:  |  Size: 682 B

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 15.1.0, SVG Export Plug-In -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">
]>
<svg version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/"
x="0px" y="0px" width="16px" height="16px" viewBox="0 0 16 16" style="overflow:visible;enable-background:new 0 0 16 16;"
xml:space="preserve" preserveAspectRatio="xMinYMid meet">
<defs>
</defs>
<path style="fill:#999999;" d="M14,8.4V12c0,1.1-0.9,2-2,2H4c-1.1,0-2-0.9-2-2V4c0-1.1,0.9-2,2-2h8c0.4,0,0.8,0.1,1.1,0.3
c0.3-0.3,0.8-0.4,1.2-0.4c0.5,0,1,0.2,1.4,0.6L16,2.8V2c0-1.1-0.9-2-2-2H2C0.9,0,0,0.9,0,2v12c0,1.1,0.9,2,2,2h12c1.1,0,2-0.9,2-2
V6.4L14,8.4z"/>
<path style="fill:#76A1F0;" d="M15.7,3.9L15,3.2c-0.4-0.4-1-0.4-1.4,0l-6,6L5.4,7C5,6.7,4.4,6.7,4,7L3.3,7.7c-0.4,0.4-0.4,1,0,1.4
l3.6,3.6c0.4,0.4,1,0.4,1.4,0l7.4-7.4C16.1,4.9,16.1,4.3,15.7,3.9z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,26 @@
// (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 { Translate } from '@singletons';
/**
* Network error. It will automatically set the right error message if none is provided.
*/
export class CoreNetworkError extends Error {
constructor(message?: string) {
super(message || Translate.instance.instant('core.networkerrormsg'));
}
}

View File

@ -26,6 +26,7 @@ import {
CoreWSAjaxPreSets,
CoreWSExternalWarning,
CoreWSUploadFileResult,
CoreWSPreSetsSplitRequest,
} from '@services/ws';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text';
@ -516,6 +517,7 @@ export class CoreSite {
cleanUnicode: this.cleanUnicode,
typeExpected: preSets.typeExpected,
responseExpected: preSets.responseExpected,
splitRequest: preSets.splitRequest,
};
if (wsPreSets.cleanUnicode && CoreTextUtils.instance.hasUnicodeData(data)) {
@ -2052,6 +2054,12 @@ export type CoreSiteWSPreSets = {
* Component id. Optionally included when 'component' is set.
*/
componentId?: number;
/**
* Whether to split a request if it has too many parameters. Sending too many parameters to the site
* can cause the request to fail (see PHP's max_input_vars).
*/
splitRequest?: CoreWSPreSetsSplitRequest;
};
/**

View File

@ -24,12 +24,10 @@ import {
KeyValueDiffers,
SimpleChange,
ChangeDetectorRef,
Optional,
ElementRef,
KeyValueDiffer,
Type,
} from '@angular/core';
import { NavController } from '@ionic/angular';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreLogger } from '@singletons/logger';
@ -57,7 +55,7 @@ import { CoreLogger } from '@singletons/logger';
*
* Alternatively, you can also supply a ComponentRef instead of the class of the component. In this case, the component won't
* be instantiated because it already is, it will be attached to the view and the right data will be passed to it.
* Passing ComponentRef is meant for site plugins, so we'll inject a NavController instance to the component.
* Passing ComponentRef is meant for site plugins.
*
* The contents of this component will be displayed if no component is supplied or it cannot be created. In the example above,
* if no component is supplied then the template will show the message "Cannot render the data.".
@ -90,7 +88,6 @@ export class CoreDynamicComponent implements OnChanges, DoCheck {
constructor(
protected factoryResolver: ComponentFactoryResolver,
differs: KeyValueDiffers,
@Optional() protected navCtrl: NavController,
protected cdr: ChangeDetectorRef,
protected element: ElementRef,
) {
@ -167,7 +164,6 @@ export class CoreDynamicComponent implements OnChanges, DoCheck {
// This feature is usually meant for site plugins. Inject some properties.
this.instance['ChangeDetectorRef'] = this.cdr;
this.instance['NavController'] = this.navCtrl;
this.instance['componentContainer'] = this.element.nativeElement;
} else {
try {

View File

@ -16,7 +16,6 @@ import {
Component, Input, Output, ViewChild, ElementRef, EventEmitter, OnChanges, SimpleChange,
} from '@angular/core';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { NavController } from '@ionic/angular';
import { CoreFile } from '@services/file';
import { CoreDomUtils } from '@services/utils/dom';
@ -47,7 +46,6 @@ export class CoreIframeComponent implements OnChanges {
constructor(
protected sanitizer: DomSanitizer,
protected navCtrl: NavController,
) {
this.logger = CoreLogger.getInstance('CoreIframe');
@ -77,7 +75,8 @@ export class CoreIframeComponent implements OnChanges {
this.loading = !this.src || !CoreUrlUtils.instance.isLocalFileUrl(this.src);
// @todo const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl;
CoreIframeUtils.instance.treatFrame(iframe, false, this.navCtrl);
// CoreIframeUtils.instance.treatFrame(iframe, false, this.navCtrl);
CoreIframeUtils.instance.treatFrame(iframe, false);
iframe.addEventListener('load', () => {
this.loading = false;

View File

@ -1,5 +1,5 @@
<ion-tabs class="hide-header">
<ion-tab-bar slot="top" class="core-tabs-bar" [hidden]="!tabs || numTabsShown < 1">
<ion-tab-bar slot="top" class="core-tabs-bar" [hidden]="!tabs || numTabsShown <= 1">
<ion-spinner *ngIf="!hideUntil"></ion-spinner>
<ion-row *ngIf="hideUntil">
<ion-col class="col-with-arrow ion-no-padding" (click)="slidePrev()" size="1">

View File

@ -24,18 +24,18 @@ import {
ViewChild,
ElementRef,
} from '@angular/core';
import { Platform, IonSlides, IonTabs, NavController } from '@ionic/angular';
import { Platform, IonSlides, IonTabs } from '@ionic/angular';
import { TranslateService } from '@ngx-translate/core';
import { Subscription } from 'rxjs';
import { CoreApp } from '@services/app';
import { CoreConfig } from '@services/config';
import { CoreConstants } from '@/core/constants';
import { CoreUtils } from '@services/utils/utils';
import { NavigationOptions } from '@ionic/angular/providers/nav-controller';
import { Params } from '@angular/router';
import { CoreNavBarButtonsComponent } from '../navbar-buttons/navbar-buttons';
import { CoreDomUtils } from '@services/utils/dom';
import { StackEvent } from '@ionic/angular/directives/navigation/stack-utils';
import { CoreNavigator } from '@services/navigator';
/**
* This component displays some top scrollable tabs that will autohide on vertical scroll.
@ -106,7 +106,7 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe
protected unregisterBackButtonAction: any;
protected languageChangedSubscription: Subscription;
protected isInTransition = false; // Weather Slides is in transition.
protected slidesSwiper: any;
protected slidesSwiper: any; // eslint-disable-line @typescript-eslint/no-explicit-any
protected slidesSwiperLoaded = false;
protected stackEventsSubscription?: Subscription;
@ -114,7 +114,6 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe
protected element: ElementRef,
platform: Platform,
translate: TranslateService,
protected navCtrl: NavController,
) {
this.direction = platform.isRTL ? 'rtl' : 'ltr';
@ -338,7 +337,7 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe
return;
}
this.firstSelectedTab = selectedTab.id;
this.firstSelectedTab = selectedTab.id!;
this.selectTab(this.firstSelectedTab);
// Setup tab scrolling.
@ -548,18 +547,31 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe
}
/**
* Tab selected.
* Select a tab by ID.
*
* @param tabId Selected tab index.
* @param tabId Tab ID.
* @param e Event.
* @return Promise resolved when done.
*/
async selectTab(tabId: string, e?: Event): Promise<void> {
let index = this.tabs.findIndex((tab) => tabId == tab.id);
const index = this.tabs.findIndex((tab) => tabId == tab.id);
return this.selectByIndex(index, e);
}
/**
* Select a tab by index.
*
* @param index Index to select.
* @param e Event.
* @return Promise resolved when done.
*/
async selectByIndex(index: number, e?: Event): Promise<void> {
if (index < 0 || index >= this.tabs.length) {
if (this.selected) {
// Invalid index do not change tab.
e && e.preventDefault();
e && e.stopPropagation();
e?.preventDefault();
e?.stopPropagation();
return;
}
@ -568,12 +580,11 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe
index = 0;
}
const selectedTab = this.tabs[index];
if (tabId == this.selected || !selectedTab || !selectedTab.enabled) {
const tabToSelect = this.tabs[index];
if (!tabToSelect || !tabToSelect.enabled || tabToSelect.id == this.selected) {
// Already selected or not enabled.
e && e.preventDefault();
e && e.stopPropagation();
e?.preventDefault();
e?.stopPropagation();
return;
}
@ -582,18 +593,16 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe
await this.slides!.slideTo(index);
}
const pageParams: NavigationOptions = {};
if (selectedTab.pageParams) {
pageParams.queryParams = selectedTab.pageParams;
}
const ok = await this.navCtrl.navigateForward(selectedTab.page, pageParams);
const ok = await CoreNavigator.instance.navigate(tabToSelect.page, {
params: tabToSelect.pageParams,
});
if (ok !== false) {
this.selectHistory.push(tabId);
this.selected = tabId;
this.selectHistory.push(tabToSelect.id!);
this.selected = tabToSelect.id;
this.selectedIndex = index;
this.ionChange.emit(selectedTab);
this.ionChange.emit(tabToSelect);
}
}
@ -644,16 +653,14 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe
/**
* Core Tab class.
*/
class CoreTab {
id = ''; // Unique tab id.
class = ''; // Class, if needed.
title = ''; // The translatable tab title.
export type CoreTab = {
page: string; // Page to navigate to.
title: string; // The translatable tab title.
id?: string; // Unique tab id.
class?: string; // Class, if needed.
icon?: string; // The tab icon.
badge?: string; // A badge to add in the tab.
badgeStyle?: string; // The badge color.
enabled = true; // Whether the tab is enabled.
page = ''; // Page to navigate to.
enabled?: boolean; // Whether the tab is enabled.
pageParams?: Params; // Page params.
}
};

View File

@ -13,15 +13,13 @@
// limitations under the License.
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChange } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { NavController } from '@ionic/angular';
import { CoreApp } from '@services/app';
import { CoreSites } from '@services/sites';
import { CoreUtils } from '@services/utils/utils';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreObject } from '@singletons/object';
import { CoreUserProvider, CoreUserBasicData, CoreUserProfilePictureUpdatedData } from '@features/user/services/user';
import { CoreNavigator } from '@services/navigator';
/**
* Component to display a "user avatar".
@ -53,10 +51,7 @@ export class CoreUserAvatarComponent implements OnInit, OnChanges, OnDestroy {
protected currentUserId: number;
protected pictureObserver: CoreEventObserver;
constructor(
protected navCtrl: NavController,
protected route: ActivatedRoute,
) {
constructor() {
this.currentUserId = CoreSites.instance.getCurrentSiteUserId();
this.pictureObserver = CoreEvents.on<CoreUserProfilePictureUpdatedData>(
@ -143,12 +138,11 @@ export class CoreUserAvatarComponent implements OnInit, OnChanges, OnDestroy {
event.stopPropagation();
// @todo Decide which navCtrl to use. If this component is inside a split view, use the split view's master nav.
this.navCtrl.navigateForward(['user'], {
relativeTo: this.route,
queryParams: CoreObject.withoutEmpty({
CoreNavigator.instance.navigateToSitePath('user', {
params: {
userId: this.userId,
courseId: this.courseId,
}),
},
});
}

View File

@ -23,7 +23,7 @@ import {
Optional,
ViewContainerRef,
} from '@angular/core';
import { NavController, IonContent } from '@ionic/angular';
import { IonContent } from '@ionic/angular';
import { CoreEventLoadingChangedData, CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreSites } from '@services/sites';
@ -84,7 +84,6 @@ export class CoreFormatTextDirective implements OnChanges {
constructor(
element: ElementRef,
@Optional() protected navCtrl: NavController,
@Optional() protected content: IonContent,
protected viewContainerRef: ViewContainerRef,
) {
@ -471,7 +470,8 @@ export class CoreFormatTextDirective implements OnChanges {
*/
protected async treatHTMLElements(div: HTMLElement, site?: CoreSite): Promise<void> {
const canTreatVimeo = site?.isVersionGreaterEqualThan(['3.3.4', '3.4']) || false;
const navCtrl = this.navCtrl; // @todo this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl;
// @todo this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl;
// @todo: Pass navCtrl to all treateFrame calls?
const images = Array.from(div.querySelectorAll('img'));
const anchors = Array.from(div.querySelectorAll('a'));
@ -521,7 +521,7 @@ export class CoreFormatTextDirective implements OnChanges {
});
iframes.forEach((iframe) => {
this.treatIframe(iframe, site, canTreatVimeo, navCtrl);
this.treatIframe(iframe, site, canTreatVimeo);
});
svgImages.forEach((image) => {
@ -554,7 +554,7 @@ export class CoreFormatTextDirective implements OnChanges {
// Handle all kind of frames.
frames.forEach((frame: HTMLFrameElement | HTMLObjectElement | HTMLEmbedElement) => {
CoreIframeUtils.instance.treatFrame(frame, false, navCtrl);
CoreIframeUtils.instance.treatFrame(frame, false);
});
CoreDomUtils.instance.handleBootstrapTooltips(div);
@ -671,13 +671,11 @@ export class CoreFormatTextDirective implements OnChanges {
* @param iframe Iframe to treat.
* @param site Site instance.
* @param canTreatVimeo Whether Vimeo videos can be treated in the site.
* @param navCtrl NavController to use.
*/
protected async treatIframe(
iframe: HTMLIFrameElement,
site: CoreSite | undefined,
canTreatVimeo: boolean,
navCtrl: NavController,
): Promise<void> {
const src = iframe.src;
const currentSite = CoreSites.instance.getCurrentSite();
@ -689,8 +687,7 @@ export class CoreFormatTextDirective implements OnChanges {
const finalUrl = await currentSite.getAutoLoginUrl(src, false);
iframe.src = finalUrl;
CoreIframeUtils.instance.treatFrame(iframe, false, navCtrl);
CoreIframeUtils.instance.treatFrame(iframe, false);
return;
}
@ -751,7 +748,7 @@ export class CoreFormatTextDirective implements OnChanges {
}
}
CoreIframeUtils.instance.treatFrame(iframe, false, navCtrl);
CoreIframeUtils.instance.treatFrame(iframe, false);
}
/**

View File

@ -13,7 +13,7 @@
// limitations under the License.
import { DomSanitizer } from '@angular/platform-browser';
import { IonContent, NavController } from '@ionic/angular';
import { IonContent } from '@ionic/angular';
import { NgZone } from '@angular/core';
import Faker from 'faker';
@ -44,7 +44,6 @@ describe('CoreFormatTextDirective', () => {
config = {
providers: [
{ provide: NavController, useValue: null },
{ provide: IonContent, useValue: null },
],
};

View File

@ -13,8 +13,6 @@
// limitations under the License.
import { Directive, Input, OnInit, ElementRef } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { NavController } from '@ionic/angular';
import { CoreNavigator } from '@services/navigator';
import { CoreObject } from '@singletons/object';
@ -34,8 +32,6 @@ export class CoreUserLinkDirective implements OnInit {
constructor(
element: ElementRef,
protected navCtrl: NavController,
protected route: ActivatedRoute,
) {
this.element = element.nativeElement;
}

View File

@ -7,7 +7,8 @@
<ion-list>
<!-- Course expand="block"s. -->
<ng-container *ngFor="let block of blocks">
<core-block *ngIf="block.visible" [block]="block" contextLevel="course" [instanceId]="courseId" [extraData]="{'downloadEnabled': downloadEnabled}"></core-block>
<core-block *ngIf="block.visible" [block]="block" contextLevel="course" [instanceId]="courseId"
[extraData]="{'downloadEnabled': downloadEnabled}"></core-block>
</ng-container>
</ion-list>
</core-loading>

View File

@ -21,6 +21,7 @@
max-width: var(--side-blocks-max-width);
min-width: var(--side-blocks-min-width);
box-shadow: -4px 0px 16px rgba(0, 0, 0, 0.18);
z-index: 2;
// @todo @include core-split-area-end();
}

View File

@ -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<void> {
await CoreUtils.instance.ignoreErrors(this.invalidateBlocks());
await this.loadContent();
}
}

View File

@ -16,6 +16,7 @@ import { CoreContentLinksHandlerBase } from './base-handler';
import { Translate } from '@singletons';
import { Params } from '@angular/router';
import { CoreContentLinksAction } from '../services/contentlinks-delegate';
import { CoreNavigator } from '@services/navigator';
/**
* Handler to handle URLs pointing to a list of a certain type of modules.
@ -55,16 +56,15 @@ export class CoreContentLinksModuleListHandler extends CoreContentLinksHandlerBa
getActions(siteIds: string[], url: string, params: Params): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
return [{
// eslint-disable-next-line @typescript-eslint/no-unused-vars
action: (siteId): void => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const stateParams = {
CoreNavigator.instance.navigateToSitePath('course/list-mod-type', {
params: {
courseId: params.id,
modName: this.modName,
title: this.title || Translate.instance.instant('addon.mod_' + this.modName + '.modulenameplural'),
};
// @todo CoreNavigator.instance.goInSite('CoreCourseListModTypePage', stateParams, siteId);
},
siteId,
});
},
}];
}

View File

@ -13,13 +13,11 @@
// limitations under the License.
import { Component, OnInit } from '@angular/core';
import { NavController } from '@ionic/angular';
import { CoreSiteBasicInfo, CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { Translate } from '@singletons';
import { CoreContentLinksAction } from '../../services/contentlinks-delegate';
import { CoreContentLinksHelper } from '../../services/contentlinks-helper';
import { ActivatedRoute } from '@angular/router';
import { CoreError } from '@classes/errors/error';
import { CoreNavigator } from '@services/navigator';
@ -34,27 +32,22 @@ import { CoreNavigator } from '@services/navigator';
})
export class CoreContentLinksChooseSitePage implements OnInit {
url: string;
url!: string;
sites: CoreSiteBasicInfo[] = [];
loaded = false;
protected action?: CoreContentLinksAction;
protected isRootURL = false;
constructor(
route: ActivatedRoute,
protected navCtrl: NavController,
) {
this.url = route.snapshot.queryParamMap.get('url')!;
}
/**
* Component being initialized.
*/
async ngOnInit(): Promise<void> {
if (!this.url) {
const url = CoreNavigator.instance.getRouteParam<string>('url');
if (!url) {
return this.leaveView();
}
this.url = url;
let siteIds: string[] | undefined = [];
try {
@ -115,7 +108,7 @@ export class CoreContentLinksChooseSitePage implements OnInit {
try {
await CoreSites.instance.logout();
} finally {
await this.navCtrl.navigateRoot('/login/sites');
await CoreNavigator.instance.navigate('/login/sites', { reset: true });
}
}

View File

@ -20,6 +20,7 @@ import { CoreContentLinksDelegate, CoreContentLinksAction } from './contentlinks
import { CoreSite } from '@classes/site';
import { makeSingleton, Translate } from '@singletons';
import { CoreNavigator } from '@services/navigator';
import { Params } from '@angular/router';
/**
* Service that provides some features regarding content links.
@ -27,10 +28,6 @@ import { CoreNavigator } from '@services/navigator';
@Injectable({ providedIn: 'root' })
export class CoreContentLinksHelperProvider {
constructor(
protected navCtrl: NavController,
) { }
/**
* Check whether a link can be handled by the app.
*
@ -93,8 +90,7 @@ export class CoreContentLinksHelperProvider {
* @return Promise resolved when done.
* @deprecated since 3.9.5. Use CoreNavigator.navigateToSitePath instead.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async goInSite(navCtrl: NavController, pageName: string, pageParams: any, siteId?: string): Promise<void> {
async goInSite(navCtrl: NavController, pageName: string, pageParams: Params, siteId?: string): Promise<void> {
await CoreNavigator.instance.navigateToSitePath(pageName, { params: pageParams, siteId });
}
@ -105,7 +101,7 @@ export class CoreContentLinksHelperProvider {
* @todo set correct root.
*/
async goToChooseSite(url: string): Promise<void> {
await this.navCtrl.navigateRoot('CoreContentLinksChooseSitePage @todo', { queryParams: { url } });
await CoreNavigator.instance.navigate('CoreContentLinksChooseSitePage @todo', { params: { url }, reset: true });
}
/**

View File

@ -0,0 +1,193 @@
// (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 { CoreConstants } from '@/core/constants';
import { CoreNetworkError } from '@classes/errors/network-error';
import { CoreFilterHelper } from '@features/filter/services/filter-helper';
import { CoreApp } from '@services/app';
import { CoreFilepool } from '@services/filepool';
import { CoreSites } from '@services/sites';
import { CoreCourse, CoreCourseWSModule } from '../services/course';
import { CoreCourseModulePrefetchHandlerBase } from './module-prefetch-handler';
/**
* Base prefetch handler to be registered in CoreCourseModulePrefetchDelegate. It is useful to minimize the amount of
* functions that handlers need to implement. It also provides some helper features like preventing a module to be
* downloaded twice at the same time.
*
* If your handler inherits from this service, you just need to override the functions that you want to change.
*
* This class should be used for ACTIVITIES. You must override the prefetch function, and it's recommended to call
* prefetchPackage in there since it handles the package status.
*/
export class CoreCourseActivityPrefetchHandlerBase extends CoreCourseModulePrefetchHandlerBase {
/**
* Download the module.
*
* @param module The module object returned by WS.
* @param courseId Course ID.
* @param dirPath Path of the directory where to store all the content files.
* @return Promise resolved when all content is downloaded.
*/
download(module: CoreCourseWSModule, courseId: number, dirPath?: string): Promise<void> {
// Same implementation for download and prefetch.
return this.prefetch(module, courseId, false, dirPath);
}
/**
* Prefetch a module.
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @param single True if we're downloading a single module, false if we're downloading a whole section.
* @param dirPath Path of the directory where to store all the content files.
* @return Promise resolved when done.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async prefetch(module: CoreCourseWSModule, courseId?: number, single?: boolean, dirPath?: string): Promise<void> {
// To be overridden. It should call prefetchPackage
return;
}
/**
* Prefetch the module, setting package status at start and finish.
*
* Example usage from a child instance:
* return this.prefetchPackage(module, courseId, single, this.prefetchModule.bind(this, otherParam), siteId);
*
* Then the function "prefetchModule" will receive params:
* prefetchModule(module, courseId, single, siteId, someParam, anotherParam)
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @param downloadFn Function to perform the prefetch. Please check the documentation of prefetchFunction.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when the module has been downloaded. Data returned is not reliable.
*/
async prefetchPackage(
module: CoreCourseWSModule,
courseId: number,
downloadFunction: () => Promise<string>,
siteId?: string,
): Promise<void> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
if (!CoreApp.instance.isOnline()) {
// Cannot prefetch in offline.
throw new CoreNetworkError();
}
if (this.isDownloading(module.id, siteId)) {
// There's already a download ongoing for this module, return the promise.
return this.getOngoingDownload(module.id, siteId);
}
const prefetchPromise = this.changeStatusAndPrefetch(module, courseId, downloadFunction, siteId);
return this.addOngoingDownload(module.id, prefetchPromise, siteId);
}
protected async changeStatusAndPrefetch(
module: CoreCourseWSModule,
courseId: number,
downloadFunction: () => Promise<string>,
siteId?: string,
): Promise<void> {
try {
await this.setDownloading(module.id, siteId);
// Package marked as downloading, get module info to be able to handle links. Get module filters too.
await Promise.all([
CoreCourse.instance.getModuleBasicInfo(module.id, siteId),
CoreCourse.instance.getModule(module.id, courseId, undefined, false, true, siteId),
CoreFilterHelper.instance.getFilters('module', module.id, { courseId }),
]);
// Call the download function.
let extra = await downloadFunction();
// Only accept string types.
if (typeof extra != 'string') {
extra = '';
}
// Prefetch finished, mark as downloaded.
await this.setDownloaded(module.id, siteId, extra);
} catch (error) {
// Error prefetching, go back to previous status and reject the promise.
return this.setPreviousStatus(module.id, siteId);
throw error;
}
}
/**
* Mark the module as downloaded.
*
* @param id Unique identifier per component.
* @param siteId Site ID. If not defined, current site.
* @param extra Extra data to store.
* @return Promise resolved when done.
*/
setDownloaded(id: number, siteId?: string, extra?: string): Promise<void> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
return CoreFilepool.instance.storePackageStatus(siteId, CoreConstants.DOWNLOADED, this.component, id, extra);
}
/**
* Mark the module as downloading.
*
* @param id Unique identifier per component.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
setDownloading(id: number, siteId?: string): Promise<void> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
return CoreFilepool.instance.storePackageStatus(siteId, CoreConstants.DOWNLOADING, this.component, id);
}
/**
* Set previous status and return a rejected promise.
*
* @param id Unique identifier per component.
* @param siteId Site ID. If not defined, current site.
* @return Rejected promise.
*/
async setPreviousStatus(id: number, siteId?: string): Promise<void> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
await CoreFilepool.instance.setPackagePreviousStatus(siteId, this.component, id);
}
/**
* Set previous status and return a rejected promise.
*
* @param id Unique identifier per component.
* @param error Error to throw.
* @param siteId Site ID. If not defined, current site.
* @return Rejected promise.
* @deprecated since 3.9.5. Use setPreviousStatus instead.
*/
async setPreviousStatusAndReject(id: number, error?: Error, siteId?: string): Promise<never> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
await CoreFilepool.instance.setPackagePreviousStatus(siteId, this.component, id);
throw error;
}
}

View File

@ -0,0 +1,57 @@
// (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 { CoreSyncBaseProvider } from '@classes/base-sync';
import { CoreCourseWSModule } from '../services/course';
import { CoreCourseModulePrefetchDelegate } from '../services/module-prefetch-delegate';
import { CoreCourseModulePrefetchHandlerBase } from './module-prefetch-handler';
/**
* Base class to create activity sync providers. It provides some common functions.
*/
export class CoreCourseActivitySyncBaseProvider extends CoreSyncBaseProvider {
/**
* Conveniece function to prefetch data after an update.
*
* @param module Module.
* @param courseId Course ID.
* @param preventDownloadRegex If regex matches, don't download the data. Defaults to check files.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async prefetchAfterUpdate(
prefetchHandler: CoreCourseModulePrefetchHandlerBase,
module: CoreCourseWSModule,
courseId: number,
preventDownloadRegex?: RegExp,
siteId?: string,
): Promise<void> {
// Get the module updates to check if the data was updated or not.
const result = await CoreCourseModulePrefetchDelegate.instance.getModuleUpdates(module, courseId, true, siteId);
if (!result?.updates.length) {
return;
}
// Only prefetch if files haven't changed, to prevent downloading too much data automatically.
const regex = preventDownloadRegex || /^.*files$/;
const shouldDownload = !result.updates.find((entry) => entry.name.match(regex));
if (shouldDownload) {
return prefetchHandler.download(module, courseId);
}
}
}

View File

@ -0,0 +1,269 @@
// (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, Inject, Input, OnDestroy, OnInit, Optional } from '@angular/core';
import { IonContent } from '@ionic/angular';
import { CoreCourseModuleMainResourceComponent } from './main-resource-component';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { Network, NgZone } from '@singletons';
import { Subscription } from 'rxjs';
import { CoreApp } from '@services/app';
import { CoreCourse } from '../services/course';
import { CoreUtils } from '@services/utils/utils';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreWSExternalWarning } from '@services/ws';
import { CoreCourseContentsPage } from '../pages/contents/contents';
/**
* Template class to easily create CoreCourseModuleMainComponent of activities.
*/
@Component({
template: '',
})
export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy {
@Input() group?: number; // Group ID the component belongs to.
moduleName?: string; // Raw module name to be translated. It will be translated on init.
// Data for context menu.
syncIcon?: string; // Sync icon.
hasOffline?: boolean; // If it has offline data to be synced.
isOnline?: boolean; // If the app is online or not.
protected syncObserver?: CoreEventObserver; // It will observe the sync auto event.
protected onlineSubscription: Subscription; // It will observe the status of the network connection.
protected syncEventName?: string; // Auto sync event name.
constructor(
@Optional() @Inject('') loggerName: string = 'CoreCourseModuleMainResourceComponent',
protected content?: IonContent,
courseContentsPage?: CoreCourseContentsPage,
) {
super(loggerName, courseContentsPage);
// Refresh online status when changes.
this.onlineSubscription = Network.instance.onChange().subscribe(() => {
// Execute the callback in the Angular zone, so change detection doesn't stop working.
NgZone.instance.run(() => {
this.isOnline = CoreApp.instance.isOnline();
});
});
}
/**
* Component being initialized.
*/
async ngOnInit(): Promise<void> {
super.ngOnInit();
this.hasOffline = false;
this.syncIcon = 'spinner';
this.moduleName = CoreCourse.instance.translateModuleName(this.moduleName || '');
if (this.syncEventName) {
// Refresh data if this discussion is synchronized automatically.
this.syncObserver = CoreEvents.on(this.syncEventName, (data) => {
this.autoSyncEventReceived(data);
}, this.siteId);
}
}
/**
* Compares sync event data with current data to check if refresh content is needed.
*
* @param syncEventData Data received on sync observer.
* @return True if refresh is needed, false otherwise.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected isRefreshSyncNeeded(syncEventData: unknown): boolean {
return false;
}
/**
* An autosync event has been received, check if refresh is needed and update the view.
*
* @param syncEventData Data receiven on sync observer.
*/
protected autoSyncEventReceived(syncEventData: unknown): void {
if (this.isRefreshSyncNeeded(syncEventData)) {
// Refresh the data.
this.showLoadingAndRefresh(false);
}
}
/**
* Perform the refresh content function.
*
* @param sync If the refresh needs syncing.
* @param showErrors Wether to show errors to the user or hide them.
* @return Resolved when done.
*/
protected async refreshContent(sync: boolean = false, showErrors: boolean = false): Promise<void> {
if (!this.module) {
// This can happen if course format changes from single activity to weekly/topics.
return;
}
this.refreshIcon = 'spinner';
this.syncIcon = 'spinner';
try {
await CoreUtils.instance.ignoreErrors(this.invalidateContent());
await this.loadContent(true, sync, showErrors);
} finally {
this.refreshIcon = 'fas-redo';
this.syncIcon = 'fas-sync';
}
}
/**
* Show loading and perform the load content function.
*
* @param sync If the fetch needs syncing.
* @param showErrors Wether to show errors to the user or hide them.
* @return Resolved when done.
*/
protected async showLoadingAndFetch(sync: boolean = false, showErrors: boolean = false): Promise<void> {
this.refreshIcon = 'spinner';
this.syncIcon = 'spinner';
this.loaded = false;
this.content?.scrollToTop();
try {
await this.loadContent(false, sync, showErrors);
} finally {
this.refreshIcon = 'fas-redo';
this.syncIcon = 'fas-sync';
}
}
/**
* Show loading and perform the refresh content function.
*
* @param sync If the refresh needs syncing.
* @param showErrors Wether to show errors to the user or hide them.
* @return Resolved when done.
*/
protected showLoadingAndRefresh(sync: boolean = false, showErrors: boolean = false): Promise<void> {
this.refreshIcon = 'spinner';
this.syncIcon = 'spinner';
this.loaded = false;
this.content?.scrollToTop();
return this.refreshContent(sync, showErrors);
}
/**
* Download the component contents.
*
* @param refresh Whether we're refreshing data.
* @param sync If the refresh needs syncing.
* @param showErrors Wether to show errors to the user or hide them.
* @return Promise resolved when done.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<void> {
return;
}
/**
* Loads the component contents and shows the corresponding error.
*
* @param refresh Whether we're refreshing data.
* @param sync If the refresh needs syncing.
* @param showErrors Wether to show errors to the user or hide them.
* @return Promise resolved when done.
*/
protected async loadContent(refresh?: boolean, sync: boolean = false, showErrors: boolean = false): Promise<void> {
this.isOnline = CoreApp.instance.isOnline();
if (!this.module) {
// This can happen if course format changes from single activity to weekly/topics.
return;
}
try {
await this.fetchContent(refresh, sync, showErrors);
} catch (error) {
if (!refresh) {
// Some call failed, retry without using cache since it might be a new activity.
return await this.refreshContent(sync);
}
CoreDomUtils.instance.showErrorModalDefault(error, this.fetchContentDefaultError, true);
} finally {
this.loaded = true;
this.refreshIcon = 'fas-redo';
this.syncIcon = 'fas-sync';
}
}
/**
* Performs the sync of the activity.
*
* @return Promise resolved when done.
*/
protected async sync(): Promise<unknown> {
return {};
}
/**
* Checks if sync has succeed from result sync data.
*
* @param result Data returned on the sync function.
* @return If suceed or not.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected hasSyncSucceed(result: unknown): boolean {
return true;
}
/**
* Tries to synchronize the activity.
*
* @param showErrors If show errors to the user of hide them.
* @return Promise resolved with true if sync succeed, or false if failed.
*/
protected async syncActivity(showErrors: boolean = false): Promise<boolean> {
try {
const result = <{warnings?: CoreWSExternalWarning[]}> await this.sync();
if (result?.warnings?.length) {
CoreDomUtils.instance.showErrorModal(result.warnings[0]);
}
return this.hasSyncSucceed(result);
} catch (error) {
if (showErrors) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.errorsync', true);
}
return false;
}
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
super.ngOnDestroy();
this.onlineSubscription?.unsubscribe();
this.syncObserver?.off();
}
}

View File

@ -0,0 +1,412 @@
// (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 { CoreConstants } from '@/core/constants';
import { OnInit, OnDestroy, Input, Output, EventEmitter, Component, Optional, Inject } from '@angular/core';
import { IonRefresher } from '@ionic/angular';
import { CoreApp } from '@services/app';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreTextErrorObject, CoreTextUtils } from '@services/utils/text';
import { CoreUtils } from '@services/utils/utils';
import { Translate } from '@singletons';
import { CoreEventObserver, CoreEventPackageStatusChanged, CoreEvents } from '@singletons/events';
import { CoreLogger } from '@singletons/logger';
import { CoreCourseContentsPage } from '../pages/contents/contents';
import { CoreCourse } from '../services/course';
import { CoreCourseHelper, CoreCourseModule } from '../services/course-helper';
import { CoreCourseModuleDelegate, CoreCourseModuleMainComponent } from '../services/module-delegate';
import { CoreCourseModulePrefetchDelegate } from '../services/module-prefetch-delegate';
/**
* Result of a resource download.
*/
export type CoreCourseResourceDownloadResult = {
failed?: boolean; // Whether the download has failed.
error?: string | CoreTextErrorObject; // The error in case it failed.
};
/**
* Template class to easily create CoreCourseModuleMainComponent of resources (or activities without syncing).
*/
@Component({
template: '',
})
export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy, CoreCourseModuleMainComponent {
@Input() module?: CoreCourseModule; // The module of the component.
@Input() courseId?: number; // Course ID the component belongs to.
@Output() dataRetrieved = new EventEmitter<unknown>(); // Called to notify changes the index page from the main component.
loaded = false; // If the component has been loaded.
component?: string; // Component name.
componentId?: number; // Component ID.
blog?: boolean; // If blog is avalaible.
// Data for context menu.
externalUrl?: string; // External URL to open in browser.
description?: string; // Module description.
refreshIcon = 'spinner'; // Refresh icon, normally spinner or refresh.
prefetchStatusIcon?: string; // Used when calling fillContextMenu.
prefetchStatus?: string; // Used when calling fillContextMenu.
prefetchText?: string; // Used when calling fillContextMenu.
size?: string; // Used when calling fillContextMenu.
isDestroyed?: boolean; // Whether the component is destroyed, used when calling fillContextMenu.
contextMenuStatusObserver?: CoreEventObserver; // Observer of package status, used when calling fillContextMenu.
contextFileStatusObserver?: CoreEventObserver; // Observer of file status, used when calling fillContextMenu.
protected fetchContentDefaultError = 'core.course.errorgetmodule'; // Default error to show when loading contents.
protected isCurrentView?: boolean; // Whether the component is in the current view.
protected siteId?: string; // Current Site ID.
protected statusObserver?: CoreEventObserver; // Observer of package status. Only if setStatusListener is called.
protected currentStatus?: string; // The current status of the module. Only if setStatusListener is called.
protected logger: CoreLogger;
constructor(
@Optional() @Inject('') loggerName: string = 'CoreCourseModuleMainResourceComponent',
protected courseContentsPage?: CoreCourseContentsPage,
) {
this.logger = CoreLogger.getInstance(loggerName);
}
/**
* Component being initialized.
*/
async ngOnInit(): Promise<void> {
this.siteId = CoreSites.instance.getCurrentSiteId();
this.description = this.module?.description;
this.componentId = this.module?.id;
this.externalUrl = this.module?.url;
this.courseId = this.courseId || this.module?.course;
// @todo this.blog = await this.blogProvider.isPluginEnabled();
}
/**
* Refresh the data.
*
* @param refresher Refresher.
* @param done Function to call when done.
* @param showErrors If show errors to the user of hide them.
* @return Promise resolved when done.
*/
async doRefresh(refresher?: CustomEvent<IonRefresher>, done?: () => void, showErrors: boolean = false): Promise<void> {
if (!this.loaded || !this.module) {
return;
}
// If it's a single activity course and the refresher is displayed within the component,
// call doRefresh on the section page to refresh the course data.
if (this.courseContentsPage && !CoreCourseModuleDelegate.instance.displayRefresherInSingleActivity(this.module.modname)) {
await CoreUtils.instance.ignoreErrors(this.courseContentsPage.doRefresh());
}
await CoreUtils.instance.ignoreErrors(this.refreshContent(true, showErrors));
refresher?.detail.complete();
done && done();
}
/**
* Perform the refresh content function.
*
* @param sync If the refresh needs syncing.
* @param showErrors Wether to show errors to the user or hide them.
* @return Resolved when done.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected async refreshContent(sync: boolean = false, showErrors: boolean = false): Promise<void> {
if (!this.module) {
// This can happen if course format changes from single activity to weekly/topics.
return;
}
this.refreshIcon = 'spinner';
try {
await CoreUtils.instance.ignoreErrors(this.invalidateContent());
await this.loadContent(true);
} finally {
this.refreshIcon = 'fas-redo';
}
}
/**
* Perform the invalidate content function.
*
* @return Resolved when done.
*/
protected async invalidateContent(): Promise<void> {
return;
}
/**
* Download the component contents.
*
* @param refresh Whether we're refreshing data.
* @return Promise resolved when done.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected async fetchContent(refresh?: boolean): Promise<void> {
return;
}
/**
* Loads the component contents and shows the corresponding error.
*
* @param refresh Whether we're refreshing data.
* @return Promise resolved when done.
*/
protected async loadContent(refresh?: boolean): Promise<void> {
if (!this.module) {
// This can happen if course format changes from single activity to weekly/topics.
return;
}
try {
await this.fetchContent(refresh);
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, this.fetchContentDefaultError, true);
} finally {
this.loaded = true;
this.refreshIcon = 'fas-redo';
}
}
/**
* Fill the context menu options
*/
protected fillContextMenu(refresh: boolean = false): void {
if (!this.module) {
return;
}
// All data obtained, now fill the context menu.
CoreCourseHelper.instance.fillContextMenu(this, this.module, this.courseId!, refresh, this.component);
}
/**
* Check if the module is prefetched or being prefetched. To make it faster, just use the data calculated by fillContextMenu.
* This means that you need to call fillContextMenu to make this work.
*/
protected isPrefetched(): boolean {
return this.prefetchStatus != CoreConstants.NOT_DOWNLOADABLE && this.prefetchStatus != CoreConstants.NOT_DOWNLOADED;
}
/**
* Expand the description.
*/
expandDescription(): void {
CoreTextUtils.instance.viewText(Translate.instance.instant('core.description'), this.description!, {
component: this.component,
componentId: this.module?.id,
filter: true,
contextLevel: 'module',
instanceId: this.module?.id,
courseId: this.courseId,
});
}
/**
* Go to blog posts.
*/
async gotoBlog(): Promise<void> {
// @todo return this.linkHelper.goInSite(this.navCtrl, 'AddonBlogEntriesPage', { cmId: this.module.id });
}
/**
* Prefetch the module.
*
* @param done Function to call when done.
*/
prefetch(done?: () => void): void {
if (!this.module) {
return;
}
CoreCourseHelper.instance.contextMenuPrefetch(this, this.module, this.courseId!, done);
}
/**
* Confirm and remove downloaded files.
*
* @param done Function to call when done.
*/
removeFiles(done?: () => void): void {
if (!this.module) {
return;
}
if (this.prefetchStatus == CoreConstants.DOWNLOADING) {
CoreDomUtils.instance.showAlertTranslated(undefined, 'core.course.cannotdeletewhiledownloading');
return;
}
CoreCourseHelper.instance.confirmAndRemoveFiles(this.module, this.courseId!, done);
}
/**
* Get message about an error occurred while downloading files.
*
* @param error The specific error.
* @param multiLine Whether to put each message in a different paragraph or in a single line.
*/
protected getErrorDownloadingSomeFilesMessage(error: string | CoreTextErrorObject, multiLine?: boolean): string {
if (multiLine) {
return CoreTextUtils.instance.buildSeveralParagraphsMessage([
Translate.instance.instant('core.errordownloadingsomefiles'),
error,
]);
} else {
error = CoreTextUtils.instance.getErrorMessageFromError(error) || error;
return Translate.instance.instant('core.errordownloadingsomefiles') + (error ? ' ' + error : '');
}
}
/**
* Show an error occurred while downloading files.
*
* @param error The specific error.
*/
protected showErrorDownloadingSomeFiles(error: string | CoreTextErrorObject): void {
CoreDomUtils.instance.showErrorModal(this.getErrorDownloadingSomeFilesMessage(error, true));
}
/**
* Displays some data based on the current status.
*
* @param status The current status.
* @param previousStatus The previous status. If not defined, there is no previous status.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected showStatus(status: string, previousStatus?: string): void {
// To be overridden.
}
/**
* Watch for changes on the status.
*
* @return Promise resolved when done.
*/
protected async setStatusListener(): Promise<void> {
if (typeof this.statusObserver != 'undefined' || !this.module) {
return;
}
// Listen for changes on this module status.
this.statusObserver = CoreEvents.on<CoreEventPackageStatusChanged>(CoreEvents.PACKAGE_STATUS_CHANGED, (data) => {
if (!this.module || data.componentId != this.module.id || data.component != this.component) {
return;
}
// The status has changed, update it.
const previousStatus = this.currentStatus;
this.currentStatus = data.status;
this.showStatus(this.currentStatus, previousStatus);
}, this.siteId);
// Also, get the current status.
const status = await CoreCourseModulePrefetchDelegate.instance.getModuleStatus(this.module, this.courseId!);
this.currentStatus = status;
this.showStatus(status);
}
/**
* Download a resource if needed.
* If the download call fails the promise won't be rejected, but the error will be included in the returned object.
* If module.contents cannot be loaded then the Promise will be rejected.
*
* @param refresh Whether we're refreshing data.
* @return Promise resolved when done.
*/
protected async downloadResourceIfNeeded(
refresh?: boolean,
contentsAlreadyLoaded?: boolean,
): Promise<CoreCourseResourceDownloadResult> {
const result: CoreCourseResourceDownloadResult = {
failed: false,
};
if (!this.module) {
return result;
}
// Get module status to determine if it needs to be downloaded.
await this.setStatusListener();
if (this.currentStatus != CoreConstants.DOWNLOADED) {
// Download content. This function also loads module contents if needed.
try {
await CoreCourseModulePrefetchDelegate.instance.downloadModule(this.module, this.courseId!);
// If we reach here it means the download process already loaded the contents, no need to do it again.
contentsAlreadyLoaded = true;
} catch (error) {
// Mark download as failed but go on since the main files could have been downloaded.
result.failed = true;
result.error = error;
}
}
if (!this.module.contents.length || (refresh && !contentsAlreadyLoaded)) {
// Try to load the contents.
const ignoreCache = refresh && CoreApp.instance.isOnline();
try {
await CoreCourse.instance.loadModuleContents(this.module, this.courseId, undefined, false, ignoreCache);
} catch (error) {
// Error loading contents. If we ignored cache, try to get the cached value.
if (ignoreCache && !this.module.contents) {
await CoreCourse.instance.loadModuleContents(this.module, this.courseId);
} else if (!this.module.contents) {
// Not able to load contents, throw the error.
throw error;
}
}
}
return result;
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
this.isDestroyed = true;
this.contextMenuStatusObserver?.off();
this.contextFileStatusObserver?.off();
this.statusObserver?.off();
}
/**
* User entered the page that contains the component. This function should be called by the page that contains this component.
*/
ionViewDidEnter(): void {
this.isCurrentView = true;
}
/**
* User left the page that contains the component. This function should be called by the page that contains this component.
*/
ionViewDidLeave(): void {
this.isCurrentView = false;
}
}

View File

@ -0,0 +1,344 @@
// (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 { CoreFilepool } from '@services/filepool';
import { CoreFileSizeSum, CorePluginFileDelegate } from '@services/plugin-file-delegate';
import { CoreSites } from '@services/sites';
import { CoreWSExternalFile } from '@services/ws';
import { CoreCourse, CoreCourseModuleContentFile, CoreCourseWSModule } from '../services/course';
import { CoreCourseModulePrefetchHandler } from '../services/module-prefetch-delegate';
/**
* Base prefetch handler to be registered in CoreCourseModulePrefetchDelegate. Prefetch handlers should inherit either
* from CoreCourseModuleActivityPrefetchHandlerBase or CoreCourseModuleResourcePrefetchHandlerBase, depending on whether
* they are an activity or a resource. It's not recommended to inherit from this class directly.
*/
export class CoreCourseModulePrefetchHandlerBase implements CoreCourseModulePrefetchHandler {
/**
* Name of the handler.
*/
name = 'CoreCourseModulePrefetchHandler';
/**
* Name of the module. It should match the "modname" of the module returned in core_course_get_contents.
*/
modName = 'default';
/**
* The handler's component.
*/
component = 'core_module';
/**
* The RegExp to check updates. If a module has an update whose name matches this RegExp, the module will be marked
* as outdated. This RegExp is ignored if hasUpdates function is defined.
*/
updatesNames = /^.*files$/;
/**
* If true, this module will be ignored when determining the status of a list of modules. The module will
* still be downloaded when downloading the section/course, it only affects whether the button should be displayed.
*/
skipListStatus = false;
/**
* List of download promises to prevent downloading the module twice at the same time.
*/
protected downloadPromises: { [s: string]: { [s: string]: Promise<void> } } = {};
/**
* Add an ongoing download to the downloadPromises list. When the promise finishes it will be removed.
*
* @param id Unique identifier per component.
* @param promise Promise to add.
* @param siteId Site ID. If not defined, current site.
* @return Promise of the current download.
*/
async addOngoingDownload(id: number, promise: Promise<void>, siteId?: string): Promise<void> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
const uniqueId = this.getUniqueId(id);
if (!this.downloadPromises[siteId]) {
this.downloadPromises[siteId] = {};
}
this.downloadPromises[siteId][uniqueId] = promise;
try {
return await this.downloadPromises[siteId][uniqueId];
} finally {
delete this.downloadPromises[siteId][uniqueId];
}
}
/**
* Download the module.
*
* @param module The module object returned by WS.
* @param courseId Course ID.
* @param dirPath Path of the directory where to store all the content files.
* @return Promise resolved when all content is downloaded.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async download(module: CoreCourseWSModule, courseId: number, dirPath?: string): Promise<void> {
// To be overridden.
return;
}
/**
* Returns a list of content files that can be downloaded.
*
* @param module The module object returned by WS.
* @return List of files.
*/
getContentDownloadableFiles(module: CoreCourseWSModule): CoreCourseModuleContentFile[] {
if (!module.contents?.length) {
return [];
}
return module.contents.filter((content) => this.isFileDownloadable(content));
}
/**
* Get the download size of a module.
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @param single True if we're downloading a single module, false if we're downloading a whole section.
* @return Promise resolved with the size.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getDownloadSize(module: CoreCourseWSModule, courseId: number, single?: boolean): Promise<CoreFileSizeSum> {
try {
const files = await this.getFiles(module, courseId);
return await CorePluginFileDelegate.instance.getFilesDownloadSize(files);
} catch {
return { size: -1, total: false };
}
}
/**
* Get the downloaded size of a module. If not defined, we'll use getFiles to calculate it (it can be slow).
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @return Size, or promise resolved with the size.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getDownloadedSize(module: CoreCourseWSModule, courseId: number): Promise<number> {
const siteId = CoreSites.instance.getCurrentSiteId();
return CoreFilepool.instance.getFilesSizeByComponent(siteId, this.component, module.id);
}
/**
* Get list of files. If not defined, we'll assume they're in module.contents.
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @param single True if we're downloading a single module, false if we're downloading a whole section.
* @return Promise resolved with the list of files.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getFiles(module: CoreCourseWSModule, courseId: number, single?: boolean): Promise<CoreWSExternalFile[]> {
// To be overridden.
return [];
}
/**
* Returns module intro files.
*
* @param module The module object returned by WS.
* @param courseId Course ID.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @return Promise resolved with list of intro files.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getIntroFiles(module: CoreCourseWSModule, courseId: number, ignoreCache?: boolean): Promise<CoreWSExternalFile[]> {
return this.getIntroFilesFromInstance(module);
}
/**
* Returns module intro files from instance.
*
* @param module The module object returned by WS.
* @param instance The instance to get the intro files (book, assign, ...). If not defined, module will be used.
* @return List of intro files.
*/
getIntroFilesFromInstance(module: CoreCourseWSModule, instance?: ModuleInstance): CoreWSExternalFile[] {
if (instance) {
if (typeof instance.introfiles != 'undefined') {
return instance.introfiles;
} else if (instance.intro) {
return CoreFilepool.instance.extractDownloadableFilesFromHtmlAsFakeFileObjects(instance.intro);
}
}
if (module.description) {
return CoreFilepool.instance.extractDownloadableFilesFromHtmlAsFakeFileObjects(module.description);
}
return [];
}
/**
* If there's an ongoing download for a certain identifier return it.
*
* @param id Unique identifier per component.
* @param siteId Site ID. If not defined, current site.
* @return Promise of the current download.
*/
async getOngoingDownload(id: number, siteId?: string): Promise<void> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
if (this.isDownloading(id, siteId)) {
// There's already a download ongoing, return the promise.
return this.downloadPromises[siteId][this.getUniqueId(id)];
}
}
/**
* Create unique identifier using component and id.
*
* @param id Unique ID inside component.
* @return Unique ID.
*/
getUniqueId(id: number): string {
return this.component + '#' + id;
}
/**
* Invalidate the prefetched content.
*
* @param moduleId The module ID.
* @param courseId The course ID the module belongs to.
* @return Promise resolved when the data is invalidated.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async invalidateContent(moduleId: number, courseId: number): Promise<void> {
// To be overridden.
return;
}
/**
* Invalidate WS calls needed to determine module status (usually, to check if module is downloadable).
* It doesn't need to invalidate check updates. It should NOT invalidate files nor all the prefetched data.
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @return Promise resolved when invalidated.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
invalidateModule(module: CoreCourseWSModule, courseId: number): Promise<void> {
return CoreCourse.instance.invalidateModule(module.id);
}
/**
* Check if a module can be downloaded. If the function is not defined, we assume that all modules are downloadable.
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @return Whether the module can be downloaded. The promise should never be rejected.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async isDownloadable(module: CoreCourseWSModule, courseId: number): Promise<boolean> {
// By default, mark all instances as downloadable.
return true;
}
/**
* Check if a there's an ongoing download for the given identifier.
*
* @param id Unique identifier per component.
* @param siteId Site ID. If not defined, current site.
* @return True if downloading, false otherwise.
*/
isDownloading(id: number, siteId?: string): boolean {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
return !!(this.downloadPromises[siteId] && this.downloadPromises[siteId][this.getUniqueId(id)]);
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return A boolean, or a promise resolved with a boolean, indicating if the handler is enabled.
*/
async isEnabled(): Promise<boolean> {
return true;
}
/**
* Check if a file is downloadable.
*
* @param file File to check.
* @return Whether the file is downloadable.
*/
isFileDownloadable(file: CoreCourseModuleContentFile): boolean {
return file.type === 'file';
}
/**
* Load module contents into module.contents if they aren't loaded already.
*
* @param module Module to load the contents.
* @param courseId The course ID. Recommended to speed up the process and minimize data usage.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @return Promise resolved when loaded.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async loadContents(module: CoreCourseWSModule, courseId: number, ignoreCache?: boolean): Promise<void> {
// To be overridden.
return;
}
/**
* Prefetch a module.
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @param single True if we're downloading a single module, false if we're downloading a whole section.
* @param dirPath Path of the directory where to store all the content files.
* @return Promise resolved when done.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async prefetch(module: CoreCourseWSModule, courseId?: number, single?: boolean, dirPath?: string): Promise<void> {
// To be overridden.
return;
}
/**
* Remove module downloaded files. If not defined, we'll use getFiles to remove them (slow).
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @return Promise resolved when done.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
removeFiles(module: CoreCourseWSModule, courseId: number): Promise<void> {
return CoreFilepool.instance.removeFilesByComponent(CoreSites.instance.getCurrentSiteId(), this.component, module.id);
}
}
/**
* Properties a module instance should have to be able to retrieve its intro files.
*/
type ModuleInstance = {
introfiles?: CoreWSExternalFile[];
intro?: string;
};

View File

@ -0,0 +1,203 @@
// (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 { CoreError } from '@classes/errors/error';
import { CoreNetworkError } from '@classes/errors/network-error';
import { CoreFilterHelper } from '@features/filter/services/filter-helper';
import { CoreApp } from '@services/app';
import { CoreFilepool } from '@services/filepool';
import { CoreSites } from '@services/sites';
import { CoreWSExternalFile } from '@services/ws';
import { CoreCourse, CoreCourseWSModule } from '../services/course';
import { CoreCourseModulePrefetchHandlerBase } from './module-prefetch-handler';
/**
* Base prefetch handler to be registered in CoreCourseModulePrefetchDelegate. It is useful to minimize the amount of
* functions that handlers need to implement. It also provides some helper features like preventing a module to be
* downloaded twice at the same time.
*
* If your handler inherits from this service, you just need to override the functions that you want to change.
*
* This class should be used for RESOURCES whose main purpose is downloading files present in module.contents.
*/
export class CoreCourseResourcePrefetchHandlerBase extends CoreCourseModulePrefetchHandlerBase {
/**
* Download the module.
*
* @param module The module object returned by WS.
* @param courseId Course ID.
* @param dirPath Path of the directory where to store all the content files.
* @return Promise resolved when all content is downloaded.
*/
download(module: CoreCourseWSModule, courseId: number, dirPath?: string): Promise<void> {
return this.downloadOrPrefetch(module, courseId, false, dirPath);
}
/**
* Download or prefetch the content.
*
* @param module The module object returned by WS.
* @param courseId Course ID.
* @param prefetch True to prefetch, false to download right away.
* @param dirPath Path of the directory where to store all the content files. This is to keep the files
* relative paths and make the package work in an iframe. Undefined to download the files
* in the filepool root folder.
* @return Promise resolved when all content is downloaded.
*/
async downloadOrPrefetch(module: CoreCourseWSModule, courseId: number, prefetch?: boolean, dirPath?: string): Promise<void> {
if (!CoreApp.instance.isOnline()) {
// Cannot download in offline.
throw new CoreNetworkError();
}
const siteId = CoreSites.instance.getCurrentSiteId();
if (this.isDownloading(module.id, siteId)) {
// There's already a download ongoing for this module, return the promise.
return this.getOngoingDownload(module.id, siteId);
}
// Get module info to be able to handle links.
const prefetchPromise = this.performDownloadOrPrefetch(siteId, module, courseId, !!prefetch, dirPath);
return this.addOngoingDownload(module.id, prefetchPromise, siteId);
}
/**
* Download or prefetch the content.
*
* @param module The module object returned by WS.
* @param courseId Course ID.
* @param prefetch True to prefetch, false to download right away.
* @param dirPath Path of the directory where to store all the content files.
* @return Promise resolved when all content is downloaded.
*/
protected async performDownloadOrPrefetch(
siteId: string,
module: CoreCourseWSModule,
courseId: number,
prefetch: boolean,
dirPath?: string,
): Promise<void> {
// Get module info to be able to handle links.
await CoreCourse.instance.getModuleBasicInfo(module.id, siteId);
// Load module contents (ignore cache so we always have the latest data).
await this.loadContents(module, courseId, true);
// Get the intro files.
const introFiles = await this.getIntroFiles(module, courseId, true);
const contentFiles = this.getContentDownloadableFiles(module);
const promises: Promise<unknown>[] = [];
if (dirPath) {
// Download intro files in filepool root folder.
promises.push(
CoreFilepool.instance.downloadOrPrefetchFiles(siteId, introFiles, prefetch, false, this.component, module.id),
);
// Download content files inside dirPath.
promises.push(CoreFilepool.instance.downloadOrPrefetchPackage(
siteId,
contentFiles,
prefetch,
this.component,
module.id,
undefined,
dirPath,
));
} else {
// No dirPath, download everything in filepool root folder.
promises.push(CoreFilepool.instance.downloadOrPrefetchPackage(
siteId,
introFiles.concat(contentFiles),
prefetch,
this.component,
module.id,
));
}
promises.push(CoreFilterHelper.instance.getFilters('module', module.id, { courseId }));
await Promise.all(promises);
}
/**
* Get list of files. If not defined, we'll assume they're in module.contents.
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @param single True if we're downloading a single module, false if we're downloading a whole section.
* @return Promise resolved with the list of files.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getFiles(module: CoreCourseWSModule, courseId: number, single?: boolean): Promise<CoreWSExternalFile[]> {
// Load module contents if needed.
await this.loadContents(module, courseId);
const files = await this.getIntroFiles(module, courseId);
return files.concat(this.getContentDownloadableFiles(module));
}
/**
* Invalidate the prefetched content.
*
* @param moduleId The module ID.
* @param courseId The course ID the module belongs to.
* @return Promise resolved when the data is invalidated.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async invalidateContent(moduleId: number, courseId: number): Promise<void> {
const siteId = CoreSites.instance.getCurrentSiteId();
await Promise.all([
CoreCourse.instance.invalidateModule(moduleId),
CoreFilepool.instance.invalidateFilesByComponent(siteId, this.component, moduleId),
]);
}
/**
* Load module contents into module.contents if they aren't loaded already.
*
* @param module Module to load the contents.
* @param courseId The course ID. Recommended to speed up the process and minimize data usage.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @return Promise resolved when loaded.
*/
loadContents(module: CoreCourseWSModule, courseId: number, ignoreCache?: boolean): Promise<void> {
return CoreCourse.instance.loadModuleContents(module, courseId, undefined, false, ignoreCache);
}
/**
* Prefetch a module.
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @param single True if we're downloading a single module, false if we're downloading a whole section.
* @param dirPath Path of the directory where to store all the content files.
* @return Promise resolved when done.
*/
prefetch(module: CoreCourseWSModule, courseId?: number, single?: boolean, dirPath?: string): Promise<void> {
courseId = courseId || module.course;
if (!courseId) {
throw new CoreError('Course ID not supplied.');
}
return this.downloadOrPrefetch(module, courseId, true, dirPath);
}
}

View File

@ -0,0 +1,57 @@
// (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 { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule } from '@ionic/angular';
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 { CoreCourseSectionSelectorComponent } from './section-selector/section-selector';
import { CoreCourseTagAreaComponent } from './tag-area/tag-area';
import { CoreCourseUnsupportedModuleComponent } from './unsupported-module/unsupported-module';
@NgModule({
declarations: [
CoreCourseFormatComponent,
CoreCourseModuleComponent,
CoreCourseModuleCompletionComponent,
CoreCourseModuleDescriptionComponent,
CoreCourseSectionSelectorComponent,
CoreCourseTagAreaComponent,
CoreCourseUnsupportedModuleComponent,
],
imports: [
CoreBlockComponentsModule,
CommonModule,
IonicModule,
TranslateModule.forChild(),
CoreSharedModule,
],
exports: [
CoreCourseFormatComponent,
CoreCourseModuleComponent,
CoreCourseModuleCompletionComponent,
CoreCourseModuleDescriptionComponent,
CoreCourseSectionSelectorComponent,
CoreCourseTagAreaComponent,
CoreCourseUnsupportedModuleComponent,
],
})
export class CoreCourseComponentsModule {}

View File

@ -0,0 +1,168 @@
<!-- Buttons to add to the header. *ngIf is needed, otherwise the component is executed too soon and doesn't find the header. -->
<core-navbar-buttons slot="end" *ngIf="loaded">
<core-context-menu>
<core-context-menu-item [hidden]="!displaySectionSelector || !sections || !sections.length" [priority]="500"
[content]="'core.course.sections' | translate" (action)="showSectionSelector()" iconAction="menu">
</core-context-menu-item>
</core-context-menu>
</core-navbar-buttons>
<core-block-course-blocks *ngIf="loaded" [courseId]="course!.id" [hideBlocks]="!displayBlocks" [downloadEnabled]="downloadEnabled"
[hideBottomBlocks]="selectedSection && selectedSection.id == allSectionsId && canLoadMore">
<core-dynamic-component [component]="courseFormatComponent" [data]="data">
<!-- Default course format. -->
<core-loading [hideUntil]="loaded">
<!-- Section selector. -->
<core-dynamic-component [component]="sectionSelectorComponent" [data]="data">
<div *ngIf="displaySectionSelector && sections && hasSeveralSections"
class="ion-text-wrap clearfix ion-justify-content-between core-button-selector-row"
[class.core-section-download]="downloadEnabled">
<ion-button class="core-button-select button-no-uppercase" (click)="showSectionSelector()"
aria-haspopup="true" [attr.aria-expanded]="sectionSelectorExpanded"
id="core-course-section-button" expand="block"> <!-- @todo: attr.aria-label? -->
<ion-icon name="fas-folder" slot="start"></ion-icon>
<span class="core-button-select-text">
<core-format-text *ngIf="selectedSection" [text]="selectedSection.name" contextLevel="course"
[contextInstanceId]="course?.id" [clean]="true" [singleLine]="true">
</core-format-text>
<span *ngIf="!selectedSection">{{ 'core.course.sections' | translate }}</span>
</span>
<div class="select-icon" slot="end"><div class="select-icon-inner"></div></div>
</ion-button>
<!-- Section download. -->
<ng-container *ngTemplateOutlet="sectionDownloadTemplate; context: {section: selectedSection}"></ng-container>
</div>
</core-dynamic-component>
<!-- Course summary. By default we only display the course progress. -->
<core-dynamic-component [component]="courseSummaryComponent" [data]="data">
<ion-list lines="none" class="core-format-progress-list"
*ngIf="imageThumb || (selectedSection?.id == allSectionsId && progress !== undefined) ||
(selectedSection && selectedSection.id != allSectionsId &&
(selectedSection.availabilityinfo || selectedSection.visible === 0))">
<div *ngIf="imageThumb" class="core-course-thumb">
<img [src]="imageThumb" core-external-content alt=""/>
</div>
<ng-container *ngIf="selectedSection">
<ion-item class="core-course-progress"
*ngIf="selectedSection?.id == allSectionsId && progress !== undefined">
<core-progress-bar [progress]="progress"></core-progress-bar>
</ion-item>
<ion-item *ngIf="selectedSection && selectedSection.id != allSectionsId &&
(selectedSection.availabilityinfo || selectedSection.visible === 0)">
<ion-badge color="secondary" class="ion-text-wrap"
*ngIf="selectedSection.visible === 0 && selectedSection.uservisible !== false">
{{ 'core.course.hiddenfromstudents' | translate }}
</ion-badge>
<ion-badge color="secondary" class="ion-text-wrap"
*ngIf="selectedSection.visible === 0 && selectedSection.uservisible === false">
{{ 'core.notavailable' | translate }}
</ion-badge>
<ion-badge color="secondary" class="ion-text-wrap" *ngIf="selectedSection.availabilityinfo">
<core-format-text [text]="selectedSection.availabilityinfo" contextLevel="course"
[contextInstanceId]="course?.id">
</core-format-text>
</ion-badge>
</ion-item>
</ng-container>
</ion-list>
</core-dynamic-component>
<!-- Single section. -->
<div *ngIf="selectedSection && selectedSection.id != allSectionsId">
<core-dynamic-component [component]="singleSectionComponent" [data]="data">
<ng-container *ngTemplateOutlet="sectionTemplate; context: {section: selectedSection}"></ng-container>
<core-empty-box *ngIf="!selectedSection.hasContent" icon="fas-th-large"
[message]="'core.course.nocontentavailable' | translate">
</core-empty-box>
</core-dynamic-component>
</div>
<!-- Multiple sections. -->
<div *ngIf="selectedSection && selectedSection.id == allSectionsId">
<core-dynamic-component [component]="allSectionsComponent" [data]="data">
<ng-container *ngFor="let section of sections; index as i">
<ng-container *ngIf="i <= showSectionId">
<ng-container *ngTemplateOutlet="sectionTemplate; context: {section: section}"></ng-container>
</ng-container>
</ng-container>
</core-dynamic-component>
<core-infinite-loading [enabled]="canLoadMore" (action)="showMoreActivities($event)"></core-infinite-loading>
</div>
<ion-buttons class="ion-padding core-course-section-nav-buttons safe-padding-horizontal"
*ngIf="displaySectionSelector && sections?.length">
<ion-button *ngIf="previousSection" color="medium" (click)="sectionChanged(previousSection)"
title="{{ 'core.previous' | translate }}">
<ion-icon name="fas-chevron-left" slot="icon-only"></ion-icon>
<core-format-text class="accesshide" [text]="previousSection.name" contextLevel="course"
[contextInstanceId]="course?.id">
</core-format-text>
</ion-button>
<ion-button *ngIf="nextSection" (click)="sectionChanged(nextSection)" title="{{ 'core.next' | translate }}">
<ion-icon name="fas-chevron-right" slot="icon-only"></ion-icon>
<core-format-text class="accesshide" [text]="nextSection.name" contextLevel="course"
[contextInstanceId]="course?.id">
</core-format-text>
</ion-button>
</ion-buttons>
</core-loading>
</core-dynamic-component>
</core-block-course-blocks>
<!-- Template to render a section. -->
<ng-template #sectionTemplate let-section="section">
<section ion-list *ngIf="!section.hiddenbynumsections && section.id != allSectionsId && section.id != stealthModulesSectionId">
<!-- Title is only displayed when viewing all sections. -->
<ion-item-divider *ngIf="selectedSection?.id == allSectionsId && section.name" class="ion-text-wrap" color="light"
[class.core-section-download]="downloadEnabled"
[class.item-dimmed]="section.visible === 0 || section.uservisible === false">
<ion-label>
<core-format-text [text]="section.name" contextLevel="course" [contextInstanceId]="course?.id"></core-format-text>
<!-- Section download. -->
<ng-container *ngTemplateOutlet="sectionDownloadTemplate; context: {section: section}"></ng-container>
<p *ngIf="section.visible === 0 || section.availabilityinfo">
<ion-badge color="secondary" *ngIf="section.visible === 0 && section.uservisible !== false" class="ion-text-wrap">
{{ 'core.course.hiddenfromstudents' | translate }}
</ion-badge>
<ion-badge color="secondary" *ngIf="section.visible === 0 && section.uservisible === false" class="ion-text-wrap">
{{ 'core.notavailable' | translate }}
</ion-badge>
<ion-badge color="secondary" *ngIf="section.availabilityinfo" class="ion-text-wrap">
<core-format-text [text]=" section.availabilityinfo" contextLevel="course" [contextInstanceId]="course?.id">
</core-format-text>
</ion-badge>
</p>
</ion-label>
</ion-item-divider>
<ion-item class="ion-text-wrap" *ngIf="section.summary">
<core-format-text [text]="section.summary" contextLevel="course" [contextInstanceId]="course?.id"></core-format-text>
</ion-item>
<ng-container *ngFor="let module of section.modules">
<core-course-module *ngIf="module.visibleoncoursepage !== 0" [module]="module" [courseId]="course?.id"
[downloadEnabled]="downloadEnabled" [section]="section" (completionChanged)="onCompletionChange($event)"
(statusChanged)="onModuleStatusChange()">
</core-course-module>
</ng-container>
</section>
</ng-template>
<!-- Template to render a section download button/progress. -->
<ng-template #sectionDownloadTemplate let-section="section">
<div *ngIf="section && downloadEnabled" class="core-button-spinner ion-float-end">
<!-- Download progress. -->
<ion-badge class="core-course-download-section-progress"
*ngIf="section.isDownloading && section.total > 0 && section.count < section.total">
{{section.count}} / {{section.total}}
</ion-badge>
<core-download-refresh [status]="section.downloadStatus" [enabled]="downloadEnabled" (action)="prefetch(section)"
[loading]="section.isDownloading || section.isCalculating" [canTrustDownload]="section.canCheckUpdates">
</core-download-refresh>
</div>
</ng-template>

View File

@ -0,0 +1,77 @@
// 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%;
}
}
// @todo
// .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);
// }
}

View File

@ -0,0 +1,633 @@
// (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,
CoreCourseProvider,
} from '@features/course/services/course';
import {
CoreCourseHelper,
CoreCourseModule,
CoreCourseModuleCompletionData,
CoreCourseSection,
CoreCourseSectionWithStatus,
} 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 '@features/course/services/module-prefetch-delegate';
import { CoreBlockCourseBlocksComponent } from '@features/block/components/course-blocks/course-blocks';
import { ModalController } from '@singletons';
import { CoreCourseSectionSelectorComponent } from '../section-selector/section-selector';
/**
* 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:
*
* <core-course-format [course]="course" [sections]="sections" (completionChanged)="onCompletionChange()"></core-course-format>
*/
@Component({
selector: 'core-course-format',
templateUrl: 'core-course-format.html',
styleUrls: ['format.scss'],
})
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?: CoreCourseSectionWithStatus[]; // List of course sections. The status will be calculated in this component.
@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<CoreCourseModuleCompletionData>(); // Notify when any module completion changes.
@ViewChildren(CoreDynamicComponent) dynamicComponents?: QueryList<CoreDynamicComponent>;
@ViewChild(CoreBlockCourseBlocksComponent) courseBlocksComponent?: CoreBlockCourseBlocksComponent;
// All the possible component classes.
courseFormatComponent?: Type<unknown>;
courseSummaryComponent?: Type<unknown>;
sectionSelectorComponent?: Type<unknown>;
singleSectionComponent?: Type<unknown>;
allSectionsComponent?: Type<unknown>;
canLoadMore = false;
showSectionId = 0;
sectionSelectorExpanded = false;
data: Record<string, unknown> = {}; // Data to pass to the components.
displaySectionSelector?: boolean;
displayBlocks?: boolean;
selectedSection?: CoreCourseSection;
previousSection?: CoreCourseSection;
nextSection?: CoreCourseSection;
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<CoreEventSectionStatusChangedData>(
CoreEvents.SECTION_STATUS_CHANGED,
async (data) => {
if (!this.downloadEnabled || !this.sections?.length || !data.sectionId || data.courseId != this.course?.id) {
return;
}
// 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 (CoreCourseModulePrefetchDelegate.instance.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 && !CoreCourseModulePrefetchDelegate.instance.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<CoreEventSelectCourseTabData>(CoreEvents.SELECT_COURSE_TAB, (data) => {
if (data.name) {
return;
}
let section: CoreCourseSection | 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<void> {
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<void> {
this.courseFormatComponent = await CoreCourseFormatDelegate.instance.getCourseFormatComponent(this.course!);
}
/**
* Load course summary component.
*
* @return Promise resolved when done.
*/
protected async loadCourseSummaryComponent(): Promise<void> {
this.courseSummaryComponent = await CoreCourseFormatDelegate.instance.getCourseSummaryComponent(this.course!);
}
/**
* Load section selector component.
*
* @return Promise resolved when done.
*/
protected async loadSectionSelectorComponent(): Promise<void> {
this.sectionSelectorComponent = await CoreCourseFormatDelegate.instance.getSectionSelectorComponent(this.course!);
}
/**
* Load single section component.
*
* @return Promise resolved when done.
*/
protected async loadSingleSectionComponent(): Promise<void> {
this.singleSectionComponent = await CoreCourseFormatDelegate.instance.getSingleSectionComponent(this.course!);
}
/**
* Load all sections component.
*
* @return Promise resolved when done.
*/
protected async loadAllSectionsComponent(): Promise<void> {
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: CoreCourseSection[]): Promise<void> {
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.
*/
async showSectionSelector(): Promise<void> {
if (this.sectionSelectorExpanded) {
return;
}
this.sectionSelectorExpanded = true;
const modal = await ModalController.instance.create({
component: CoreCourseSectionSelectorComponent,
componentProps: {
course: this.course,
sections: this.sections,
selected: this.selectedSection,
},
});
await modal.present();
const result = await modal.onWillDismiss();
this.sectionSelectorExpanded = false;
if (result?.data) {
this.sectionChanged(result?.data);
}
}
/**
* Function called when selected section changes.
*
* @param newSection The new selected section.
*/
sectionChanged(newSection: CoreCourseSection): 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();
if (this.downloadEnabled) {
this.calculateSectionsStatus(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: CoreCourseSection, section2: CoreCourseSection): boolean {
return section1 && section2 ? section1.id === section2.id : section1 === section2;
}
/**
* Calculate the status of sections.
*
* @param refresh If refresh or not.
*/
protected calculateSectionsStatus(refresh?: boolean): void {
if (!this.sections || !this.course) {
return;
}
CoreUtils.instance.ignoreErrors(CoreCourseHelper.instance.calculateSectionsStatus(this.sections, this.course.id, refresh));
}
/**
* 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).
*/
async prefetch(section: CoreCourseSectionWithStatus): Promise<void> {
section.isCalculating = true;
try {
await CoreCourseHelper.instance.confirmDownloadSizeSection(this.course!.id, section, this.sections);
await this.prefetchSection(section, true);
} catch (error) {
// User cancelled or there was an error calculating the size.
if (error) {
CoreDomUtils.instance.showErrorModal(error);
}
} finally {
section.isCalculating = false;
}
}
/**
* Prefetch a section.
*
* @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 async prefetchSection(section: CoreCourseSectionWithStatus, manual?: boolean): Promise<void> {
try {
await 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<IonRefresher>, done?: () => void, afterCompletionChange?: boolean): Promise<void> {
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');
});
if (!this.downloadEnabled || !this.course || !this.sections) {
return;
}
// 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: CoreCourseSection): 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 = (<CoreCourseModule[]> [])
.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.
*/
onModuleStatusChange(): void {
if (!this.downloadEnabled || !this.sections || !this.course) {
return;
}
CoreCourseHelper.instance.calculateSectionsStatus(this.sections, this.course.id, false, false);
}
}

View File

@ -0,0 +1,3 @@
<button *ngIf="completion" (click)="completionClicked($event)">
<img [src]="completionImage" [alt]="completionDescription">
</button>

View File

@ -0,0 +1,13 @@
:host {
button {
display: block;
background-color: transparent;
img {
padding: 5px;
width: 30px;
vertical-align: middle;
max-width: none;
}
}
}

View File

@ -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 { CoreCourseModuleCompletionData } 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:
*
* <core-course-module-completion [completion]="module.completiondata" [moduleName]="module.name"
* (completionChanged)="completionChanged()"></core-course-module-completion>
*/
@Component({
selector: 'core-course-module-completion',
templateUrl: 'core-course-module-completion.html',
styleUrls: ['module-completion.scss'],
})
export class CoreCourseModuleCompletionComponent implements OnChanges {
@Input() completion?: CoreCourseModuleCompletionData; // 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<CoreCourseModuleCompletionData>(); // 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<void> {
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<void> {
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<string, unknown> = {
$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);
}
}

View File

@ -0,0 +1,15 @@
<ion-card *ngIf="description">
<ion-item class="ion-text-wrap">
<ion-label>
<core-format-text [text]="description" [component]="component" [componentId]="componentId"
[maxHeight]="showFull && showFull !== 'false' ? 0 : 120" fullOnClick="true" [contextLevel]="contextLevel"
[contextInstanceId]="contextInstanceId" [courseId]="courseId">
</core-format-text>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="note">
<ion-label>
<ion-note slot="end">{{ note }}</ion-note>
</ion-label>
</ion-item>
</ion-card>

View File

@ -0,0 +1,17 @@
// @todo Review commented styles.
// ion-app.app-root {
// .safe-area-page,
// .safe-padding-horizontal {
// core-course-module-description {
// padding-left: 0 !important;
// padding-right: 0 !important;
// .item-ios.item-block {
// @include safe-area-padding-horizontal($item-ios-padding-end / 2, null);
// .item-inner {
// @include safe-area-padding-horizontal(null, $item-ios-padding-end / 2);
// }
// }
// }
// }
// }

View File

@ -0,0 +1,48 @@
// (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 } from '@angular/core';
/**
* Component to display the description of a module.
*
* This directive is meant to display a module description in a similar way throughout all the app.
*
* You can add a note at the right side of the description by using the 'note' attribute.
*
* You can also pass a component and componentId to be used in format-text.
*
* Module descriptions are shortened by default, allowing the user to see the full description by clicking in it.
* If you want the whole description to be shown you can use the 'showFull' attribute.
*
* Example usage:
*
* <core-course-module-description [description]="myDescription"></core-course-module-description
*/
@Component({
selector: 'core-course-module-description',
templateUrl: 'core-course-module-description.html',
})
export class CoreCourseModuleDescriptionComponent {
@Input() description?: string; // The description to display.
@Input() note?: string; // A note to display along with the description.
@Input() component?: string; // Component for format text directive.
@Input() componentId?: string | number; // Component ID to use in conjunction with the component.
@Input() showFull?: string | boolean; // Whether to always display the full description.
@Input() contextLevel?: string; // The context level.
@Input() contextInstanceId?: number; // The instance ID related to the context.
@Input() courseId?: number; // Course ID the text belongs to. It can be used to improve performance with filters.
}

View File

@ -0,0 +1,73 @@
<ion-item *ngIf="module && module.handlerData && module.visibleoncoursepage !== 0 && !module.handlerData.loading"
id="core-course-module-{{module.id}}" class="ion-text-wrap core-course-module-handler {{module.handlerData.class}}"
(click)="moduleClicked($event)" [title]="module.handlerData.a11yTitle" detail="false"
[ngClass]="{'item-media': module.handlerData.icon, 'item-dimmed': module.visible === 0 || module.uservisible === false,
'core-not-clickable': !module.handlerData.action || module.uservisible === false}">
<img slot="start" *ngIf="module.handlerData.icon" [src]="module.handlerData.icon" [alt]="modNameTranslated"
[attr.aria-hidden]="true" class="core-module-icon">
<ion-label>
<div class="core-module-title">
<core-format-text [text]="module.handlerData.title" contextLevel="module" [contextInstanceId]="module.id"
[courseId]="courseId" [attr.aria-label]="module.handlerData.a11yTitle + ', ' + modNameTranslated">
</core-format-text>
<!-- Buttons. -->
<div slot="end" *ngIf="module.uservisible !== false" class="buttons core-module-buttons"
[ngClass]="{'core-button-completion': module.completiondata}">
<!-- Module completion. -->
<core-course-module-completion *ngIf="module.completiondata" [completion]="module.completiondata"
[moduleName]="module.name" [moduleId]="module.id" (completionChanged)="completionChanged.emit($event)">
</core-course-module-completion>
<div class="core-module-buttons-more">
<!-- @todo <core-download-refresh [status]="downloadStatus" [enabled]="downloadEnabled" [canTrustDownload]="canCheckUpdates"
[loading]="spinner || module.handlerData.spinner" (action)="download($event)">
</core-download-refresh> -->
<!-- Buttons defined by the module handler. -->
<ion-button fill="clear" *ngFor="let button of module.handlerData.buttons" color="dark"
[hidden]="button.hidden || spinner || module.handlerData.spinner" class="core-animate-show-hide"
(click)="buttonClicked($event, button)"
[attr.aria-label]="button.label | translate:{$a: module.handlerData.title}">
<ion-icon [name]="button.icon" slot="icon-only"></ion-icon>
</ion-button>
</div>
</div>
</div>
<div class="core-module-more-info">
<ion-badge slot="end" *ngIf="module.handlerData.extraBadge" [color]="module.handlerData.extraBadgeColor"
class="ion-text-wrap ion-text-start">
<span [innerHTML]="module.handlerData.extraBadge"></span>
</ion-badge>
<ion-badge slot="end" *ngIf="module.visible === 0 && (!section || section.visible)" class="ion-text-wrap">
{{ 'core.course.hiddenfromstudents' | translate }}
</ion-badge>
<ion-badge slot="end" *ngIf="module.visible !== 0 && module.isStealth" class="ion-text-wrap">
{{ 'core.course.hiddenoncoursepage' | translate }}
</ion-badge>
<div class="core-module-availabilityinfo" *ngIf="module.availabilityinfo" slot="end">
<ion-badge class="ion-text-wrap">{{ 'core.restricted' | translate }}</ion-badge>
<core-format-text [text]="module.availabilityinfo" contextLevel="module" [contextInstanceId]="module.id"
[courseId]="courseId" class="ion-text-wrap">
</core-format-text>
</div>
<ion-badge slot="end" *ngIf="module.completiondata?.offline" color="warning" class="ion-text-wrap">
{{ 'core.course.manualcompletionnotsynced' | translate }}
</ion-badge>
</div>
<core-format-text class="core-module-description" *ngIf="module.description" maxHeight="80" [text]="module.description"
contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-format-text>
</ion-label>
</ion-item>
<!-- Loading. -->
<ion-item *ngIf="module && module.handlerData && module.visibleoncoursepage !== 0 && module.handlerData.loading" role="status"
class="ion-text-wrap" id="core-course-module-{{module.id}}" [title]="module.handlerData.a11yTitle"
[ngClass]="['core-course-module-handler', 'core-module-loading', module.handlerData.class]" detail="false">
<ion-label><ion-spinner></ion-spinner></ion-label>
</ion-item>

View File

@ -0,0 +1,164 @@
:host {
// @todo Review commented styles.
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);
// }

View File

@ -0,0 +1,234 @@
// (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, CoreEventPackageStatusChanged, CoreEvents } from '@singletons/events';
import {
CoreCourseHelper,
CoreCourseModule,
CoreCourseModuleCompletionData,
CoreCourseSection,
} from '@features/course/services/course-helper';
import { CoreCourse } from '@features/course/services/course';
import { CoreCourseModuleHandlerButton } from '@features/course/services/module-delegate';
import {
CoreCourseModulePrefetchDelegate,
CoreCourseModulePrefetchHandler,
} from '@features/course/services/module-prefetch-delegate';
/**
* Component to display a module entry in a list of modules.
*
* Example usage:
*
* <core-course-module [module]="module" [courseId]="courseId" (completionChanged)="onCompletionChange()"></core-course-module>
*/
@Component({
selector: 'core-course-module',
templateUrl: 'core-course-module.html',
styleUrls: ['module.scss'],
})
export class CoreCourseModuleComponent implements OnInit, OnDestroy {
@Input() module?: CoreCourseModule; // The module to render.
@Input() courseId?: number; // The course the module belongs to.
@Input() section?: CoreCourseSection; // 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.
this.calculateAndShowStatus();
};
@Output() completionChanged = new EventEmitter<CoreCourseModuleCompletionData>(); // Notify when module completion changes.
@Output() statusChanged = new EventEmitter<CoreCourseModuleStatusChangedData>(); // 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) {
// Listen for changes on this module status, even if download isn't enabled.
this.prefetchHandler = CoreCourseModulePrefetchDelegate.instance.getPrefetchHandlerFor(this.module);
this.canCheckUpdates = CoreCourseModulePrefetchDelegate.instance.canCheckUpdates();
this.statusObserver = CoreEvents.on<CoreEventPackageStatusChanged>(CoreEvents.PACKAGE_STATUS_CHANGED, (data) => {
if (!this.module || data.componentId != this.module.id || !this.prefetchHandler ||
data.component != this.prefetchHandler.component) {
return;
}
// Call determineModuleStatus to get the right status to display.
const status = CoreCourseModulePrefetchDelegate.instance.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);
}
}, CoreSites.instance.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!);
}
/**
* Download the module.
*
* @param refresh Whether it's refreshing.
* @return Promise resolved when done.
*/
async download(refresh: boolean): Promise<void> {
if (!this.prefetchHandler || !this.module) {
return;
}
// Show spinner since this operation might take a while.
this.spinner = true;
try {
// Get download size to ask for confirm if it's high.
const size = await this.prefetchHandler.getDownloadSize(this.module, this.courseId!, true);
await CoreCourseHelper.instance.prefetchModule(this.prefetchHandler, this.module, size, this.courseId!, refresh);
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) {
CoreDomUtils.instance.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);
}
/**
* Calculate and show module status.
*
* @return Promise resolved when done.
*/
protected async calculateAndShowStatus(): Promise<void> {
if (!this.module || !this.courseId) {
return;
}
const status = await CoreCourseModulePrefetchDelegate.instance.getModuleStatus(this.module, this.courseId);
this.showStatus(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;
};

View File

@ -0,0 +1,39 @@
<ion-header>
<ion-toolbar>
<ion-title>{{ 'core.course.sections' | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon slot="icon-only" name="fas-times"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-list id="core-course-section-selector" role="menu">
<ng-container *ngFor="let section of sections">
<ion-item *ngIf="!section.hiddenbynumsections && section.id != stealthModulesSectionId" class="ion-text-wrap"
(click)="selectSection(section)" [class.core-selected-item]="selected?.id == section.id"
[class.item-dimmed]="section.visible === 0 || section.uservisible === false" detail="false" role="menuitem"
[attr.aria-hidden]="section.uservisible === false">
<ion-icon name="fas-folder" slot="start"></ion-icon>
<ion-label>
<h2><core-format-text [text]="section.name" contextLevel="course" [contextInstanceId]="course?.id">
</core-format-text></h2>
<core-progress-bar *ngIf="section.progress >= 0" [progress]="section.progress"></core-progress-bar>
<ion-badge color="secondary" *ngIf="section.visible === 0 && section.uservisible !== false" class="ion-text-wrap">
{{ 'core.course.hiddenfromstudents' | translate }}
</ion-badge>
<ion-badge color="secondary" *ngIf="section.visible === 0 && section.uservisible === false" class="ion-text-wrap">
{{ 'core.notavailable' | translate }}
</ion-badge>
<ion-badge color="secondary" *ngIf="section.availabilityinfo" class="ion-text-wrap">
<core-format-text [text]=" section.availabilityinfo" contextLevel="course" [contextInstanceId]="course?.id">
</core-format-text>
</ion-badge>
</ion-label>
</ion-item>
</ng-container>
</ion-list>
</ion-content>

View File

@ -0,0 +1,17 @@
:host {
core-progress-bar {
.core-progress-text {
line-height: 24px;
position: absolute;
top: -8px;
right: 10px;
}
progress {
margin: 8px 0 4px 0;
}
}
ion-badge {
text-align: start;
}
}

View File

@ -0,0 +1,98 @@
// (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 } from '@angular/core';
import { CoreCourseSection } from '@features/course/services/course-helper';
import { CoreCourseProvider } from '@features/course/services/course';
import { CoreCourseAnyCourseData } from '@features/courses/services/courses';
import { CoreUtils } from '@services/utils/utils';
import { ModalController } from '@singletons';
/**
* Component to display course section selector in a modal.
*/
@Component({
selector: 'core-course-section-selector',
templateUrl: 'section-selector.html',
})
export class CoreCourseSectionSelectorComponent implements OnInit {
@Input() sections?: SectionWithProgress[];
@Input() selected?: CoreCourseSection;
@Input() course?: CoreCourseAnyCourseData;
stealthModulesSectionId = CoreCourseProvider.STEALTH_MODULES_SECTION_ID;
/**
* Component being initialized.
*/
ngOnInit(): void {
if (!this.course || !this.sections || !this.course.enablecompletion || !('courseformatoptions' in this.course) ||
!this.course.courseformatoptions) {
return;
}
const formatOptions = CoreUtils.instance.objectToKeyValueMap(this.course.courseformatoptions, 'name', 'value');
if (!formatOptions || formatOptions.coursedisplay != 1 || formatOptions.completionusertracked === false) {
return;
}
this.sections.forEach((section) => {
let complete = 0;
let total = 0;
section.modules.forEach((module) => {
if (!module.uservisible || module.completiondata === undefined || module.completiondata.tracking === undefined ||
module.completiondata.tracking <= CoreCourseProvider.COMPLETION_TRACKING_NONE) {
return;
}
total++;
if (module.completiondata.state == CoreCourseProvider.COMPLETION_COMPLETE ||
module.completiondata.state == CoreCourseProvider.COMPLETION_COMPLETE_PASS) {
complete++;
}
});
if (total > 0) {
section.progress = complete / total * 100;
}
});
}
/**
* Close the modal.
*/
closeModal(): void {
ModalController.instance.dismiss();
}
/**
* Select a section.
*
* @param section Selected section object.
*/
selectSection(section: SectionWithProgress): void {
if (section.uservisible !== false) {
ModalController.instance.dismiss(section);
}
}
}
type SectionWithProgress = CoreCourseSection & {
progress?: number;
};

View File

@ -0,0 +1,7 @@
<ion-item class="ion-text-wrap" *ngFor="let item of items" (click)="openCourse(item.courseId)" [title]="item.courseName">
<ion-icon name="fas-graduation-cap" slot="start"></ion-icon>
<ion-label>
<h2>{{ item.courseName }}</h2>
<p *ngIf="item.categoryName">{{ 'core.category' | translate }}: {{ item.categoryName }}</p>
</ion-label>
</ion-item>

View File

@ -0,0 +1,42 @@
// (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 } from '@angular/core';
import { CoreCourseHelper } from '@features/course/services/course-helper';
import { CoreCouseTagItems } from '@features/course/services/handlers/course-tag-area';
/**
* Component that renders the course tag area.
*/
@Component({
selector: 'core-course-tag-area',
templateUrl: 'core-course-tag-area.html',
})
export class CoreCourseTagAreaComponent {
@Input() items?: CoreCouseTagItems[]; // Area items to render.
/**
* Open a course.
*
* @param courseId The course to open.
*/
openCourse(courseId: number): void {
// @todo If this component is inside a split view, use the master nav to open it.
// const navCtrl = this.splitviewCtrl ? this.splitviewCtrl.getMasterNav() : this.navCtrl;
CoreCourseHelper.instance.getAndOpenCourse(courseId);
}
}

View File

@ -0,0 +1,25 @@
<div class="ion-padding">
<core-course-module-description [description]="module?.description" contextLevel="module"
[contextInstanceId]="module?.id" [courseId]="courseId">
</core-course-module-description>
<h2 *ngIf="!isDisabledInSite && isSupportedByTheApp">{{ 'core.whoops' | translate }}</h2>
<h2 *ngIf="isDisabledInSite || !isSupportedByTheApp">{{ 'core.uhoh' | translate }}</h2>
<p class="core-big" *ngIf="isDisabledInSite">{{ 'core.course.activitydisabled' | translate }}</p>
<p class="core-big" *ngIf="!isDisabledInSite && isSupportedByTheApp">
{{ 'core.course.activitynotyetviewablesiteupgradeneeded' | translate }}
</p>
<p class="core-big" *ngIf="!isDisabledInSite && !isSupportedByTheApp">
{{ 'core.course.activitynotyetviewableremoteaddon' | translate }}
</p>
<p *ngIf="isDisabledInSite || !isSupportedByTheApp"><strong>{{ 'core.course.askadmintosupport' | translate }}</strong></p>
<div *ngIf="module && module.url">
<p><strong>{{ 'core.course.useactivityonbrowser' | translate }}</strong></p>
<ion-button expand="block" [href]="module.url" core-link>
{{ 'core.openinbrowser' | translate }}
<ion-icon name="fas-external-link-alt" slot="end"></ion-icon>
</ion-button>
</div>
</div>

View File

@ -0,0 +1,49 @@
// (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 } from '@angular/core';
import { CoreCourse, CoreCourseWSModule } from '@features/course/services/course';
import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
/**
* Component that displays info about an unsupported module.
*/
@Component({
selector: 'core-course-unsupported-module',
templateUrl: 'core-course-unsupported-module.html',
})
export class CoreCourseUnsupportedModuleComponent implements OnInit {
@Input() courseId?: number; // The course to module belongs to.
@Input() module?: CoreCourseWSModule; // The module to render.
isDisabledInSite?: boolean;
isSupportedByTheApp?: boolean;
moduleName?: string;
/**
* Component being initialized.
*/
ngOnInit(): void {
if (!this.module) {
return;
}
this.isDisabledInSite = CoreCourseModuleDelegate.instance.isModuleDisabledInSite(this.module.modname);
this.isSupportedByTheApp = CoreCourseModuleDelegate.instance.hasHandler(this.module.modname);
this.moduleName = CoreCourse.instance.translateModuleName(this.module.modname);
}
}

View File

@ -0,0 +1,42 @@
// (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 { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
const routes: Routes = [
{
path: '',
redirectTo: 'index',
pathMatch: 'full',
},
{
path: 'index',
loadChildren: () => import('./pages/index/index.module').then( m => m.CoreCourseIndexPageModule),
},
{
path: 'unsupported-module',
loadChildren: () => import('./pages/unsupported-module/unsupported-module.module')
.then( m => m.CoreCourseUnsupportedModulePageModule),
},
{
path: 'list-mod-type',
loadChildren: () => import('./pages/list-mod-type/list-mod-type').then( m => m.CoreCourseListModTypePage),
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
})
export class CoreCourseLazyModule {}

View File

@ -12,20 +12,70 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { APP_INITIALIZER, NgModule } from '@angular/core';
import { Routes } from '@angular/router';
import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module';
import { CORE_SITE_SCHEMAS } from '@services/sites';
import { CoreCourseComponentsModule } from './components/components.module';
import { CoreCourseDirectivesModule } from './directives/directives.module';
import { CoreCourseFormatModule } from './format/formats.module';
import { SITE_SCHEMA, OFFLINE_SITE_SCHEMA } from './services/database/course';
import { SITE_SCHEMA as LOG_SITE_SCHEMA } from './services/database/log';
import { SITE_SCHEMA as PREFETCH_SITE_SCHEMA } from './services/database/module-prefetch';
import { CoreCourseIndexRoutingModule } from './pages/index/index-routing.module';
import { CoreCourseModulePrefetchDelegate } from './services/module-prefetch-delegate';
import { CoreCronDelegate } from '@services/cron';
import { CoreCourseLogCronHandler } from './services/handlers/log-cron';
import { CoreCourseSyncCronHandler } from './services/handlers/sync-cron';
import { CoreTagAreaDelegate } from '@features/tag/services/tag-area-delegate';
import { CoreCourseTagAreaHandler } from './services/handlers/course-tag-area';
import { CoreCourseModulesTagAreaHandler } from './services/handlers/modules-tag-area';
import { CoreCourse } from './services/course';
const routes: Routes = [
{
path: 'course',
loadChildren: () => import('@features/course/course-lazy.module').then(m => m.CoreCourseLazyModule),
},
];
const courseIndexRoutes: Routes = [
{
path: 'contents',
loadChildren: () => import('./pages/contents/contents.module').then(m => m.CoreCourseContentsPageModule),
},
];
@NgModule({
imports: [
CoreCourseIndexRoutingModule.forChild({ children: courseIndexRoutes }),
CoreMainMenuTabRoutingModule.forChild(routes),
CoreCourseFormatModule,
CoreCourseComponentsModule,
CoreCourseDirectivesModule,
],
exports: [CoreCourseIndexRoutingModule],
providers: [
{
provide: CORE_SITE_SCHEMAS,
useValue: [SITE_SCHEMA, OFFLINE_SITE_SCHEMA],
useValue: [SITE_SCHEMA, OFFLINE_SITE_SCHEMA, LOG_SITE_SCHEMA, PREFETCH_SITE_SCHEMA],
multi: true,
},
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreCronDelegate.instance.register(CoreCourseSyncCronHandler.instance);
CoreCronDelegate.instance.register(CoreCourseLogCronHandler.instance);
CoreTagAreaDelegate.instance.registerHandler(CoreCourseTagAreaHandler.instance);
CoreTagAreaDelegate.instance.registerHandler(CoreCourseModulesTagAreaHandler.instance);
CoreCourse.instance.initialize();
CoreCourseModulePrefetchDelegate.instance.initialize();
},
},
],
})
export class CoreCourseModule {
}
export class CoreCourseModule {}

View File

@ -0,0 +1,28 @@
// (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 { NgModule } from '@angular/core';
import { CoreCourseDownloadModuleMainFileDirective } from './download-module-main-file';
@NgModule({
declarations: [
CoreCourseDownloadModuleMainFileDirective,
],
imports: [],
exports: [
CoreCourseDownloadModuleMainFileDirective,
],
})
export class CoreCourseDirectivesModule {}

View File

@ -0,0 +1,85 @@
// (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 { Directive, Input, OnInit, ElementRef } from '@angular/core';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreCourse, CoreCourseModuleContentFile, CoreCourseWSModule } from '@features/course/services/course';
import { CoreCourseHelper } from '@features/course/services/course-helper';
/**
* Directive to allow downloading and open the main file of a module.
* When the item with this directive is clicked, the module will be downloaded (if needed) and opened.
* This is meant for modules like mod_resource.
*
* This directive must receive either a module or a moduleId. If no files are provided, it will use module.contents.
*/
@Directive({
selector: '[core-course-download-module-main-file]',
})
export class CoreCourseDownloadModuleMainFileDirective implements OnInit {
@Input() module?: CoreCourseWSModule; // The module.
@Input() moduleId?: string | number; // The module ID. Required if module is not supplied.
@Input() courseId?: string | number; // The course ID.
@Input() component?: string; // Component to link the file to.
@Input() componentId?: string | number; // Component ID to use in conjunction with the component. If not defined, use moduleId.
@Input() files?: CoreCourseModuleContentFile[]; // List of files of the module. If not provided, use module.contents.
protected element: HTMLElement;
constructor(element: ElementRef) {
this.element = element.nativeElement;
}
/**
* Component being initialized.
*/
ngOnInit(): void {
this.element.addEventListener('click', async (ev: Event) => {
if (!this.module && !this.moduleId) {
return;
}
ev.preventDefault();
ev.stopPropagation();
const modal = await CoreDomUtils.instance.showModalLoading();
const courseId = typeof this.courseId == 'string' ? parseInt(this.courseId, 10) : this.courseId;
try {
if (!this.module) {
// Try to get the module from cache.
this.moduleId = typeof this.moduleId == 'string' ? parseInt(this.moduleId, 10) : this.moduleId;
this.module = await CoreCourse.instance.getModule(this.moduleId!, courseId);
}
const componentId = this.componentId || module.id;
await CoreCourseHelper.instance.downloadModuleAndOpenFile(
this.module,
courseId ?? this.module.course!,
this.component,
componentId,
this.files,
);
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.errordownloading', true);
} finally {
modal.dismiss();
}
});
}
}

View File

@ -0,0 +1,33 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { CoreCourseFormatSingleActivityModule } from './singleactivity/singleactivity.module';
import { CoreCourseFormatSocialModule } from './social/social.module';
import { CoreCourseFormatTopicsModule } from './topics/topics.module';
import { CoreCourseFormatWeeksModule } from './weeks/weeks.module';
@NgModule({
declarations: [],
imports: [
CoreCourseFormatSingleActivityModule,
CoreCourseFormatSocialModule,
CoreCourseFormatTopicsModule,
CoreCourseFormatWeeksModule,
],
providers: [],
exports: [],
})
export class CoreCourseFormatModule { }

View File

@ -0,0 +1 @@
<core-dynamic-component [component]="componentClass" [data]="data"></core-dynamic-component>

View File

@ -0,0 +1,104 @@
// (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, OnChanges, SimpleChange, ViewChild, Output, EventEmitter, Type } from '@angular/core';
import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
import { CoreCourseUnsupportedModuleComponent } from '@features/course/components/unsupported-module/unsupported-module';
import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component';
import { CoreCourseAnyCourseData } from '@features/courses/services/courses';
import { IonRefresher } from '@ionic/angular';
import { CoreCourseModuleCompletionData, CoreCourseSectionWithStatus } from '@features/course/services/course-helper';
/**
* Component to display single activity format. It will determine the right component to use and instantiate it.
*
* The instantiated component will receive the course and the module as inputs.
*/
@Component({
selector: 'core-course-format-single-activity',
templateUrl: 'core-course-format-single-activity.html',
})
export class CoreCourseFormatSingleActivityComponent implements OnChanges {
@Input() course?: CoreCourseAnyCourseData; // The course to render.
@Input() sections?: CoreCourseSectionWithStatus[]; // 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<CoreCourseModuleCompletionData>(); // Notify when any module completion changes.
@ViewChild(CoreDynamicComponent) dynamicComponent?: CoreDynamicComponent;
componentClass?: Type<unknown>; // The class of the component to render.
data: Record<string | number, unknown> = {}; // Data to pass to the component.
/**
* Detect changes on input properties.
*/
async ngOnChanges(changes: { [name: string]: SimpleChange }): Promise<void> {
if (!changes.course || !changes.sections) {
return;
}
if (!this.course || !this.sections || !this.sections.length) {
return;
}
// In single activity the module should only have 1 section and 1 module. Get the module.
const module = this.sections?.[0].modules?.[0];
this.data.courseId = this.course.id;
this.data.module = module;
if (module && !this.componentClass) {
// We haven't obtained the class yet. Get it now.
const component = await CoreCourseModuleDelegate.instance.getMainComponent(this.course, module);
this.componentClass = component || CoreCourseUnsupportedModuleComponent;
}
}
/**
* 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<IonRefresher>, done?: () => void, afterCompletionChange?: boolean): Promise<void> {
if (afterCompletionChange) {
// Don't refresh the view after a completion change since completion isn't displayed.
return;
}
await this.dynamicComponent?.callComponentFunction('doRefresh', [refresher, done]);
}
/**
* User entered the page that contains the component.
*/
ionViewDidEnter(): void {
this.dynamicComponent?.callComponentFunction('ionViewDidEnter');
}
/**
* User left the page that contains the component.
*/
ionViewDidLeave(): void {
this.dynamicComponent?.callComponentFunction('ionViewDidLeave');
}
}

View File

@ -0,0 +1,153 @@
// (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 { Injectable, Type } from '@angular/core';
import { CoreCourseWSSection } from '@features/course/services/course';
import { CoreCourseFormatHandler } from '@features/course/services/format-delegate';
import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
import { CoreCourseAnyCourseData } from '@features/courses/services/courses';
import { CoreCourseFormatSingleActivityComponent } from '../../components/singleactivity';
import { makeSingleton } from '@singletons';
/**
* Handler to support singleactivity course format.
*/
@Injectable({ providedIn: 'root' })
export class CoreCourseFormatSingleActivityHandlerService implements CoreCourseFormatHandler {
name = 'CoreCourseFormatSingleActivity';
format = 'singleactivity';
/**
* Whether or not the handler is enabled on a site level.
*
* @return True or promise resolved with true if enabled.
*/
async isEnabled(): Promise<boolean> {
return true;
}
/**
* Whether it allows seeing all sections at the same time. Defaults to true.
*
* @param course The course to check.
* @return Whether it can view all sections.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
canViewAllSections(course: CoreCourseAnyCourseData): boolean {
return false;
}
/**
* Whether the option blocks should be displayed. Defaults to true.
*
* @param course The course to check.
* @return Whether it can display blocks.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
displayBlocks(course: CoreCourseAnyCourseData): boolean {
return false;
}
/**
* Get the title to use in course page. If not defined, course displayname or fullname.
* This function will be called without sections first, and then call it again when the sections are retrieved.
*
* @param course The course.
* @param sections List of sections.
* @return Title.
*/
getCourseTitle(course: CoreCourseAnyCourseData, sections?: CoreCourseWSSection[]): string {
if (sections?.[0]?.modules?.[0]) {
return sections[0].modules[0].name;
}
if (course.displayname) {
return course.displayname;
} else if (course.fullname) {
return course.fullname;
}
return '';
}
/**
* Whether the option to enable section/module download should be displayed. Defaults to true.
*
* @param course The course to check.
* @return Whether the option to enable section/module download should be displayed
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
displayEnableDownload(course: CoreCourseAnyCourseData): boolean {
return false;
}
/**
* Whether the default section selector should be displayed. Defaults to true.
*
* @param course The course to check.
* @return Whether the default section selector should be displayed.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
displaySectionSelector(course: CoreCourseAnyCourseData): boolean {
return false;
}
/**
* Whether the course refresher should be displayed. If it returns false, a refresher must be included in the course format,
* and the doRefresh method of CoreCourseSectionPage must be called on refresh. Defaults to true.
*
* @param course The course to check.
* @param sections List of course sections.
* @return Whether the refresher should be displayed.
*/
displayRefresher(course: CoreCourseAnyCourseData, sections: CoreCourseWSSection[]): boolean {
if (sections?.[0]?.modules?.[0]) {
return CoreCourseModuleDelegate.instance.displayRefresherInSingleActivity(sections[0].modules[0].modname);
} else {
return true;
}
}
/**
* Return the Component to use to display the course format instead of using the default one.
* Use it if you want to display a format completely different from the default one.
* If you want to customize the default format there are several methods to customize parts of it.
* It's recommended to return the class of the component, but you can also return an instance of the component.
*
* @param injector Injector.
* @param course The course to render.
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getCourseFormatComponent(course: CoreCourseAnyCourseData): Promise<Type<unknown>> {
return CoreCourseFormatSingleActivityComponent;
}
/**
* Whether the view should be refreshed when completion changes. If your course format doesn't display
* activity completion then you should return false.
*
* @param course The course.
* @return Whether course view should be refreshed when an activity completion changes.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async shouldRefreshWhenCompletionChanges(course: CoreCourseAnyCourseData): Promise<boolean> {
return false;
}
}
export class CoreCourseFormatSingleActivityHandler extends makeSingleton(CoreCourseFormatSingleActivityHandlerService) {}

View File

@ -0,0 +1,43 @@
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreCourseFormatDelegate } from '@features/course/services/format-delegate';
import { CoreCourseFormatSingleActivityComponent } from './components/singleactivity';
import { CoreCourseFormatSingleActivityHandler } from './services/handlers/singleactivity-format';
@NgModule({
declarations: [
CoreCourseFormatSingleActivityComponent,
],
imports: [
CoreSharedModule,
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreCourseFormatDelegate.instance.registerHandler(CoreCourseFormatSingleActivityHandler.instance);
},
},
],
exports: [
CoreCourseFormatSingleActivityComponent,
],
})
export class CoreCourseFormatSingleActivityModule {}

View File

@ -0,0 +1,32 @@
// (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 { Injectable } from '@angular/core';
import { makeSingleton } from '@singletons';
import { CoreCourseFormatSingleActivityHandlerService } from '../../../singleactivity/services/handlers/singleactivity-format';
/**
* Handler to support social course format.
* This format is like singleactivity in the mobile app, so we'll use the same implementation for both.
*/
@Injectable({ providedIn: 'root' })
export class CoreCourseFormatSocialHandlerService extends CoreCourseFormatSingleActivityHandlerService {
name = 'CoreCourseFormatSocial';
format = 'social';
}
export class CoreCourseFormatSocialHandler extends makeSingleton(CoreCourseFormatSocialHandlerService) {}

View File

@ -0,0 +1,34 @@
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreCourseFormatDelegate } from '@features/course/services/format-delegate';
import { CoreCourseFormatSocialHandler } from './services/handlers/social-format';
@NgModule({
declarations: [],
imports: [],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreCourseFormatDelegate.instance.registerHandler(CoreCourseFormatSocialHandler.instance);
},
},
],
})
export class CoreCourseFormatSocialModule {}

View File

@ -0,0 +1,40 @@
// (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 { Injectable } from '@angular/core';
import { CoreCourseFormatHandler } from '@features/course/services/format-delegate';
import { makeSingleton } from '@singletons';
/**
* Handler to support topics course format.
*/
@Injectable({ providedIn: 'root' })
export class CoreCourseFormatTopicsHandlerService implements CoreCourseFormatHandler {
name = 'CoreCourseFormatTopics';
format = 'topics';
/**
* Whether or not the handler is enabled on a site level.
*
* @return True or promise resolved with true if enabled.
*/
async isEnabled(): Promise<boolean> {
return true;
}
}
export class CoreCourseFormatTopicsHandler extends makeSingleton(CoreCourseFormatTopicsHandlerService) {}

View File

@ -0,0 +1,34 @@
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreCourseFormatDelegate } from '@features/course/services/format-delegate';
import { CoreCourseFormatTopicsHandler } from './services/handlers/topics-format';
@NgModule({
declarations: [],
imports: [],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreCourseFormatDelegate.instance.registerHandler(CoreCourseFormatTopicsHandler.instance);
},
},
],
})
export class CoreCourseFormatTopicsModule {}

View File

@ -0,0 +1,96 @@
// (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 { Injectable } from '@angular/core';
import { CoreTimeUtils } from '@services/utils/time';
import { CoreCourseFormatHandler } from '@features/course/services/format-delegate';
import { makeSingleton } from '@singletons';
import { CoreCourseAnyCourseData } from '@features/courses/services/courses';
import { CoreCourseWSSection } from '@features/course/services/course';
import { CoreConstants } from '@/core/constants';
import { CoreCourseSection } from '@features/course/services/course-helper';
/**
* Handler to support weeks course format.
*/
@Injectable({ providedIn: 'root' })
export class CoreCourseFormatWeeksHandlerService implements CoreCourseFormatHandler {
name = 'CoreCourseFormatWeeks';
format = 'weeks';
/**
* Whether or not the handler is enabled on a site level.
*
* @return True or promise resolved with true if enabled.
*/
async isEnabled(): Promise<boolean> {
return true;
}
/**
* Given a list of sections, get the "current" section that should be displayed first.
*
* @param course The course to get the title.
* @param sections List of sections.
* @return Current section (or promise resolved with current section).
*/
async getCurrentSection(course: CoreCourseAnyCourseData, sections: CoreCourseSection[]): Promise<CoreCourseSection> {
const now = CoreTimeUtils.instance.timestamp();
if ((course.startdate && now < course.startdate) || (course.enddate && now > course.enddate)) {
// Course hasn't started yet or it has ended already. Return all sections.
return sections[0];
}
for (let i = 0; i < sections.length; i++) {
const section = sections[i];
if (typeof section.section == 'undefined' || section.section < 1) {
continue;
}
const dates = this.getSectionDates(section, course.startdate || 0);
if (now >= dates.start && now < dates.end) {
return section;
}
}
// The section wasn't found, return all sections.
return sections[0];
}
/**
* Return the start and end date of a section.
*
* @param section The section to treat.
* @param startDate The course start date (in seconds).
* @return An object with the start and end date of the section.
*/
protected getSectionDates(section: CoreCourseWSSection, startDate: number): { start: number; end: number } {
// Hack alert. We add 2 hours to avoid possible DST problems. (e.g. we go into daylight savings and the date changes).
startDate = startDate + 7200;
const dates = {
start: startDate + (CoreConstants.SECONDS_WEEK * (section.section! - 1)),
end: 0,
};
dates.end = dates.start + CoreConstants.SECONDS_WEEK;
return dates;
}
}
export class CoreCourseFormatWeeksHandler extends makeSingleton(CoreCourseFormatWeeksHandlerService) {}

View File

@ -0,0 +1,34 @@
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreCourseFormatDelegate } from '@features/course/services/format-delegate';
import { CoreCourseFormatWeeksHandler } from './services/handlers/weeks-format';
@NgModule({
declarations: [],
imports: [],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreCourseFormatDelegate.instance.registerHandler(CoreCourseFormatWeeksHandler.instance);
},
},
],
})
export class CoreCourseFormatWeeksModule {}

View File

@ -0,0 +1,29 @@
<core-navbar-buttons slot="end">
<core-context-menu>
<core-context-menu-item [hidden]="!displayEnableDownload" [priority]="2000" [iconAction]="downloadEnabledIcon"
[content]="'core.settings.showdownloadoptions' | translate" (action)="toggleDownload()">
</core-context-menu-item>
<core-context-menu-item [hidden]="!downloadCourseEnabled" [priority]="1900"
[content]="prefetchCourseData.statusTranslatable | translate" (action)="prefetchCourse()"
[iconAction]="prefetchCourseData.icon" [closeOnClick]="false">
</core-context-menu-item>
<core-context-menu-item [priority]="1800" [content]="'core.course.coursesummary' | translate" (action)="openCourseSummary()"
iconAction="fa-graduation-cap">
</core-context-menu-item>
<core-context-menu-item *ngFor="let item of courseMenuHandlers" [priority]="item.priority" (action)="openMenuItem(item)"
[content]="item.data.title | translate" [iconAction]="item.data.icon" [class]="item.data.class">
</core-context-menu-item>
</core-context-menu>
</core-navbar-buttons>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!dataLoaded || !displayRefresher" (ionRefresh)="doRefresh($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="dataLoaded">
<core-course-format [course]="course" [sections]="sections" [initialSectionId]="sectionId"
[initialSectionNumber]="sectionNumber" [downloadEnabled]="downloadEnabled" [moduleId]="moduleId"
(completionChanged)="onCompletionChange($event)" class="core-course-format-{{course.format}}">
</core-course-format>
</core-loading>
</ion-content>

View File

@ -0,0 +1,46 @@
// (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 { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, Routes } from '@angular/router';
import { IonicModule } from '@ionic/angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreCourseContentsPage } from './contents';
import { CoreCourseComponentsModule } from '@features/course/components/components.module';
const routes: Routes = [
{
path: '',
component: CoreCourseContentsPage,
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
CommonModule,
IonicModule,
TranslateModule.forChild(),
CoreSharedModule,
CoreCourseComponentsModule,
],
declarations: [
CoreCourseContentsPage,
],
exports: [RouterModule],
})
export class CoreCourseContentsPageModule {}

View File

@ -0,0 +1,501 @@
// (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, ViewChild, OnInit, OnDestroy } from '@angular/core';
import { IonContent, IonRefresher } from '@ionic/angular';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { CoreCourses, CoreCourseAnyCourseData } from '@features/courses/services/courses';
import {
CoreCourse,
CoreCourseCompletionActivityStatus,
CoreCourseProvider,
} from '@features/course/services/course';
import {
CoreCourseHelper,
CoreCourseModuleCompletionData,
CoreCourseSection,
CorePrefetchStatusInfo,
} from '@features/course/services/course-helper';
import { CoreCourseFormatDelegate } from '@features/course/services/format-delegate';
import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate';
import {
CoreCourseOptionsDelegate,
CoreCourseOptionsMenuHandlerToDisplay,
} from '@features/course/services/course-options-delegate';
import { CoreCourseAutoSyncData, CoreCourseSync, CoreCourseSyncProvider } from '@features/course/services/sync';
import { CoreCourseFormatComponent } from '../../components/format/format';
import {
CoreEvents,
CoreEventObserver,
CoreEventCourseStatusChanged,
CoreEventCompletionModuleViewedData,
} from '@singletons/events';
import { CoreNavigator } from '@services/navigator';
/**
* Page that displays the contents of a course.
*/
@Component({
selector: 'page-core-course-contents',
templateUrl: 'contents.html',
})
export class CoreCourseContentsPage implements OnInit, OnDestroy {
@ViewChild(IonContent) content?: IonContent;
@ViewChild(CoreCourseFormatComponent) formatComponent?: CoreCourseFormatComponent;
course!: CoreCourseAnyCourseData;
sections?: CoreCourseSection[];
sectionId?: number;
sectionNumber?: number;
courseMenuHandlers: CoreCourseOptionsMenuHandlerToDisplay[] = [];
dataLoaded = false;
downloadEnabled = false;
downloadEnabledIcon = 'far-square'; // Disabled by default.
downloadCourseEnabled = false;
moduleId?: number;
displayEnableDownload = false;
displayRefresher = false;
prefetchCourseData: CorePrefetchStatusInfo = {
icon: 'spinner',
statusTranslatable: 'core.course.downloadcourse',
status: '',
loading: true,
};
protected formatOptions?: Record<string, unknown>;
protected completionObserver?: CoreEventObserver;
protected courseStatusObserver?: CoreEventObserver;
protected syncObserver?: CoreEventObserver;
protected isDestroyed = false;
/**
* Component being initialized.
*/
async ngOnInit(): Promise<void> {
const course = CoreNavigator.instance.getRouteParam<CoreCourseAnyCourseData>('course');
if (!course) {
CoreDomUtils.instance.showErrorModal('Missing required course parameter.');
CoreNavigator.instance.back();
return;
}
this.course = course;
this.sectionId = CoreNavigator.instance.getRouteNumberParam('sectionId');
this.sectionNumber = CoreNavigator.instance.getRouteNumberParam('sectionNumber');
this.moduleId = CoreNavigator.instance.getRouteNumberParam('moduleId');
this.displayEnableDownload = !CoreSites.instance.getCurrentSite()?.isOfflineDisabled() &&
CoreCourseFormatDelegate.instance.displayEnableDownload(this.course);
this.downloadCourseEnabled = !CoreCourses.instance.isDownloadCourseDisabledInSite();
this.initListeners();
await this.loadData(false, true);
this.dataLoaded = true;
this.initPrefetch();
}
/**
* Init listeners.
*
* @return Promise resolved when done.
*/
protected async initListeners(): Promise<void> {
if (this.downloadCourseEnabled) {
// Listen for changes in course status.
this.courseStatusObserver = CoreEvents.on<CoreEventCourseStatusChanged>(CoreEvents.COURSE_STATUS_CHANGED, (data) => {
if (data.courseId == this.course.id || data.courseId == CoreCourseProvider.ALL_COURSES_CLEARED) {
this.updateCourseStatus(data.status);
}
}, CoreSites.instance.getCurrentSiteId());
}
// Check if the course format requires the view to be refreshed when completion changes.
const shouldRefresh = await CoreCourseFormatDelegate.instance.shouldRefreshWhenCompletionChanges(this.course);
if (!shouldRefresh) {
return;
}
this.completionObserver = CoreEvents.on<CoreEventCompletionModuleViewedData>(
CoreEvents.COMPLETION_MODULE_VIEWED,
(data) => {
if (data && data.courseId == this.course.id) {
this.refreshAfterCompletionChange(true);
}
},
);
this.syncObserver = CoreEvents.on<CoreCourseAutoSyncData>(CoreCourseSyncProvider.AUTO_SYNCED, (data) => {
if (!data || data.courseId != this.course.id) {
return;
}
this.refreshAfterCompletionChange(false);
if (data.warnings && data.warnings[0]) {
CoreDomUtils.instance.showErrorModal(data.warnings[0]);
}
});
}
/**
* Init prefetch data if needed.
*
* @return Promise resolved when done.
*/
protected async initPrefetch(): Promise<void> {
if (!this.downloadCourseEnabled) {
// Cannot download the whole course, stop.
return;
}
// Determine the course prefetch status.
await this.determineCoursePrefetchIcon();
if (this.prefetchCourseData.icon != 'spinner') {
return;
}
// Course is being downloaded. Get the download promise.
const promise = CoreCourseHelper.instance.getCourseDownloadPromise(this.course.id);
if (promise) {
// There is a download promise. Show an error if it fails.
promise.catch((error) => {
if (!this.isDestroyed) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true);
}
});
} else {
// No download, this probably means that the app was closed while downloading. Set previous status.
const status = await CoreCourse.instance.setCoursePreviousStatus(this.course.id);
this.updateCourseStatus(status);
}
}
/**
* Fetch and load all the data required for the view.
*
* @param refresh If it's refreshing content.
* @param sync If it should try to sync.
* @return Promise resolved when done.
*/
protected async loadData(refresh?: boolean, sync?: boolean): Promise<void> {
// First of all, get the course because the data might have changed.
const result = await CoreUtils.instance.ignoreErrors(CoreCourseHelper.instance.getCourse(this.course.id));
if (result) {
if (this.course.id === result.course.id && 'displayname' in this.course && !('displayname' in result.course)) {
result.course.displayname = this.course.displayname;
}
this.course = result.course;
}
if (sync) {
// Try to synchronize the course data.
// For now we don't allow manual syncing, so ignore errors.
const result = await CoreUtils.instance.ignoreErrors(CoreCourseSync.instance.syncCourse(this.course.id));
if (result?.warnings?.length) {
CoreDomUtils.instance.showErrorModal(result.warnings[0]);
}
}
try {
await Promise.all([
this.loadSections(refresh),
this.loadMenuHandlers(refresh),
this.loadCourseFormatOptions(),
]);
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.couldnotloadsectioncontent', true);
}
}
/**
* Load course sections.
*
* @param refresh If it's refreshing content.
* @return Promise resolved when done.
*/
protected async loadSections(refresh?: boolean): Promise<void> {
// Get all the sections.
const sections = await CoreCourse.instance.getSections(this.course.id, false, true);
if (refresh) {
// Invalidate the recently downloaded module list. To ensure info can be prefetched.
const modules = CoreCourse.instance.getSectionsModules(sections);
await CoreCourseModulePrefetchDelegate.instance.invalidateModules(modules, this.course.id);
}
let completionStatus: Record<string, CoreCourseCompletionActivityStatus> = {};
// Get the completion status.
if (this.course.enablecompletion !== false) {
const sectionWithModules = sections.find((section) => section.modules.length > 0);
if (sectionWithModules && typeof sectionWithModules.modules[0].completion != 'undefined') {
// The module already has completion (3.6 onwards). Load the offline completion.
await CoreUtils.instance.ignoreErrors(CoreCourseHelper.instance.loadOfflineCompletion(this.course.id, sections));
} else {
const fetchedData = await CoreUtils.instance.ignoreErrors(
CoreCourse.instance.getActivitiesCompletionStatus(this.course.id),
);
completionStatus = fetchedData || completionStatus;
}
}
// Add handlers
const result = CoreCourseHelper.instance.addHandlerDataForModules(
sections,
this.course.id,
completionStatus,
this.course.fullname,
true,
);
this.sections = result.sections;
if (CoreCourseFormatDelegate.instance.canViewAllSections(this.course)) {
// Add a fake first section (all sections).
this.sections.unshift(CoreCourseHelper.instance.createAllSectionsSection());
}
// Get whether to show the refresher now that we have sections.
this.displayRefresher = CoreCourseFormatDelegate.instance.displayRefresher(this.course, this.sections);
}
/**
* Load the course menu handlers.
*
* @param refresh If it's refreshing content.
* @return Promise resolved when done.
*/
protected async loadMenuHandlers(refresh?: boolean): Promise<void> {
this.courseMenuHandlers = await CoreCourseOptionsDelegate.instance.getMenuHandlersToDisplay(this.course, refresh);
}
/**
* Load course format options if needed.
*
* @return Promise resolved when done.
*/
protected async loadCourseFormatOptions(): Promise<void> {
// Load the course format options when course completion is enabled to show completion progress on sections.
if (!this.course.enablecompletion || !CoreCourses.instance.isGetCoursesByFieldAvailable()) {
return;
}
if ('courseformatoptions' in this.course && this.course.courseformatoptions) {
// Already loaded.
this.formatOptions = CoreUtils.instance.objectToKeyValueMap(this.course.courseformatoptions, 'name', 'value');
return;
}
const course = await CoreUtils.instance.ignoreErrors(CoreCourses.instance.getCourseByField('id', this.course.id));
course && Object.assign(this.course, course);
if (course?.courseformatoptions) {
this.formatOptions = CoreUtils.instance.objectToKeyValueMap(course.courseformatoptions, 'name', 'value');
}
}
/**
* Refresh the data.
*
* @param refresher Refresher.
* @return Promise resolved when done.
*/
async doRefresh(refresher?: CustomEvent<IonRefresher>): Promise<void> {
await CoreUtils.instance.ignoreErrors(this.invalidateData());
try {
await this.loadData(true, true);
} finally {
// Do not call doRefresh on the format component if the refresher is defined in the format component
// to prevent an inifinite loop.
if (this.displayRefresher && this.formatComponent) {
await CoreUtils.instance.ignoreErrors(this.formatComponent.doRefresh(refresher));
}
refresher?.detail.complete();
}
}
/**
* The completion of any of the modules has changed.
*
* @param completionData Completion data.
* @return Promise resolved when done.
*/
async onCompletionChange(completionData: CoreCourseModuleCompletionData): Promise<void> {
const shouldReload = typeof completionData.valueused == 'undefined' || completionData.valueused;
if (!shouldReload) {
return;
}
await CoreUtils.instance.ignoreErrors(this.invalidateData());
await this.refreshAfterCompletionChange(true);
}
/**
* Invalidate the data.
*
* @return Promise resolved when done.
*/
protected async invalidateData(): Promise<void> {
const promises: Promise<void>[] = [];
promises.push(CoreCourse.instance.invalidateSections(this.course.id));
promises.push(CoreCourses.instance.invalidateUserCourses());
promises.push(CoreCourseFormatDelegate.instance.invalidateData(this.course, this.sections || []));
if (this.sections) {
promises.push(CoreCourseModulePrefetchDelegate.instance.invalidateCourseUpdates(this.course.id));
}
await Promise.all(promises);
}
/**
* Refresh list after a completion change since there could be new activities.
*
* @param sync If it should try to sync.
* @return Promise resolved when done.
*/
protected async refreshAfterCompletionChange(sync?: boolean): Promise<void> {
// Save scroll position to restore it once done.
const scrollElement = await this.content?.getScrollElement();
const scrollTop = scrollElement?.scrollTop || 0;
const scrollLeft = scrollElement?.scrollLeft || 0;
this.dataLoaded = false;
this.content?.scrollToTop(0); // Scroll top so the spinner is seen.
try {
await this.loadData(true, sync);
await this.formatComponent?.doRefresh(undefined, undefined, true);
} finally {
this.dataLoaded = true;
// Wait for new content height to be calculated and scroll without animation.
setTimeout(() => {
this.content?.scrollToPoint(scrollLeft, scrollTop, 0);
});
}
}
/**
* Determines the prefetch icon of the course.
*
* @return Promise resolved when done.
*/
protected async determineCoursePrefetchIcon(): Promise<void> {
this.prefetchCourseData = await CoreCourseHelper.instance.getCourseStatusIconAndTitle(this.course.id);
}
/**
* Prefetch the whole course.
*/
async prefetchCourse(): Promise<void> {
try {
await CoreCourseHelper.instance.confirmAndPrefetchCourse(
this.prefetchCourseData,
this.course,
this.sections,
undefined,
this.courseMenuHandlers,
);
} catch (error) {
if (this.isDestroyed) {
return;
}
CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true);
}
}
/**
* Toggle download enabled.
*/
toggleDownload(): void {
this.downloadEnabled = !this.downloadEnabled;
this.downloadEnabledIcon = this.downloadEnabled ? 'far-check-square' : 'far-square';
}
/**
* Update the course status icon and title.
*
* @param status Status to show.
*/
protected updateCourseStatus(status: string): void {
this.prefetchCourseData = CoreCourseHelper.instance.getCourseStatusIconAndTitleFromStatus(status);
}
/**
* Open the course summary
*/
openCourseSummary(): void {
CoreNavigator.instance.navigateToSitePath('/courses/preview', { params: { course: this.course, avoidOpenCourse: true } });
}
/**
* Opens a menu item registered to the delegate.
*
* @param item Item to open
*/
openMenuItem(item: CoreCourseOptionsMenuHandlerToDisplay): void {
const params = Object.assign({ course: this.course }, item.data.pageParams);
CoreNavigator.instance.navigateToSitePath(item.data.page, { params });
}
/**
* Page destroyed.
*/
ngOnDestroy(): void {
this.isDestroyed = true;
this.completionObserver?.off();
this.courseStatusObserver?.off();
this.syncObserver?.off();
}
/**
* User entered the page.
*/
ionViewDidEnter(): void {
this.formatComponent?.ionViewDidEnter();
}
/**
* User left the page.
*/
ionViewDidLeave(): void {
this.formatComponent?.ionViewDidLeave();
}
}

View File

@ -0,0 +1,33 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { InjectionToken, ModuleWithProviders, NgModule } from '@angular/core';
import { ModuleRoutesConfig } from '@/app/app-routing.module';
export const COURSE_INDEX_ROUTES = new InjectionToken('COURSE_INDEX_ROUTES');
@NgModule()
export class CoreCourseIndexRoutingModule {
static forChild(routes: ModuleRoutesConfig): ModuleWithProviders<CoreCourseIndexRoutingModule> {
return {
ngModule: CoreCourseIndexRoutingModule,
providers: [
{ provide: COURSE_INDEX_ROUTES, multi: true, useValue: routes },
],
};
}
}

View File

@ -0,0 +1,15 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>
<core-format-text [text]="title" contextLevel="course" [contextInstanceId]="course?.id"></core-format-text>
</ion-title>
<ion-buttons slot="end"></ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<core-tabs [tabs]="tabs" [hideUntil]="loaded"></core-tabs>
</ion-content>

View File

@ -0,0 +1,54 @@
// (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 { Injector, NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, ROUTES, Routes } from '@angular/router';
import { IonicModule } from '@ionic/angular';
import { TranslateModule } from '@ngx-translate/core';
import { resolveModuleRoutes } from '@/app/app-routing.module';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreCourseIndexPage } from './index';
import { COURSE_INDEX_ROUTES } from './index-routing.module';
function buildRoutes(injector: Injector): Routes {
const routes = resolveModuleRoutes(injector, COURSE_INDEX_ROUTES);
return [
{
path: '',
component: CoreCourseIndexPage,
children: routes.children,
},
...routes.siblings,
];
}
@NgModule({
providers: [
{ provide: ROUTES, multi: true, useFactory: buildRoutes, deps: [Injector] },
],
imports: [
CommonModule,
IonicModule,
TranslateModule.forChild(),
CoreSharedModule,
],
declarations: [
CoreCourseIndexPage,
],
exports: [RouterModule],
})
export class CoreCourseIndexPageModule {}

View File

@ -0,0 +1,188 @@
// (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, ViewChild, OnDestroy, OnInit } from '@angular/core';
import { Params } from '@angular/router';
import { CoreTab, CoreTabsComponent } from '@components/tabs/tabs';
import { CoreCourseFormatDelegate } from '../../services/format-delegate';
import { CoreCourseOptionsDelegate } from '../../services/course-options-delegate';
import { CoreCourseAnyCourseData } from '@features/courses/services/courses';
import { CoreEventObserver, CoreEvents, CoreEventSelectCourseTabData } from '@singletons/events';
import { CoreCourse, CoreCourseWSModule } from '@features/course/services/course';
import { CoreCourseHelper } from '@features/course/services/course-helper';
import { CoreUtils } from '@services/utils/utils';
import { CoreTextUtils } from '@services/utils/text';
import { CoreNavigator } from '@services/navigator';
/**
* Page that displays the list of courses the user is enrolled in.
*/
@Component({
selector: 'page-core-course-index',
templateUrl: 'index.html',
})
export class CoreCourseIndexPage implements OnInit, OnDestroy {
@ViewChild(CoreTabsComponent) tabsComponent?: CoreTabsComponent;
title?: string;
course?: CoreCourseAnyCourseData;
tabs: CourseTab[] = [];
loaded = false;
protected currentPagePath = '';
protected selectTabObserver: CoreEventObserver;
protected firstTabName?: string;
protected contentsTab: CoreTab = {
page: 'contents',
title: 'core.course.contents',
pageParams: {},
};
constructor() {
this.selectTabObserver = CoreEvents.on<CoreEventSelectCourseTabData>(CoreEvents.SELECT_COURSE_TAB, (data) => {
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;
}
if (data.sectionNumber) {
this.contentsTab.pageParams!.sectionNumber = data.sectionNumber;
}
// Select course contents.
this.tabsComponent?.selectByIndex(0);
} else if (this.tabs) {
const index = this.tabs.findIndex((tab) => tab.name == data.name);
if (index >= 0) {
this.tabsComponent?.selectByIndex(index + 1);
}
}
});
}
/**
* Component being initialized.
*/
async ngOnInit(): Promise<void> {
// Get params.
this.course = CoreNavigator.instance.getRouteParam('course');
this.firstTabName = CoreNavigator.instance.getRouteParam('selectedTab');
const module = CoreNavigator.instance.getRouteParam<CoreCourseWSModule>('module');
const modParams = CoreNavigator.instance.getRouteParam<Params>('modParams');
this.currentPagePath = CoreNavigator.instance.getCurrentPath();
this.contentsTab.page = CoreTextUtils.instance.concatenatePaths(this.currentPagePath, this.contentsTab.page);
this.contentsTab.pageParams = {
course: this.course,
sectionId: CoreNavigator.instance.getRouteNumberParam('sectionId'),
sectionNumber: CoreNavigator.instance.getRouteNumberParam('sectionNumber'),
};
if (module) {
this.contentsTab.pageParams!.moduleId = module.id;
CoreCourseHelper.instance.openModule(module, this.course!.id, this.contentsTab.pageParams!.sectionId, modParams);
}
this.tabs.push(this.contentsTab);
this.loaded = true;
await Promise.all([
this.loadCourseHandlers(),
this.loadTitle(),
]);
}
/**
* Load course option handlers.
*
* @return Promise resolved when done.
*/
protected async loadCourseHandlers(): Promise<void> {
// Load the course handlers.
const handlers = await CoreCourseOptionsDelegate.instance.getHandlersToDisplay(this.course!, false, false);
this.tabs.concat(handlers.map(handler => handler.data));
let tabToLoad: number | undefined;
// Add the courseId to the handler component data.
handlers.forEach((handler, index) => {
handler.data.page = CoreTextUtils.instance.concatenatePaths(this.currentPagePath, handler.data.page);
handler.data.pageParams = handler.data.pageParams || {};
handler.data.pageParams.courseId = this.course!.id;
// Check if this handler should be the first selected tab.
if (this.firstTabName && handler.name == this.firstTabName) {
tabToLoad = index + 1;
}
});
// Select the tab if needed.
this.firstTabName = undefined;
if (tabToLoad) {
setTimeout(() => {
this.tabsComponent?.selectByIndex(tabToLoad!);
});
}
}
/**
* Load title for the page.
*
* @return Promise resolved when done.
*/
protected async loadTitle(): Promise<void> {
// Get the title to display initially.
this.title = CoreCourseFormatDelegate.instance.getCourseTitle(this.course!);
// Load sections.
const sections = await CoreUtils.instance.ignoreErrors(CoreCourse.instance.getSections(this.course!.id, false, true));
if (!sections) {
return;
}
// Get the title again now that we have sections.
this.title = CoreCourseFormatDelegate.instance.getCourseTitle(this.course!, sections);
}
/**
* Page destroyed.
*/
ngOnDestroy(): void {
this.selectTabObserver?.off();
}
/**
* User entered the page.
*/
ionViewDidEnter(): void {
this.tabsComponent?.ionViewDidEnter();
}
/**
* User left the page.
*/
ionViewDidLeave(): void {
this.tabsComponent?.ionViewDidLeave();
}
}
type CourseTab = CoreTab & {
name?: string;
};

View File

@ -0,0 +1,28 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>{{ title }}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refreshData($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="loaded">
<core-empty-box *ngIf="!sections || !sections.length" icon="qr-scanner"
[message]="'core.course.nocontentavailable' | translate">
</core-empty-box>
<ion-list>
<ng-container *ngFor="let section of sections" >
<ng-container *ngFor="let module of section.modules">
<core-course-module *ngIf="module.visibleoncoursepage !== 0" [module]="module" [section]="section"
[courseId]="courseId" [downloadEnabled]="downloadEnabled">
</core-course-module>
</ng-container>
</ng-container>
</ion-list>
</core-loading>
</ion-content>

View File

@ -0,0 +1,46 @@
// (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 { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, Routes } from '@angular/router';
import { IonicModule } from '@ionic/angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreCourseListModTypePage } from './list-mod-type';
import { CoreCourseComponentsModule } from '@features/course/components/components.module';
const routes: Routes = [
{
path: '',
component: CoreCourseListModTypePage,
},
];
@NgModule({
declarations: [
CoreCourseListModTypePage,
],
imports: [
RouterModule.forChild(routes),
CommonModule,
IonicModule,
TranslateModule.forChild(),
CoreSharedModule,
CoreCourseComponentsModule,
],
exports: [RouterModule],
})
export class CoreCourseListModTypePageModule {}

View File

@ -0,0 +1,132 @@
// (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, OnInit } from '@angular/core';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreCourse } from '@features/course/services/course';
import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
import { CoreCourseHelper, CoreCourseSection } from '@features/course/services/course-helper';
import { CoreNavigator } from '@services/navigator';
import { CoreConstants } from '@/core/constants';
import { IonRefresher } from '@ionic/angular';
import { CoreUtils } from '@services/utils/utils';
/**
* Page that displays all modules of a certain type in a course.
*/
@Component({
selector: 'page-core-course-list-mod-type',
templateUrl: 'list-mod-type.html',
})
export class CoreCourseListModTypePage implements OnInit {
sections: CoreCourseSection[] = [];
title = '';
loaded = false;
downloadEnabled = false;
courseId?: number;
protected modName?: string;
protected archetypes: Record<string, number> = {}; // To speed up the check of modules.
/**
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
this.title = CoreNavigator.instance.getRouteParam('title') || '';
this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId');
this.modName = CoreNavigator.instance.getRouteParam('modName');
this.downloadEnabled = !CoreSites.instance.getCurrentSite()?.isOfflineDisabled();
try {
await this.fetchData();
} finally {
this.loaded = true;
}
}
/**
* Fetches the data.
*
* @return Resolved when done.
*/
protected async fetchData(): Promise<void> {
if (!this.courseId) {
return;
}
try {
// Get all the modules in the course.
let sections = await CoreCourse.instance.getSections(this.courseId, false, true);
sections = sections.filter((section) => {
if (!section.modules) {
return false;
}
section.modules = section.modules.filter((mod) => {
if (mod.uservisible === false || !CoreCourse.instance.moduleHasView(mod)) {
// Ignore this module.
return false;
}
if (this.modName === 'resources') {
// Check that the module is a resource.
if (typeof this.archetypes[mod.modname] == 'undefined') {
this.archetypes[mod.modname] = CoreCourseModuleDelegate.instance.supportsFeature<number>(
mod.modname,
CoreConstants.FEATURE_MOD_ARCHETYPE,
CoreConstants.MOD_ARCHETYPE_OTHER,
);
}
if (this.archetypes[mod.modname] == CoreConstants.MOD_ARCHETYPE_RESOURCE) {
return true;
}
} else if (mod.modname == this.modName) {
return true;
}
});
return section.modules.length > 0;
});
const result = CoreCourseHelper.instance.addHandlerDataForModules(sections, this.courseId);
this.sections = result.sections;
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting data');
}
}
/**
* Refresh the data.
*
* @param refresher Refresher.
* @return Promise resolved when done.
*/
async refreshData(refresher: CustomEvent<IonRefresher>): Promise<void> {
await CoreUtils.instance.ignoreErrors(CoreCourse.instance.invalidateSections(this.courseId || 0));
try {
await this.fetchData();
} finally {
refresher.detail.complete();
}
}
}

View File

@ -0,0 +1,25 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>
<core-format-text [text]="module?.name" contextLevel="module" [contextInstanceId]="module?.id" [courseId]="courseId">
</core-format-text>
</ion-title>
<ion-buttons slot="end">
<core-context-menu>
<core-context-menu-item [priority]="900" *ngIf="module?.url" [href]="module!.url"
[content]="'core.openinbrowser' | translate" iconAction="fas-external-link-alt">
</core-context-menu-item>
<core-context-menu-item [priority]="800" *ngIf="module?.description" [content]="'core.moduleintro' | translate"
(action)="expandDescription()" iconAction="fas-arrow-right">
</core-context-menu-item>
</core-context-menu>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<core-course-unsupported-module [module]="module" [courseId]="courseId"></core-course-unsupported-module>
</ion-content>

View File

@ -0,0 +1,46 @@
// (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 { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, Routes } from '@angular/router';
import { IonicModule } from '@ionic/angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreCourseUnsupportedModulePage } from './unsupported-module';
import { CoreCourseComponentsModule } from '@features/course/components/components.module';
const routes: Routes = [
{
path: '',
component: CoreCourseUnsupportedModulePage,
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
CommonModule,
IonicModule,
TranslateModule.forChild(),
CoreSharedModule,
CoreCourseComponentsModule,
],
declarations: [
CoreCourseUnsupportedModulePage,
],
exports: [RouterModule],
})
export class CoreCourseUnsupportedModulePageModule {}

View File

@ -0,0 +1,54 @@
// (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, OnInit } from '@angular/core';
import { CoreCourseWSModule } from '@features/course/services/course';
import { CoreNavigator } from '@services/navigator';
import { CoreTextUtils } from '@services/utils/text';
import { Translate } from '@singletons';
/**
* Page that displays info about an unsupported module.
*/
@Component({
selector: 'page-core-course-unsupported-module',
templateUrl: 'unsupported-module.html',
})
export class CoreCourseUnsupportedModulePage implements OnInit {
module?: CoreCourseWSModule;
courseId?: number;
/**
* @inheritDoc
*/
ngOnInit(): void {
this.module = CoreNavigator.instance.getRouteParam('module');
this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId');
}
/**
* Expand the description.
*/
expandDescription(): void {
CoreTextUtils.instance.viewText(Translate.instance.instant('core.description'), this.module!.description!, {
filter: true,
contextLevel: 'module',
instanceId: this.module!.id,
courseId: this.courseId,
});
}
}

File diff suppressed because it is too large Load Diff

View File

@ -11,14 +11,19 @@
// 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.
// @todo test delegate
import { Injectable, Type } from '@angular/core';
import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate';
import { Injectable } from '@angular/core';
import { CoreDelegate, CoreDelegateHandler, CoreDelegateToDisplay } from '@classes/delegate';
import { CoreEvents } from '@singletons/events';
import { CoreSites } from '@services/sites';
import { CoreUtils, PromiseDefer } from '@services/utils/utils';
import { CoreCourses, CoreCoursesProvider, CoreCourseUserAdminOrNavOptionIndexed } from '@features/courses/services/courses';
import {
CoreCourseAnyCourseData,
CoreCourseAnyCourseDataWithOptions,
CoreCourses,
CoreCoursesProvider,
CoreCourseUserAdminOrNavOptionIndexed,
} from '@features/courses/services/courses';
import { CoreCourseProvider } from './course';
import { Params } from '@angular/router';
import { makeSingleton } from '@singletons';
@ -47,8 +52,9 @@ export interface CoreCourseOptionsHandler extends CoreDelegateHandler {
* @param admOptions Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions.
* @return True or promise resolved with true if enabled.
*/
isEnabledForCourse(courseId: number,
accessData: CoreCourseAccessData,
isEnabledForCourse(
courseId: number,
accessData: CoreCourseAccess,
navOptions?: CoreCourseUserAdminOrNavOptionIndexed,
admOptions?: CoreCourseUserAdminOrNavOptionIndexed,
): boolean | Promise<boolean>;
@ -56,11 +62,11 @@ export interface CoreCourseOptionsHandler extends CoreDelegateHandler {
/**
* Returns the data needed to render the handler.
*
* @param course The course. // @todo: define type in the whole file.
* @param course The course.
* @return Data or promise resolved with the data.
*/
getDisplayData?(
course: CoreEnrolledCourseDataWithExtraInfoAndOptions,
course: CoreCourseAnyCourseDataWithOptions,
): CoreCourseOptionsHandlerData | Promise<CoreCourseOptionsHandlerData>;
/**
@ -97,7 +103,7 @@ export interface CoreCourseOptionsMenuHandler extends CoreCourseOptionsHandler {
* @return Data or promise resolved with data.
*/
getMenuDisplayData(
course: CoreEnrolledCourseDataWithExtraInfoAndOptions,
course: CoreCourseAnyCourseDataWithOptions,
): CoreCourseOptionsMenuHandlerData | Promise<CoreCourseOptionsMenuHandlerData>;
}
@ -116,15 +122,14 @@ export interface CoreCourseOptionsHandlerData {
class?: string;
/**
* The component to render the handler. It must be the component class, not the name or an instance.
* When the component is created, it will receive the courseId as input.
* Path of the page to load for the handler.
*/
component: Type<unknown>;
page: string;
/**
* Data to pass to the component. All the properties in this object will be passed to the component as inputs.
* Params to pass to the page (other than 'courseId' which is always sent).
*/
componentData?: Record<string | number, unknown>;
pageParams?: Params;
}
/**
@ -142,7 +147,7 @@ export interface CoreCourseOptionsMenuHandlerData {
class?: string;
/**
* Name of the page to load for the handler.
* Path of the page to load for the handler.
*/
page: string;
@ -160,29 +165,19 @@ export interface CoreCourseOptionsMenuHandlerData {
/**
* Data returned by the delegate for each handler.
*/
export interface CoreCourseOptionsHandlerToDisplay {
export interface CoreCourseOptionsHandlerToDisplay extends CoreDelegateToDisplay {
/**
* Data to display.
*/
data: CoreCourseOptionsHandlerData;
/**
* Name of the handler, or name and sub context (AddonMessages, AddonMessages:blockContact, ...).
*/
name: string;
/**
* The highest priority is displayed first.
*/
priority?: number;
/**
* Called when a course is downloaded. It should prefetch all the data to be able to see the addon in offline.
*
* @param course The course.
* @return Promise resolved when done.
*/
prefetch?(course: CoreEnrolledCourseDataWithExtraInfoAndOptions): Promise<void>;
prefetch?(course: CoreCourseAnyCourseData): Promise<void>;
}
/**
@ -210,7 +205,7 @@ export interface CoreCourseOptionsMenuHandlerToDisplay {
* @param course The course.
* @return Promise resolved when done.
*/
prefetch?(course: CoreEnrolledCourseDataWithExtraInfoAndOptions): Promise<void>;
prefetch?(course: CoreCourseAnyCourseData): Promise<void>;
}
/**
@ -226,7 +221,7 @@ export class CoreCourseOptionsDelegateService extends CoreDelegate<CoreCourseOpt
protected coursesHandlers: {
[courseId: number]: {
access: any;
access: CoreCourseAccess;
navOptions?: CoreCourseUserAdminOrNavOptionIndexed;
admOptions?: CoreCourseUserAdminOrNavOptionIndexed;
deferred: PromiseDefer<void>;
@ -320,7 +315,7 @@ export class CoreCourseOptionsDelegateService extends CoreDelegate<CoreCourseOpt
protected async getHandlersForAccess(
courseId: number,
refresh: boolean,
accessData: any,
accessData: CoreCourseAccess,
navOptions?: CoreCourseUserAdminOrNavOptionIndexed,
admOptions?: CoreCourseUserAdminOrNavOptionIndexed,
): Promise<CoreCourseOptionsHandler[]> {
@ -367,7 +362,7 @@ export class CoreCourseOptionsDelegateService extends CoreDelegate<CoreCourseOpt
* @return Promise resolved with array of handlers.
*/
getHandlersToDisplay(
course: CoreEnrolledCourseDataWithExtraInfoAndOptions,
course: CoreCourseAnyCourseData,
refresh = false,
isGuest = false,
navOptions?: CoreCourseUserAdminOrNavOptionIndexed,
@ -389,7 +384,7 @@ export class CoreCourseOptionsDelegateService extends CoreDelegate<CoreCourseOpt
* @return Promise resolved with array of handlers.
*/
getMenuHandlersToDisplay(
course: CoreEnrolledCourseDataWithExtraInfoAndOptions,
course: CoreCourseAnyCourseData,
refresh = false,
isGuest = false,
navOptions?: CoreCourseUserAdminOrNavOptionIndexed,
@ -413,28 +408,31 @@ export class CoreCourseOptionsDelegateService extends CoreDelegate<CoreCourseOpt
*/
protected async getHandlersToDisplayInternal(
menu: boolean,
course: CoreEnrolledCourseDataWithExtraInfoAndOptions,
course: CoreCourseAnyCourseData,
refresh = false,
isGuest = false,
navOptions?: CoreCourseUserAdminOrNavOptionIndexed,
admOptions?: CoreCourseUserAdminOrNavOptionIndexed,
): Promise<CoreCourseOptionsHandlerToDisplay[] | CoreCourseOptionsMenuHandlerToDisplay[]> {
const courseWithOptions: CoreCourseAnyCourseDataWithOptions = course;
const accessData = {
type: isGuest ? CoreCourseProvider.ACCESS_GUEST : CoreCourseProvider.ACCESS_DEFAULT,
};
const handlersToDisplay: CoreCourseOptionsHandlerToDisplay[] | CoreCourseOptionsMenuHandlerToDisplay[] = [];
if (navOptions) {
course.navOptions = navOptions;
courseWithOptions.navOptions = navOptions;
}
if (admOptions) {
course.admOptions = admOptions;
courseWithOptions.admOptions = admOptions;
}
await this.loadCourseOptions(course, refresh);
await this.loadCourseOptions(courseWithOptions, refresh);
// Call getHandlersForAccess to make sure the handlers have been loaded.
await this.getHandlersForAccess(course.id, refresh, accessData, course.navOptions, course.admOptions);
await this.getHandlersForAccess(course.id, refresh, accessData, courseWithOptions.navOptions, courseWithOptions.admOptions);
const promises: Promise<void>[] = [];
let handlerList: CoreCourseOptionsMenuHandler[] | CoreCourseOptionsHandler[];
@ -449,7 +447,7 @@ export class CoreCourseOptionsDelegateService extends CoreDelegate<CoreCourseOpt
? (handler as CoreCourseOptionsMenuHandler).getMenuDisplayData
: (handler as CoreCourseOptionsHandler).getDisplayData;
promises.push(Promise.resolve(getFunction!.call(handler, course)).then((data) => {
promises.push(Promise.resolve(getFunction!.call(handler, courseWithOptions)).then((data) => {
handlersToDisplay.push({
data: data,
priority: handler.priority,
@ -586,7 +584,7 @@ export class CoreCourseOptionsDelegateService extends CoreDelegate<CoreCourseOpt
* @param refresh True if it should refresh the list.
* @return Promise resolved when done.
*/
protected async loadCourseOptions(course: CoreEnrolledCourseDataWithExtraInfoAndOptions, refresh = false): Promise<void> {
protected async loadCourseOptions(course: CoreCourseAnyCourseDataWithOptions, refresh = false): Promise<void> {
if (CoreCourses.instance.canGetAdminAndNavOptions() &&
(typeof course.navOptions == 'undefined' || typeof course.admOptions == 'undefined' || refresh)) {
@ -618,7 +616,7 @@ export class CoreCourseOptionsDelegateService extends CoreDelegate<CoreCourseOpt
*/
async updateHandlersForCourse(
courseId: number,
accessData: any,
accessData: CoreCourseAccess,
navOptions?: CoreCourseUserAdminOrNavOptionIndexed,
admOptions?: CoreCourseUserAdminOrNavOptionIndexed,
): Promise<void> {
@ -673,5 +671,6 @@ export class CoreCourseOptionsDelegateService extends CoreDelegate<CoreCourseOpt
export class CoreCourseOptionsDelegate extends makeSingleton(CoreCourseOptionsDelegateService) {}
// @todo define
export type CoreCourseAccessData = any;
export type CoreCourseAccess = {
type: string; // Either CoreCourseProvider.ACCESS_GUEST or CoreCourseProvider.ACCESS_DEFAULT.
};

View File

@ -23,19 +23,24 @@ import { CoreTimeUtils } from '@services/utils/time';
import { CoreUtils } from '@services/utils/utils';
import { CoreSiteWSPreSets, CoreSite } from '@classes/site';
import { CoreConstants } from '@/core/constants';
import { makeSingleton, Translate } from '@singletons';
import { makeSingleton, Platform, Translate } from '@singletons';
import { CoreStatusWithWarningsWSResponse, CoreWSExternalFile } from '@services/ws';
import { CoreCourseStatusDBRecord, COURSE_STATUS_TABLE } from './database/course';
import { CoreCourseOffline } from './course-offline';
import { CoreError } from '@classes/errors/error';
import {
CoreCourses,
CoreCourseAnyCourseData,
CoreCoursesMyCoursesUpdatedEventData,
CoreCoursesProvider,
} from '../../courses/services/courses';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreWSError } from '@classes/errors/wserror';
import { CorePushNotifications } from '@features/pushnotifications/services/pushnotifications';
import { CoreCourseHelper, CoreCourseModuleCompletionData } from './course-helper';
import { CoreCourseFormatDelegate } from './format-delegate';
import { CoreCronDelegate } from '@services/cron';
import { CoreCourseLogCronHandler } from './handlers/log-cron';
const ROOT_CACHE_KEY = 'mmCourse:';
@ -71,12 +76,30 @@ export class CoreCourseProvider {
protected logger: CoreLogger;
constructor() {
// @todo
// protected courseFormatDelegate: CoreCourseFormatDelegate,
// protected sitePluginsProvider: CoreSitePluginsProvider,
this.logger = CoreLogger.getInstance('CoreCourseProvider');
}
/**
* Initialize.
*/
initialize(): void {
Platform.instance.resume.subscribe(() => {
// Run the handler the app is open to keep user in online status.
setTimeout(() => {
CoreCronDelegate.instance.forceCronHandlerExecution(CoreCourseLogCronHandler.instance.name);
}, 1000);
});
CoreEvents.on(CoreEvents.LOGIN, () => {
setTimeout(() => {
// Ignore errors here, since probably login is not complete: it happens on token invalid.
CoreUtils.instance.ignoreErrors(
CoreCronDelegate.instance.forceCronHandlerExecution(CoreCourseLogCronHandler.instance.name),
);
}, 1000);
});
}
/**
* Check if the get course blocks WS is available in current site.
*
@ -109,9 +132,8 @@ export class CoreCourseProvider {
*
* @param courseId Course ID.
* @param completion Completion status of the module.
* @todo Add completion type.
*/
checkModuleCompletion(courseId: number, completion: any): void {
checkModuleCompletion(courseId: number, completion: CoreCourseModuleCompletionData): void {
if (completion && completion.tracking === 2 && completion.state === 0) {
this.invalidateSections(courseId).finally(() => {
CoreEvents.trigger(CoreEvents.COMPLETION_MODULE_VIEWED, { courseId: courseId });
@ -134,9 +156,8 @@ export class CoreCourseProvider {
}
/**
* Check if the current view in a NavController is a certain course initial page.
* Check if the current view is a certain course initial page.
*
* @param navCtrl NavController.
* @param courseId Course ID.
* @return Whether the current view is a certain course.
*/
@ -346,7 +367,7 @@ export class CoreCourseProvider {
ignoreCache: boolean = false,
siteId?: string,
modName?: string,
): Promise<CoreCourseModuleData> {
): Promise<CoreCourseWSModule> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
// Helper function to do the WS request without processing the result.
@ -356,7 +377,7 @@ export class CoreCourseProvider {
modName: string | undefined,
includeStealth: boolean,
preferCache: boolean,
): Promise<CoreCourseSection[]> => {
): Promise<CoreCourseWSSection[]> => {
const params: CoreCourseGetContentsParams = {
courseid: courseId!,
options: [],
@ -394,7 +415,7 @@ export class CoreCourseProvider {
}
try {
const sections: CoreCourseSection[] = await site.read('core_course_get_contents', params, preSets);
const sections = await site.read<CoreCourseWSSection[]>('core_course_get_contents', params, preSets);
return sections;
} catch {
@ -419,7 +440,7 @@ export class CoreCourseProvider {
courseId = module.course;
}
let sections: CoreCourseSection[];
let sections: CoreCourseWSSection[];
try {
const site = await CoreSites.instance.getSite(siteId);
// We have courseId, we can use core_course_get_contents for compatibility.
@ -440,7 +461,7 @@ export class CoreCourseProvider {
sections = await this.getSections(courseId, false, false, preSets, siteId);
}
let foundModule: CoreCourseModuleData | undefined;
let foundModule: CoreCourseWSModule | undefined;
const foundSection = sections.some((section) => {
if (sectionId != null &&
@ -637,7 +658,7 @@ export class CoreCourseProvider {
excludeModules?: boolean,
excludeContents?: boolean,
siteId?: string,
): Promise<CoreCourseSection> {
): Promise<CoreCourseWSSection> {
if (sectionId < 0) {
throw new CoreError('Invalid section ID');
@ -671,7 +692,7 @@ export class CoreCourseProvider {
preSets?: CoreSiteWSPreSets,
siteId?: string,
includeStealthModules: boolean = true,
): Promise<CoreCourseSection[]> {
): Promise<CoreCourseWSSection[]> {
const site = await CoreSites.instance.getSite(siteId);
preSets = preSets || {};
@ -698,7 +719,7 @@ export class CoreCourseProvider {
});
}
let sections: CoreCourseSection[];
let sections: CoreCourseWSSection[];
try {
sections = await site.read('core_course_get_contents', params, preSets);
} catch {
@ -740,12 +761,12 @@ export class CoreCourseProvider {
* @param sections Sections.
* @return Modules.
*/
getSectionsModules(sections: CoreCourseSection[]): CoreCourseModuleData[] {
getSectionsModules(sections: CoreCourseWSSection[]): CoreCourseWSModule[] {
if (!sections || !sections.length) {
return [];
}
return sections.reduce((previous: CoreCourseModuleData[], section) => previous.concat(section.modules || []), []);
return sections.reduce((previous: CoreCourseWSModule[], section) => previous.concat(section.modules || []), []);
}
/**
@ -830,7 +851,7 @@ export class CoreCourseProvider {
* @return Promise resolved when loaded.
*/
async loadModuleContents(
module: CoreCourseModuleData & CoreCourseModuleBasicInfo,
module: CoreCourseWSModule,
courseId?: number,
sectionId?: number,
preferCache?: boolean,
@ -856,7 +877,6 @@ export class CoreCourseProvider {
* @param siteId Site ID. If not defined, current site.
* @param name Name of the course.
* @return Promise resolved when the WS call is successful.
* @todo use logHelper. Remove eslint disable when done.
*/
async logView(courseId: number, sectionNumber?: number, siteId?: string, name?: string): Promise<void> {
const params: CoreCourseViewCourseWSParams = {
@ -875,7 +895,7 @@ export class CoreCourseProvider {
if (!response.status) {
throw Error('WS core_course_view_course failed.');
} else {
CoreEvents.trigger(CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, {
CoreEvents.trigger<CoreCoursesMyCoursesUpdatedEventData>(CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, {
courseId: courseId,
action: CoreCoursesProvider.ACTION_VIEW,
}, site.getId());
@ -954,7 +974,20 @@ export class CoreCourseProvider {
completed: completed,
};
return site.write('core_completion_update_activity_completion_status_manually', params);
const result = await site.write<CoreStatusWithWarningsWSResponse>(
'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;
}
/**
@ -963,7 +996,7 @@ export class CoreCourseProvider {
* @param module The module object.
* @return Whether the module has a view page.
*/
moduleHasView(module: CoreCourseModuleSummary | CoreCourseModuleData): boolean {
moduleHasView(module: CoreCourseModuleSummary | CoreCourseWSModule): boolean {
return !!module.url;
}
@ -981,63 +1014,42 @@ export class CoreCourseProvider {
* @param params Other params to pass to the course page.
* @return Promise resolved when done.
*/
async openCourse(
course: { id: number ; format?: string },
params?: Params, // eslint-disable-line @typescript-eslint/no-unused-vars
): Promise<void> {
// @todo const loading = await CoreDomUtils.instance.showModalLoading();
async openCourse(course: CoreCourseAnyCourseData | { id: number }, params?: Params): Promise<void> {
const loading = await CoreDomUtils.instance.showModalLoading();
// Wait for site plugins to be fetched.
// @todo await this.sitePluginsProvider.waitFetchPlugins();
if (typeof course.format == 'undefined') {
// This block can be replaced by a call to CourseHelper.getCourse(), but it is circular dependant.
const coursesProvider = CoreCourses.instance;
try {
course = await coursesProvider.getUserCourse(course.id, true);
} catch (error) {
// Not enrolled or an error happened. Try to use another WebService.
const available = coursesProvider.isGetCoursesByFieldAvailableInSite();
try {
if (available) {
course = await coursesProvider.getCourseByField('id', course.id);
} else {
course = await coursesProvider.getCourse(course.id);
}
} catch (error) {
// Ignore errors.
}
}
if (!('format' in course) || typeof course.format == 'undefined') {
const result = await CoreCourseHelper.instance.getCourse(course.id);
course = result.course;
}
/* @todo
if (!this.sitePluginsProvider.sitePluginPromiseExists('format_' + course.format)) {
if (course) { // @todo Replace with: if (!this.sitePluginsProvider.sitePluginPromiseExists('format_' + course.format)) {
// No custom format plugin. We don't need to wait for anything.
await this.courseFormatDelegate.openCourse(course, params);
await CoreCourseFormatDelegate.instance.openCourse(<CoreCourseAnyCourseData> course, params);
loading.dismiss();
return;
} */
}
// This course uses a custom format plugin, wait for the format plugin to finish loading.
try {
/* @todo await this.sitePluginsProvider.sitePluginLoaded('format_' + course.format);
// The format loaded successfully, but the handlers wont be registered until all site plugins have loaded.
if (this.sitePluginsProvider.sitePluginsFinishedLoading) {
return this.courseFormatDelegate.openCourse(course, params);
return CoreCourseFormatDelegate.instance.openCourse(course, params);
}*/
// Wait for plugins to be loaded.
const deferred = CoreUtils.instance.promiseDefer<void>();
const observer = CoreEvents.on(CoreEvents.SITE_PLUGINS_LOADED, () => {
observer && observer.off();
observer?.off();
/* @todo this.courseFormatDelegate.openCourse(course, params).then((response) => {
deferred.resolve(response);
}).catch((error) => {
deferred.reject(error);
});*/
CoreCourseFormatDelegate.instance.openCourse(<CoreCourseAnyCourseData> course, params)
.then(deferred.resolve).catch(deferred.reject);
});
return deferred.promise;
@ -1334,7 +1346,7 @@ export type CoreCourseGetContentsParams = {
/**
* Data returned by core_course_get_contents WS.
*/
export type CoreCourseSection = {
export type CoreCourseWSSection = {
id: number; // Section ID.
name: string; // Section name.
visible?: number; // Is the section visible.
@ -1344,7 +1356,7 @@ export type CoreCourseSection = {
hiddenbynumsections?: number; // Whether is a section hidden in the course format.
uservisible?: boolean; // Is the section visible for the user?.
availabilityinfo?: string; // Availability information.
modules: CoreCourseModuleData[];
modules: CoreCourseWSModule[];
};
/**
@ -1371,9 +1383,9 @@ export type CoreCourseGetCourseModuleWSResponse = {
};
/**
* Course module type.
* Course module data returned by the WS.
*/
export type CoreCourseModuleData = { // List of module.
export type CoreCourseWSModule = {
id: number; // Activity id.
course?: number; // The course id.
url?: string; // Activity url.
@ -1395,12 +1407,7 @@ export type CoreCourseModuleData = { // List of module.
customdata?: string; // Custom data (JSON encoded).
noviewlink?: boolean; // Whether the module has no view page.
completion?: number; // Type of completion tracking: 0 means none, 1 manual, 2 automatic.
completiondata?: { // Module completion data.
state: number; // Completion state value: 0 means incomplete, 1 complete, 2 complete pass, 3 complete fail.
timecompleted: number; // Timestamp for completion status.
overrideby: number; // The user id who has overriden the status.
valueused?: boolean; // Whether the completion status affects the availability of another activity.
};
completiondata?: CoreCourseModuleWSCompletionData; // Module completion data.
contents: CoreCourseModuleContentFile[];
contentsinfo?: { // Contents summary information.
filescount: number; // Total number of files.
@ -1411,19 +1418,28 @@ export type CoreCourseModuleData = { // List of module.
};
};
/**
* Module completion data.
*/
export type CoreCourseModuleWSCompletionData = {
state: number; // Completion state value: 0 means incomplete, 1 complete, 2 complete pass, 3 complete fail.
timecompleted: number; // Timestamp for completion status.
overrideby: number; // The user id who has overriden the status.
valueused?: boolean; // Whether the completion status affects the availability of another activity.
};
export type CoreCourseModuleContentFile = {
type: string; // A file or a folder or external link.
filename: string; // Filename.
filepath: string; // Filepath.
filesize: number; // Filesize.
fileurl?: string; // Downloadable file url.
url?: string; // @deprecated. Use fileurl instead.
fileurl: string; // Downloadable file url.
content?: string; // Raw content, will be used when type is content.
timecreated: number; // Time created.
timemodified: number; // Time modified.
sortorder: number; // Content sort order.
mimetype?: string; // File mime type.
isexternalfile?: boolean; // Whether is an external file.
isexternalfile?: number; // Whether is an external file.
repositorytype?: string; // The repository type for external files.
userid: number; // User who added this content to moodle.
author: string; // Content owner.

View File

@ -0,0 +1,60 @@
// (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 { CoreSiteSchema } from '@services/sites';
/**
* Database variables for CoreCourse service.
*/
export const ACTIVITY_LOG_TABLE = 'course_activity_log';
export const SITE_SCHEMA: CoreSiteSchema = {
name: 'CoreCourseLogHelperProvider',
version: 1,
tables: [
{
name: ACTIVITY_LOG_TABLE,
columns: [
{
name: 'component',
type: 'TEXT',
},
{
name: 'componentid',
type: 'INTEGER',
},
{
name: 'ws',
type: 'TEXT',
},
{
name: 'data',
type: 'TEXT',
},
{
name: 'time',
type: 'INTEGER',
},
],
primaryKeys: ['component', 'componentid', 'ws', 'time'],
},
],
};
export type CoreCourseActivityLogDBRecord = {
component: string;
componentid: number;
ws: string;
time: number;
data?: string;
};

Some files were not shown because too many files have changed in this diff Show More