Merge pull request #2610 from crazyserver/MOBILE-3594

Mobile 3594
main
Dani Palou 2020-11-23 10:24:27 +01:00 committed by GitHub
commit e070c7864e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
103 changed files with 9441 additions and 121 deletions

10
package-lock.json generated
View File

@ -390,6 +390,14 @@
}
}
},
"@angular/animations": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-11.0.1.tgz",
"integrity": "sha512-RS2ZsO3yidn/dMAllR+V0EX5BOQLQDi5s2kvd4wANHYAkU/yVXWKl09nbe8LTwLVH+iOYX7AAcAUUokQPEEHxQ==",
"requires": {
"tslib": "^2.0.0"
}
},
"@angular/cli": {
"version": "10.0.8",
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-10.0.8.tgz",
@ -6514,7 +6522,7 @@
"integrity": "sha512-EYC5eQFVkoYXq39l7tYKE6lEjHJ04mvTmKXxGL7quHLdFPfJMNzru/UYpn92AOfpl3PQaZmou78C7EgmFOwFQQ=="
},
"cordova-plugin-wkuserscript": {
"version": "git+https://github.com/moodlemobile/cordova-plugin-wkuserscript.git#aa77d0f98a3fb106f2e798e5adf5882f01a2c947",
"version": "git+https://github.com/moodlemobile/cordova-plugin-wkuserscript.git#6413f4bb3c2565f353e690b5c1450b69ad9e860e",
"from": "git+https://github.com/moodlemobile/cordova-plugin-wkuserscript.git"
},
"cordova-plugin-wkwebview-cookies": {

View File

@ -32,6 +32,7 @@
"ionic:build:before": "npx gulp"
},
"dependencies": {
"@angular/animations": "^11.0.1",
"@angular/common": "~10.0.0",
"@angular/core": "~10.0.0",
"@angular/forms": "~10.0.0",

View File

@ -49,7 +49,7 @@
<!-- Upload a private file. -->
<ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end" *ngIf="showUpload && root != 'site' && !path">
<ion-fab-button (click)="uploadFile()" [attr.aria-label]="'core.fileuploader.uploadafile' | translate">
<ion-icon name="fa-plus"></ion-icon>
<ion-icon name="fas-plus"></ion-icon>
</ion-fab-button>
</ion-fab>
</ion-content>

View File

@ -20,6 +20,8 @@ import { CoreWSExternalWarning } from '@services/ws';
import { CoreSite } from '@classes/site';
import { makeSingleton } from '@singletons/core.singletons';
const ROOT_CACHE_KEY = 'mmaFiles:';
/**
* Service to handle my files and site files.
*/
@ -32,8 +34,6 @@ export class AddonPrivateFilesProvider {
static readonly PRIVATE_FILES_COMPONENT = 'mmaFilesMy';
static readonly SITE_FILES_COMPONENT = 'mmaFilesSite';
protected readonly ROOT_CACHE_KEY = 'mmaFiles:';
/**
* Check if core_user_get_private_files_info WS call is available.
*
@ -125,7 +125,7 @@ export class AddonPrivateFilesProvider {
protected getFilesListCacheKey(params: AddonPrivateFilesGetFilesWSParams): string {
const root = !params.component ? 'site' : 'my';
return this.ROOT_CACHE_KEY + 'list:' + root + ':' + params.contextid + ':' + params.filepath;
return ROOT_CACHE_KEY + 'list:' + root + ':' + params.contextid + ':' + params.filepath;
}
/**
@ -194,7 +194,7 @@ export class AddonPrivateFilesProvider {
* @return Cache key.
*/
protected getPrivateFilesInfoCommonCacheKey(): string {
return this.ROOT_CACHE_KEY + 'privateInfo';
return ROOT_CACHE_KEY + 'privateInfo';
}
/**
@ -474,7 +474,7 @@ export type AddonPrivateFilesGetFilesWSResult = {
/**
* Params of core_user_get_private_files_info WS.
*/
export type AddonPrivateFilesGetUserInfoWSParams = {
type AddonPrivateFilesGetUserInfoWSParams = {
userid?: number; // Id of the user, default to current user.
};
@ -492,6 +492,6 @@ export type AddonPrivateFilesGetUserInfoWSResult = {
/**
* Params of core_user_add_user_private_files WS.
*/
export type AddonPrivateFilesAddUserPrivateFilesWSParams = {
type AddonPrivateFilesAddUserPrivateFilesWSParams = {
draftid: number; // Draft area id.
};

View File

@ -14,6 +14,7 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { RouteReuseStrategy } from '@angular/router';
import { HttpClient, HttpClientModule } from '@angular/common/http';
@ -38,6 +39,7 @@ export function createTranslateLoader(http: HttpClient): TranslateHttpLoader {
entryComponents: [],
imports: [
BrowserModule,
BrowserAnimationsModule,
IonicModule.forRoot(),
HttpClientModule, // HttpClient is used to make JSON requests. It fails for HEAD requests because there is no content.
TranslateModule.forRoot({

View File

@ -0,0 +1,65 @@
// (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 { trigger, style, transition, animate, keyframes } from '@angular/animations';
export const coreShowHideAnimation = trigger('coreShowHideAnimation', [
transition(':enter', [
style({ opacity: 0 }),
animate('500ms ease-in-out', style({ opacity: 1 })),
]),
transition(':leave', [
style({ opacity: 1 }),
animate('500ms ease-in-out', style({ opacity: 0 })),
]),
]);
export const coreSlideInOut = trigger('coreSlideInOut', [
// Enter animation.
transition('void => fromLeft', [
style({ transform: 'translateX(0)', opacity: 1 }),
animate(300, keyframes([
style({ opacity: 0, transform: 'translateX(-100%)', offset: 0 }),
style({ opacity: 1, transform: 'translateX(5%)', offset: 0.7 }),
style({ opacity: 1, transform: 'translateX(0)', offset: 1.0 }),
])),
]),
// Leave animation.
transition('fromLeft => void', [
style({ transform: 'translateX(-100%)', opacity: 0 }),
animate(300, keyframes([
style({ opacity: 1, transform: 'translateX(0)', offset: 0 }),
style({ opacity: 1, transform: 'translateX(5%)', offset: 0.3 }),
style({ opacity: 0, transform: 'translateX(-100%)', offset: 1.0 }),
])),
]),
// Enter animation.
transition('void => fromRight', [
style({ transform: 'translateX(0)', opacity: 1 }),
animate(300, keyframes([
style({ opacity: 0, transform: 'translateX(100%)', offset: 0 }),
style({ opacity: 1, transform: 'translateX(-5%)', offset: 0.7 }),
style({ opacity: 1, transform: 'translateX(0)', offset: 1.0 }),
])),
]),
// Leave animation.
transition('fromRight => void', [
style({ transform: 'translateX(-100%)', opacity: 0 }),
animate(300, keyframes([
style({ opacity: 1, transform: 'translateX(0)', offset: 0 }),
style({ opacity: 1, transform: 'translateX(-5%)', offset: 0.3 }),
style({ opacity: 0, transform: 'translateX(100%)', offset: 1.0 }),
])),
]),
]);

View File

@ -30,9 +30,15 @@ import { CoreRecaptchaModalComponent } from './recaptcha/recaptchamodal';
import { CoreShowPasswordComponent } from './show-password/show-password';
import { CoreEmptyBoxComponent } from './empty-box/empty-box';
import { CoreTabsComponent } from './tabs/tabs';
import { CoreInfiniteLoadingComponent } from './infinite-loading/infinite-loading';
import { CoreProgressBarComponent } from './progress-bar/progress-bar';
import { CoreContextMenuComponent } from './context-menu/context-menu';
import { CoreContextMenuItemComponent } from './context-menu/context-menu-item';
import { CoreContextMenuPopoverComponent } from './context-menu/context-menu-popover';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CorePipesModule } from '@pipes/pipes.module';
import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons';
@NgModule({
declarations: [
@ -49,6 +55,12 @@ import { CorePipesModule } from '@pipes/pipes.module';
CoreShowPasswordComponent,
CoreEmptyBoxComponent,
CoreTabsComponent,
CoreInfiniteLoadingComponent,
CoreProgressBarComponent,
CoreContextMenuComponent,
CoreContextMenuItemComponent,
CoreContextMenuPopoverComponent,
CoreNavBarButtonsComponent,
],
imports: [
CommonModule,
@ -71,6 +83,12 @@ import { CorePipesModule } from '@pipes/pipes.module';
CoreShowPasswordComponent,
CoreEmptyBoxComponent,
CoreTabsComponent,
CoreInfiniteLoadingComponent,
CoreProgressBarComponent,
CoreContextMenuComponent,
CoreContextMenuItemComponent,
CoreContextMenuPopoverComponent,
CoreNavBarButtonsComponent,
],
})
export class CoreComponentsModule {}

View File

@ -0,0 +1,106 @@
// (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, OnInit, OnDestroy, EventEmitter, OnChanges, SimpleChange } from '@angular/core';
import { CoreContextMenuComponent } from './context-menu';
/**
* This directive adds a item to the Context Menu popover.
*
* @description
* This directive defines and item to be added to the popover generated in CoreContextMenu.
*
* It is required to place this tag inside a core-context-menu tag.
*
* <core-context-menu>
* <core-context-menu-item [hidden]="showGrid" [priority]="601" [content]="'core.layoutgrid' | translate"
* (action)="switchGrid()" [iconAction]="'apps'"></core-context-menu-item>
* </core-context-menu>
*/
@Component({
selector: 'core-context-menu-item',
template: '',
})
export class CoreContextMenuItemComponent implements OnInit, OnDestroy, OnChanges {
@Input() content?: string; // Content of the item.
@Input() iconDescription?: string; // Name of the icon to be shown on the left side of the item.
@Input() iconAction?: string; // Name of the icon to show on the right side of the item. Represents the action to do on click.
// If is "spinner" an spinner will be shown.
// If no icon or spinner is selected, no action or link will work.
// If href but no iconAction is provided arrow-right will be used.
@Input() iconSlash?: boolean; // Display a red slash over the icon.
@Input() ariaDescription?: string; // Aria label to add to iconDescription.
@Input() ariaAction?: string; // Aria label to add to iconAction. If not set, it will be equal to content.
@Input() href?: string; // Link to go if no action provided.
@Input() captureLink?: boolean | string; // Whether the link needs to be captured by the app.
@Input() autoLogin?: string; // Whether the link needs to be opened using auto-login.
@Input() closeOnClick = true; // Whether to close the popover when the item is clicked.
@Input() priority?: number; // Used to sort items. The highest priority, the highest position.
@Input() badge?: string; // A badge to show in the item.
@Input() badgeClass?: number; // A class to set in the badge.
@Input() hidden?: boolean; // Whether the item should be hidden.
@Output() action?: EventEmitter<() => void>; // Will emit an event when the item clicked.
@Output() onClosed?: EventEmitter<() => void>; // Will emit an event when the popover is closed because the item was clicked.
protected hasAction = false;
protected destroyed = false;
constructor(
protected ctxtMenu: CoreContextMenuComponent,
) {
this.action = new EventEmitter();
this.onClosed = new EventEmitter();
}
/**
* Component being initialized.
*/
ngOnInit(): void {
// Initialize values.
this.priority = this.priority || 1;
this.hasAction = !!this.action && this.action.observers.length > 0;
this.ariaAction = this.ariaAction || this.content;
if (this.hasAction) {
this.href = '';
}
// Navigation help if href provided.
this.captureLink = this.href && this.captureLink ? this.captureLink : false;
this.autoLogin = this.autoLogin || 'check';
if (!this.destroyed) {
this.ctxtMenu.addItem(this);
}
}
/**
* Component destroyed.
*/
ngOnDestroy(): void {
this.destroyed = true;
this.ctxtMenu.removeItem(this);
}
/**
* Detect changes on input properties.
*/
ngOnChanges(changes: { [name: string]: SimpleChange }): void {
if (changes.hidden && !changes.hidden.firstChange) {
this.ctxtMenu.itemsChanged();
}
}
}

View File

@ -0,0 +1,5 @@
:host {
ion-list {
padding: 0;
}
}

View File

@ -0,0 +1,77 @@
// (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 } from '@angular/core';
import { NavParams, PopoverController } from '@ionic/angular';
import { CoreContextMenuItemComponent } from './context-menu-item';
/**
* Component to display a list of items received by param in a popover.
*/
@Component({
selector: 'core-context-menu-popover',
templateUrl: 'core-context-menu-popover.html',
styleUrls: ['context-menu-popover.scss'],
})
export class CoreContextMenuPopoverComponent {
title: string;
uniqueId: string;
items: CoreContextMenuItemComponent[];
constructor(
navParams: NavParams,
protected popoverCtrl: PopoverController,
) {
this.title = navParams.get('title');
this.items = navParams.get('items') || [];
this.uniqueId = navParams.get('id');
}
/**
* Close the popover.
*/
closeMenu(item?: CoreContextMenuItemComponent): void {
this.popoverCtrl.dismiss(item);
}
/**
* Function called when an item is clicked.
*
* @param event Click event.
* @param item Item clicked.
* @return Return true if success, false if error.
*/
itemClicked(event: Event, item: CoreContextMenuItemComponent): boolean {
if (!!item.action && item.action.observers.length > 0) {
event.preventDefault();
event.stopPropagation();
if (item.iconAction == 'spinner') {
return false;
}
if (item.closeOnClick) {
this.closeMenu(item);
}
item.action.emit(this.closeMenu.bind(this, item));
} else if (item.closeOnClick && (item.href || (!!item.onClosed && item.onClosed.observers.length > 0))) {
this.closeMenu(item);
}
return true;
}
}

View File

@ -0,0 +1,213 @@
// (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, OnDestroy, ElementRef } from '@angular/core';
import { Subject } from 'rxjs';
import { auditTime } from 'rxjs/operators';
import { PopoverController } from '@ionic/angular';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { Translate } from '@singletons/core.singletons';
import { CoreContextMenuItemComponent } from './context-menu-item';
import { CoreContextMenuPopoverComponent } from './context-menu-popover';
/**
* This component adds a button (usually in the navigation bar) that displays a context menu popover.
*/
@Component({
selector: 'core-context-menu',
templateUrl: 'core-context-menu.html',
})
export class CoreContextMenuComponent implements OnInit, OnDestroy {
@Input() icon?: string; // Icon to be shown on the navigation bar. Default: Kebab menu icon.
@Input() title?: string; // Text to be shown on the top of the popover.
@Input('aria-label') ariaLabel?: string; // Aria label to be shown on the top of the popover.
hideMenu = true; // It will be unhidden when items are added.
expanded = false;
protected items: CoreContextMenuItemComponent[] = [];
protected itemsMovedToParent: CoreContextMenuItemComponent[] = [];
protected itemsChangedStream: Subject<void>; // Stream to update the hideMenu boolean when items change.
protected instanceId: string;
protected parentContextMenu?: CoreContextMenuComponent;
protected uniqueId: string;
constructor(
protected popoverCtrl: PopoverController,
elementRef: ElementRef,
) {
// Create the stream and subscribe to it. We ignore successive changes during 250ms.
this.itemsChangedStream = new Subject<void>();
this.itemsChangedStream.pipe(auditTime(250));
this.itemsChangedStream.subscribe(() => {
// Hide the menu if all items are hidden.
this.hideMenu = !this.items.some((item) => !item.hidden);
// Sort the items by priority.
this.items.sort((a, b) => (a.priority || 0) <= (b.priority || 0) ? 1 : -1);
});
// Calculate the unique ID.
this.uniqueId = 'core-context-menu-' + CoreUtils.instance.getUniqueId('CoreContextMenuComponent');
this.instanceId = CoreDomUtils.instance.storeInstanceByElement(elementRef.nativeElement, this);
}
/**
* Component being initialized.
*/
ngOnInit(): void {
this.icon = this.icon || 'ellipsis-vertical';
this.ariaLabel = this.ariaLabel || this.title || Translate.instance.instant('core.displayoptions');
}
/**
* Add a context menu item.
*
* @param item The item to add.
*/
addItem(item: CoreContextMenuItemComponent): void {
if (this.parentContextMenu) {
// All items were moved to the "parent" menu. Add the item in there.
this.parentContextMenu.addItem(item);
if (this.itemsMovedToParent.indexOf(item) == -1) {
this.itemsMovedToParent.push(item);
}
} else if (this.items.indexOf(item) == -1) {
this.items.push(item);
this.itemsChanged();
}
}
/**
* Function called when the items change.
*/
itemsChanged(): void {
if (this.parentContextMenu) {
// All items were moved to the "parent" menu, call the function in there.
this.parentContextMenu.itemsChanged();
} else {
this.itemsChangedStream.next();
}
}
/**
* Merge the current context menu with the one passed as parameter. All the items in this menu will be moved to the
* one passed as parameter.
*
* @param contextMenu The context menu where to move the items.
*/
mergeContextMenus(contextMenu: CoreContextMenuComponent): void {
this.parentContextMenu = contextMenu;
// Add all the items to the other menu.
for (let i = 0; i < this.items.length; i++) {
const item = this.items[i];
contextMenu.addItem(item);
this.itemsMovedToParent.push(item);
}
// Remove all items from the current menu.
this.items = [];
this.itemsChanged();
}
/**
* Remove an item from the context menu.
*
* @param item The item to remove.
*/
removeItem(item: CoreContextMenuItemComponent): void {
if (this.parentContextMenu) {
// All items were moved to the "parent" menu. Remove the item from there.
this.parentContextMenu.removeItem(item);
const index = this.itemsMovedToParent.indexOf(item);
if (index >= 0) {
this.itemsMovedToParent.splice(index, 1);
}
} else {
const index = this.items.indexOf(item);
if (index >= 0) {
this.items.splice(index, 1);
}
this.itemsChanged();
}
}
/**
* Remove the items that were merged to a parent context menu.
*/
removeMergedItems(): void {
if (this.parentContextMenu) {
for (let i = 0; i < this.itemsMovedToParent.length; i++) {
this.parentContextMenu.removeItem(this.itemsMovedToParent[i]);
}
}
}
/**
* Restore the items that were merged to a parent context menu.
*/
restoreMergedItems(): void {
if (this.parentContextMenu) {
for (let i = 0; i < this.itemsMovedToParent.length; i++) {
this.parentContextMenu.addItem(this.itemsMovedToParent[i]);
}
}
}
/**
* Show the context menu.
*
* @param event Event.
*/
async showContextMenu(event: MouseEvent): Promise<void> {
if (!this.expanded) {
const popover = await this.popoverCtrl.create(
{
event,
component: CoreContextMenuPopoverComponent,
componentProps: {
title: this.title,
items: this.items,
},
showBackdrop: true,
id: this.uniqueId,
},
);
await popover.present();
this.expanded = true;
const data = await popover.onDidDismiss<CoreContextMenuItemComponent>();
this.expanded = false;
if (data.data) {
data.data.onClosed?.emit();
}
}
}
/**
* Component destroyed.
*/
ngOnDestroy(): void {
CoreDomUtils.instance.removeInstanceById(this.instanceId);
this.removeMergedItems();
}
}

View File

@ -0,0 +1,15 @@
<ion-list [id]="uniqueId" role="menu">
<ion-list-header *ngIf="title">{{title}}</ion-list-header>
<ion-item class="ion-text-wrap" *ngFor="let item of items" core-link [capture]="item.captureLink" [autoLogin]="item.autoLogin"
[href]="item.href" (click)="itemClicked($event, item)" [attr.aria-label]="item.ariaAction" [hidden]="item.hidden"
[detail]="(item.href && !item.iconAction) || null" role="menuitem">
<ion-icon *ngIf="item.iconDescription" [name]="item.iconDescription" [attr.aria-label]="item.ariaDescription" slot="start">
</ion-icon>
<core-format-text [clean]="true" [text]="item.content" [filter]="false"></core-format-text>
<ion-icon *ngIf="(item.href || item.action) && item.iconAction && item.iconAction != 'spinner'" [name]="item.iconAction"
[class.icon-slash]="item.iconSlash" slot="end">
</ion-icon>
<ion-spinner *ngIf="(item.href || item.action) && item.iconAction == 'spinner'" slot="end"></ion-spinner>
<ion-badge class="{{item.badgeClass}}" slot="end" *ngIf="item.badge">{{item.badge}}</ion-badge>
</ion-item>
</ion-list>

View File

@ -0,0 +1,6 @@
<ion-button [hidden]="hideMenu" fill="clear" [attr.aria-label]="ariaLabel"
(click)="showContextMenu($event)" aria-haspopup="true" [attr.aria-expanded]="expanded" [attr.aria-controls]="uniqueId">
<ion-icon [name]="icon" slot="icon-only">
</ion-icon>
</ion-button>
<ng-content></ng-content>

View File

@ -1,20 +1,25 @@
<ng-container *ngIf="enabled && !(loading || status === statusDownloading)">
<ng-container *ngIf="enabled && !loading">
<!-- Download button. -->
<ion-button *ngIf="status == statusNotDownloaded" fill="clear" (click)="download($event, false)" color="dark"
class="core-animate-show-hide" [attr.aria-label]="'core.download' | translate">
[@coreShowHideAnimation] [attr.aria-label]="(statusTranslatable || 'core.download') | translate">
<ion-icon slot="icon-only" name="cloud-download"></ion-icon>
</ion-button>
<!-- Refresh button. -->
<ion-button *ngIf="status == statusOutdated || (status == statusDownloaded && !canTrustDownload)" fill="clear"
(click)="download($event, true)" color="dark" class="core-animate-show-hide" [attr.aria-label]="'core.refresh' | translate">
<ion-icon slot="icon-only" name="fas-sync"></ion-icon>
(click)="download($event, true)" color="dark" [@coreShowHideAnimation]
attr.aria-label]="(statusTranslatable || 'core.refresh') | translate">
<ion-icon slot="icon-only" name="fas-redo-alt"></ion-icon>
</ion-button>
<!-- Downloaded status icon. -->
<ion-icon *ngIf="status == statusDownloaded && canTrustDownload" class="core-icon-downloaded ion-padding-horizontal" color="success"
name="cloud-done" [attr.aria-label]="'core.downloaded' | translate" role="status"></ion-icon>
<ion-icon *ngIf="status == statusDownloaded && canTrustDownload" class="core-icon-downloaded ion-padding-horizontal"
color="success" name="cloud-done" [attr.aria-label]="(statusTranslatable || 'core.downloaded') | translate"
role="status"></ion-icon>
<ion-spinner *ngIf="status === statusDownloading" [@coreShowHideAnimation]
[attr.aria-label]="(statusTranslatable || 'core.downloading') | translate"></ion-spinner>
</ng-container>
<!-- Spinner. -->
<ion-spinner *ngIf="loading || status === statusDownloading" class="core-animate-show-hide"></ion-spinner>
<ion-spinner *ngIf="loading" [@coreShowHideAnimation] [attr.aria-label]="'core.loading' | translate"></ion-spinner>

View File

@ -14,21 +14,24 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CoreConstants } from '@/core/constants';
import { coreShowHideAnimation } from '@classes/animations';
/**
* Component to show a download button with refresh option, the spinner and the status of it.
*
* Usage:
* <core-download-refresh [status]="status" enabled="true" canCheckUpdates="true" action="download()"></core-download-refresh>
* <core-download-refresh [status]="status" enabled="true" canTrustDownload="true" action="download()"></core-download-refresh>
*/
@Component({
selector: 'core-download-refresh',
templateUrl: 'core-download-refresh.html',
styleUrls: ['download-refresh.scss'],
animations: [coreShowHideAnimation],
})
export class CoreDownloadRefreshComponent {
@Input() status?: string; // Download status.
@Input() statusTranslatable?: string; // Download status translatable string.
@Input() enabled = false; // Whether the download is enabled.
@Input() loading = true; // Force loading status when is not downloading.
@Input() canTrustDownload = false; // If false, refresh will be shown if downloaded.

View File

@ -21,7 +21,7 @@ import { Component, Input, OnChanges, ElementRef, SimpleChange } from '@angular/
*
* Check available icons at https://fontawesome.com/icons?d=gallery&m=free
*
* @deprecated since 3.9.3. Please use <ion-icon name="fa-icon"> instead.
* @deprecated since 3.9.3. Please use <ion-icon name="fas-icon"> instead.
*/
@Component({
selector: 'core-icon',

View File

@ -0,0 +1,29 @@
<ng-container *ngIf="!loadingMore && position != 'top'">
<div *ngIf="enabled || error" class="ion-padding-horizontal" #bottombutton>
<ion-button *ngIf="!error" expand="block" (click)="loadMore()" color="light">
{{ 'core.loadmore' | translate }}
</ion-button>
<ion-button *ngIf="error" expand="block" (click)="loadMore()" color="light">
{{ 'core.tryagain' | translate }}
</ion-button>
</div>
</ng-container>
<ion-infinite-scroll [disabled]="!enabled || error || loadingMore" (ionInfinite)="loadMore($event)" [position]="position">
<ion-infinite-scroll-content></ion-infinite-scroll-content>
</ion-infinite-scroll>
<ng-container *ngIf="!loadingMore && position == 'top'">
<div *ngIf="enabled || error" class="ion-padding-horizontal" #topbutton>
<ion-button *ngIf="!error" expand="block" (click)="loadMore()" color="light">
{{ 'core.loadmore' | translate }}
</ion-button>
<ion-button *ngIf="error" expand="block" (click)="loadMore()" color="light">
{{ 'core.tryagain' | translate }}
</ion-button>
</div>
</ng-container>
<div *ngIf="loadingMore" class="ion-padding ion-text-center" #spinnercontainer>
<ion-spinner></ion-spinner>
</div>

View File

@ -0,0 +1,158 @@
// (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, Optional, ViewChild, ElementRef } from '@angular/core';
import { IonContent, IonInfiniteScroll } from '@ionic/angular';
import { CoreDomUtils } from '@services/utils/dom';
/**
* Component to show a infinite loading trigger and spinner while more data is being loaded.
*
* Usage:
* <core-infinite-loading [action]="loadingAction" [enabled]="dataLoaded"></core-inifinite-loading>
*/
@Component({
selector: 'core-infinite-loading',
templateUrl: 'core-infinite-loading.html',
})
export class CoreInfiniteLoadingComponent implements OnChanges {
@Input() enabled!: boolean;
@Input() error = false;
@Input() position: 'top' | 'bottom' = 'bottom';
@Output() action: EventEmitter<() => void>; // Will emit an event when triggered.
@ViewChild('topbutton') topButton?: ElementRef;
@ViewChild('infinitescroll') infiniteEl?: ElementRef;
@ViewChild('bottombutton') bottomButton?: ElementRef;
@ViewChild('spinnercontainer') spinnerContainer?: ElementRef;
@ViewChild(IonInfiniteScroll) infiniteScroll?: IonInfiniteScroll;
loadingMore = false; // Hide button and avoid loading more.
protected threshold = parseFloat('15%') / 100;
constructor(
protected element: ElementRef,
@Optional() protected content: IonContent,
) {
this.action = new EventEmitter();
}
/**
* Detect changes on input properties.
*
* @param changes Changes.
*/
ngOnChanges(changes: {[name: string]: SimpleChange}): void {
if (changes.enabled && this.enabled && this.position == 'bottom') {
// Infinite scroll enabled. If the list doesn't fill the full height, infinite scroll isn't triggered automatically.
this.checkScrollDistance();
}
}
/**
* Checks scroll distance to the beginning/end to load more items if needed.
*
* Previously, this function what firing an scroll event but now we have to calculate the distance
* like the Ionic component does.
*/
protected async checkScrollDistance(): Promise<void> {
if (this.enabled) {
const scrollElement = await this.content.getScrollElement();
const infiniteHeight = this.element.nativeElement.getBoundingClientRect().height;
const scrollTop = scrollElement.scrollTop;
const height = scrollElement.offsetHeight;
const threshold = height * this.threshold;
const distanceFromInfinite = (this.position === 'bottom')
? scrollElement.scrollHeight - infiniteHeight - scrollTop - threshold - height
: scrollTop - infiniteHeight - threshold;
if (distanceFromInfinite < 0 && !this.loadingMore && this.enabled) {
this.loadMore();
}
}
}
/**
* Load More items calling the action provided.
*/
loadMore(): void {
if (this.loadingMore) {
return;
}
this.loadingMore = true;
this.action.emit(this.complete.bind(this));
}
/**
* Complete loading.
*/
complete(): void {
if (this.position == 'top') {
// Wait a bit before allowing loading more, otherwise it could be re-triggered automatically when it shouldn't.
setTimeout(this.completeLoadMore.bind(this), 400);
} else {
this.completeLoadMore();
}
}
/**
* Complete loading.
*/
protected async completeLoadMore(): Promise<void> {
this.loadingMore = false;
await this.infiniteScroll?.complete();
// More items loaded. If the list doesn't fill the full height, infinite scroll isn't triggered automatically.
this.checkScrollDistance();
}
/**
* Get the height of the element.
*
* @return Height.
* @todo erase is not needed: I'm depreacating it because if not needed or getBoundingClientRect has the same result, it should
* be erased, also with getElementHeight
* @deprecated
*/
getHeight(): number {
// return this.element.nativeElement.getBoundingClientRect().height;
return (this.position == 'top' ? this.getElementHeight(this.topButton): this.getElementHeight(this.bottomButton)) +
this.getElementHeight(this.infiniteEl) +
this.getElementHeight(this.spinnerContainer);
}
/**
* Get the height of an element.
*
* @param element Element ref.
* @return Height.
*/
protected getElementHeight(element?: ElementRef): number {
if (element && element.nativeElement) {
return CoreDomUtils.instance.getElementHeight(element.nativeElement, true, true, true);
}
return 0;
}
}

View File

@ -1,10 +1,10 @@
<div class="core-loading-container" *ngIf="!hideUntil" role="status"> <!-- @todo [@coreShowHideAnimation] -->
<div class="core-loading-container" *ngIf="!hideUntil" role="status" [@coreShowHideAnimation]>
<span class="core-loading-spinner">
<ion-spinner color="primary"></ion-spinner>
<p class="core-loading-message" *ngIf="message" role="status">{{message}}</p>
</span>
</div>
<div #content class="core-loading-content" [id]="uniqueId" [attr.aria-busy]="hideUntil">
<div #content class="core-loading-content" [id]="uniqueId" [attr.aria-busy]="hideUntil" [@coreShowHideAnimation]>
<ng-content *ngIf="hideUntil">
</ng-content> <!-- @todo [@coreShowHideAnimation] -->
</ng-content>
</div>

View File

@ -17,6 +17,7 @@ import { Component, Input, OnInit, OnChanges, SimpleChange, ViewChild, ElementRe
import { CoreEventLoadingChangedData, CoreEvents } from '@singletons/events';
import { CoreUtils } from '@services/utils/utils';
import { Translate } from '@singletons/core.singletons';
import { coreShowHideAnimation } from '@classes/animations';
/**
* Component to show a loading spinner and message while data is being loaded.
@ -42,7 +43,7 @@ import { Translate } from '@singletons/core.singletons';
selector: 'core-loading',
templateUrl: 'core-loading.html',
styleUrls: ['loading.scss'],
// @todo animations: [coreShowHideAnimation],
animations: [coreShowHideAnimation],
})
export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit {

View File

@ -0,0 +1,3 @@
:host {
display: none !important;
}

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, Input, OnInit, OnDestroy, ElementRef } from '@angular/core';
import { CoreLogger } from '@singletons/logger';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreContextMenuComponent } from '../context-menu/context-menu';
const BUTTON_HIDDEN_CLASS = 'core-navbar-button-hidden';
/**
* Component to add buttons to the app's header without having to place them inside the header itself. This is meant for
* pages that are loaded inside a sub ion-nav, so they don't have a header.
*
* If this component indicates a position (start/end), the buttons will only be added if the header has some buttons in that
* position. If no start/end is specified, then the buttons will be added to the first <ion-buttons> found in the header.
*
* If this component has a "prepend" attribute, the buttons will be added before other existing buttons in the header.
*
* You can use the [hidden] input to hide all the inner buttons if a certain condition is met.
*
* IMPORTANT: Do not use *ngIf in the buttons inside this component, it can cause problems. Please use [hidden] instead.
*
* Example usage:
*
* <core-navbar-buttons slot="end">
* <ion-button [hidden]="!buttonShown" [attr.aria-label]="Do something" (click)="action()">
* <ion-icon name="funnel" slot="icon-only"></ion-icon>
* </ion-button>
* </core-navbar-buttons>
*/
@Component({
selector: 'core-navbar-buttons',
template: '<ng-content></ng-content>',
styleUrls: ['navbar-buttons.scss'],
})
export class CoreNavBarButtonsComponent implements OnInit, OnDestroy {
// If the hidden input is true, hide all buttons.
// eslint-disable-next-line @angular-eslint/no-input-rename
@Input('hidden') set hidden(value: boolean) {
if (typeof value == 'string' && value == '') {
value = true;
}
this.allButtonsHidden = value;
this.showHideAllElements();
}
protected element: HTMLElement;
protected allButtonsHidden = false;
protected forceHidden = false;
protected logger: CoreLogger;
protected movedChildren?: Node[];
protected instanceId: string;
protected mergedContextMenu?: CoreContextMenuComponent;
constructor(element: ElementRef) {
this.element = element.nativeElement;
this.logger = CoreLogger.getInstance('CoreNavBarButtonsComponent');
this.instanceId = CoreDomUtils.instance.storeInstanceByElement(this.element, this);
}
/**
* Component being initialized.
*/
async ngOnInit(): Promise<void> {
try {
const header = await this.searchHeader();
if (header) {
// Search the right buttons container (start, end or any).
let selector = 'ion-buttons';
let slot = this.element.getAttribute('slot');
// Take the slot from the parent if it has.
if (!slot && this.element.parentElement) {
slot = this.element.parentElement.getAttribute('slot');
}
if (slot) {
selector += '[slot="' + slot + '"]';
}
const buttonsContainer = <HTMLElement> header.querySelector(selector);
if (buttonsContainer) {
this.mergeContextMenus(buttonsContainer);
const prepend = this.element.hasAttribute('prepend');
this.movedChildren = CoreDomUtils.instance.moveChildren(this.element, buttonsContainer, prepend);
this.showHideAllElements();
} else {
this.logger.warn('The header was found, but it didn\'t have the right ion-buttons.', selector);
}
}
} catch (error) {
// Header not found.
this.logger.warn(error);
}
}
/**
* Force or unforce hiding all buttons. If this is true, it will override the "hidden" input.
*
* @param value The value to set.
*/
forceHide(value: boolean): void {
this.forceHidden = value;
this.showHideAllElements();
}
/**
* If both button containers have a context menu, merge them into a single one.
*
* @param buttonsContainer The container where the buttons will be moved.
* @todo
*/
protected mergeContextMenus(buttonsContainer: HTMLElement): void {
// Check if both button containers have a context menu.
const mainContextMenu = buttonsContainer.querySelector('core-context-menu');
if (!mainContextMenu) {
return;
}
const secondaryContextMenu = this.element.querySelector('core-context-menu');
if (!secondaryContextMenu) {
return;
}
// Both containers have a context menu. Merge them to prevent having 2 menus at the same time.
const mainContextMenuInstance: CoreContextMenuComponent = CoreDomUtils.instance.getInstanceByElement(mainContextMenu);
const secondaryContextMenuInstance: CoreContextMenuComponent =
CoreDomUtils.instance.getInstanceByElement(secondaryContextMenu);
// Check that both context menus belong to the same core-tab. We shouldn't merge menus from different tabs.
if (mainContextMenuInstance && secondaryContextMenuInstance) {
this.mergedContextMenu = secondaryContextMenuInstance;
this.mergedContextMenu.mergeContextMenus(mainContextMenuInstance);
// Remove the empty context menu from the DOM.
secondaryContextMenu.parentElement?.removeChild(secondaryContextMenu);
}
}
/**
* Search the ion-header where the buttons should be added.
*
* @param retries Number of retries so far.
* @return Promise resolved with the header element.
*/
protected async searchHeader(retries: number = 0): Promise<HTMLElement> {
let parentPage: HTMLElement = this.element;
while (parentPage) {
if (!parentPage.parentElement) {
// No parent, stop.
break;
}
// Get the next parent page.
parentPage = <HTMLElement> CoreDomUtils.instance.closest(parentPage.parentElement, '.ion-page');
if (parentPage) {
// Check if the page has a header. If it doesn't, search the next parent page.
const header = this.searchHeaderInPage(parentPage);
if (header && getComputedStyle(header, null).display != 'none') {
return header;
}
}
}
// Header not found.
if (retries < 5) {
// If the component or any of its parent is inside a ng-content or similar it can be detached when it's initialized.
// Try again after a while.
return new Promise((resolve, reject): void => {
setTimeout(() => {
// eslint-disable-next-line promise/catch-or-return
this.searchHeader(retries + 1).then(resolve, reject);
}, 200);
});
}
// We've waited enough time, reject.
throw Error('Header not found.');
}
/**
* Search ion-header inside a page. The header should be a direct child.
*
* @param page Page to search in.
* @return Header element. Undefined if not found.
*/
protected searchHeaderInPage(page: HTMLElement): HTMLElement | undefined {
for (let i = 0; i < page.children.length; i++) {
const child = page.children[i];
if (child.tagName == 'ION-HEADER') {
return <HTMLElement> child;
}
}
}
/**
* Show or hide all the elements.
*/
protected showHideAllElements(): void {
// Show or hide all moved children.
if (this.movedChildren) {
this.movedChildren.forEach((child: Node) => {
this.showHideElement(child);
});
}
// Show or hide all the context menu items that were merged to another context menu.
if (this.mergedContextMenu) {
if (this.forceHidden || this.allButtonsHidden) {
this.mergedContextMenu.removeMergedItems();
} else {
this.mergedContextMenu.restoreMergedItems();
}
}
}
/**
* Show or hide an element.
*
* @param element Element to show or hide.
*/
protected showHideElement(element: Node): void {
// Check if it's an HTML Element
if (element instanceof Element) {
element.classList.toggle(BUTTON_HIDDEN_CLASS, !!this.forceHidden || !!this.allButtonsHidden);
}
}
/**
* Component destroyed.
*/
ngOnDestroy(): void {
CoreDomUtils.instance.removeInstanceById(this.instanceId);
// This component was destroyed, remove all the buttons that were moved.
// The buttons can be moved outside of the current page, that's why we need to manually destroy them.
// There's no need to destroy context menu items that were merged because they weren't moved from their DOM position.
if (this.movedChildren) {
this.movedChildren.forEach((child) => {
if (child.parentElement) {
child.parentElement.removeChild(child);
}
});
}
if (this.mergedContextMenu) {
this.mergedContextMenu.removeMergedItems();
}
}
}

View File

@ -0,0 +1,5 @@
<ng-container *ngIf="progress >= 0">
<progress max="100" [value]="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" [attr.aria-valuenow]="progress">
</progress>
<div class="core-progress-text">{{ 'core.percentagenumber' | translate: {$a: text} }}</div>
</ng-container>

View File

@ -0,0 +1,33 @@
:host {
display: flex;
.core-progress-text {
line-height: 40px;
font-size: 1rem;
color: var(--text-color);
width: 55px;
text-align: center;
}
progress {
-webkit-appearance: none;
appearance: none;
height: var(--height);
margin: 16px 0;
padding: 0;
display: block;
width: calc(100% - 55px);
&[value]::-webkit-progress-bar {
background-color: var(--background);
border-radius: 0;
border: 0;
box-shadow: none;
}
&[value]::-webkit-progress-value {
background-color: var(--color);
border-radius: 0;
}
}
}

View File

@ -0,0 +1,71 @@
// (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, ChangeDetectionStrategy } from '@angular/core';
import { DomSanitizer, SafeStyle } from '@angular/platform-browser';
/**
* Component to show a progress bar and its value.
*
* Example usage:
* <core-progress-bar [progress]="percentage"></core-progress-bar>
*/
@Component({
selector: 'core-progress-bar',
templateUrl: 'core-progress-bar.html',
styleUrls: ['progress-bar.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CoreProgressBarComponent implements OnChanges {
@Input() progress!: number | string; // Percentage from 0 to 100.
@Input() text?: string; // Percentage in text to be shown at the right. If not defined, progress will be used.
width?: SafeStyle;
protected textSupplied = false;
constructor(private sanitizer: DomSanitizer) { }
/**
* Detect changes on input properties.
*/
ngOnChanges(changes: { [name: string]: SimpleChange }): void {
if (changes.text && typeof changes.text.currentValue != 'undefined') {
// User provided a custom text, don't use default.
this.textSupplied = true;
}
if (changes.progress) {
// Progress has changed.
if (typeof this.progress == 'string') {
this.progress = parseInt(this.progress, 10);
}
if (this.progress < 0 || isNaN(this.progress)) {
this.progress = -1;
}
if (this.progress != -1) {
// Remove decimals.
this.progress = Math.floor(this.progress);
if (!this.textSupplied) {
this.text = String(this.progress);
}
this.width = this.sanitizer.bypassSecurityTrustStyle(this.progress + '%');
}
}
}
}

View File

@ -4,11 +4,11 @@
<ion-buttons slot="end">
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon slot="icon-only" name="fa-times"></ion-icon>
<ion-icon slot="icon-only" name="fas-times"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<core-iframe [src]="recaptchaUrl" (loaded)="loaded($event)"></core-iframe>
</ion-content>
</ion-content>

View File

@ -1,9 +1,9 @@
<ion-tabs>
<ion-tabs class="hide-header">
<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">
<ion-icon *ngIf="showPrevButton" name="fa-chevron-left"></ion-icon>
<ion-icon *ngIf="showPrevButton" name="fas-chevron-left"></ion-icon>
</ion-col>
<ion-col class="ion-no-padding" size="10">
<ion-slides (ionSlideDidChange)="slideChanged()" [options]="slideOpts" [dir]="direction" role="tablist"
@ -18,13 +18,13 @@
<ion-icon *ngIf="tab.icon" [name]="tab.icon"></ion-icon>
<ion-label>{{ tab.title | translate}}</ion-label>
<ion-badge *ngIf="tab.badge">{{ tab.badge }}</ion-badge>
</ion-tab-button>
</ion-tab-button>
</ion-slide>
</ng-container>
</ion-slides>
</ion-col>
<ion-col class="col-with-arrow ion-no-padding" (click)="slideNext()" size="1">
<ion-icon *ngIf="showNextButton" name="fa-chevron-right"></ion-icon>
<ion-icon *ngIf="showNextButton" name="fas-chevron-right"></ion-icon>
</ion-col>
</ion-row>
</ion-tab-bar>

View File

@ -33,6 +33,9 @@ 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 '@/core/services/utils/dom';
import { StackEvent } from '@ionic/angular/directives/navigation/stack-utils';
/**
* This component displays some top scrollable tabs that will autohide on vertical scroll.
@ -105,6 +108,7 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe
protected isInTransition = false; // Weather Slides is in transition.
protected slidesSwiper: any;
protected slidesSwiperLoaded = false;
protected stackEventsSubscription?: Subscription;
constructor(
protected element: ElementRef,
@ -151,6 +155,21 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe
return;
}
this.stackEventsSubscription = this.ionTabs!.outlet.stackEvents.subscribe(async (stackEvent: StackEvent) => {
if (this.isCurrentView) {
const content = stackEvent.enteringView.element.querySelector('ion-content');
this.showHideNavBarButtons(stackEvent.enteringView.element.tagName);
if (content) {
const scroll = await content.getScrollElement();
content.scrollEvents = true;
content.addEventListener('ionScroll', (e: CustomEvent): void => {
this.showHideTabs(e, scroll);
});
}
}
});
this.tabBarElement = this.element.nativeElement.querySelector('ion-tab-bar');
this.tabsElement = this.element.nativeElement.querySelector('ion-tabs');
@ -576,19 +595,29 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe
this.selectedIndex = index;
this.ionChange.emit(selectedTab);
const content = this.ionTabs!.outlet.nativeEl.querySelector('ion-content');
if (content) {
const scroll = await content.getScrollElement();
content.scrollEvents = true;
content.addEventListener('ionScroll', (e: CustomEvent): void => {
this.showHideTabs(e, scroll);
});
}
}
}
/**
* Get all child core-navbar-buttons and show or hide depending on the page state.
* We need to use querySelectorAll because ContentChildren doesn't work with ng-template.
* https://github.com/angular/angular/issues/14842
*
* @param activatedPageName Activated page name.
*/
protected showHideNavBarButtons(activatedPageName: string): void {
const elements = this.ionTabs!.outlet.nativeEl.querySelectorAll('core-navbar-buttons');
const domUtils = CoreDomUtils.instance;
elements.forEach((element) => {
const instance: CoreNavBarButtonsComponent = domUtils.getInstanceByElement(element);
if (instance) {
const pagetagName = element.closest('.ion-page')?.tagName;
instance.forceHide(activatedPageName != pagetagName);
}
});
}
/**
* Adapt tabs to a window resize.
*/
@ -607,6 +636,7 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe
if (this.resizeFunction) {
window.removeEventListener('resize', this.resizeFunction);
}
this.stackEventsSubscription?.unsubscribe();
}
}

View File

@ -71,6 +71,12 @@ export class CoreConstants {
static readonly OUTDATED = 'outdated';
static readonly NOT_DOWNLOADABLE = 'notdownloadable';
static readonly DOWNLOADED_ICON = 'cloud-done';
static readonly DOWNLOADING_ICON = 'spinner';
static readonly NOT_DOWNLOADED_ICON = 'cloud-download';
static readonly OUTDATED_ICON = 'fas-redo-alt';
static readonly NOT_DOWNLOADABLE_ICON = '';
// Constants from Moodle's resourcelib.
static readonly RESOURCELIB_DISPLAY_AUTO = 0; // Try the best way.
static readonly RESOURCELIB_DISPLAY_EMBED = 1; // Display using object tag.

View File

@ -23,7 +23,7 @@ import { CoreConstants } from '@/core/constants';
*
* Example usage:
*
* <ion-icon name="fa-icon">
* <ion-icon name="fas-icon">
*/
@Directive({
selector: 'ion-icon[name]',

View File

@ -194,8 +194,8 @@ export class CoreFormatTextDirective implements OnChanges {
anchor.classList.add('core-image-viewer-icon');
anchor.setAttribute('aria-label', label);
// Add an ion-icon item to apply the right styles, but the ion-icon component won't be executed.
anchor.innerHTML = '<ion-icon name="search" class="icon icon-md ion-md-search"></ion-icon>';
// @todo Add an ion-icon item to apply the right styles, but the ion-icon component won't be executed.
anchor.innerHTML = '<ion-icon name="fas-search" class="icon icon-md ion-md-search"></ion-icon>';
anchor.addEventListener('click', (e: Event) => {
e.preventDefault();

View File

@ -0,0 +1,37 @@
// (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 { CORE_SITE_SCHEMAS } from '@/core/services/sites';
import {
SITE_SCHEMA as COURSE_SITE_SCHEMA,
OFFLINE_SITE_SCHEMA as COURSE_OFFLINE_SITE_SCHEMA,
} from './services/course-db';
@NgModule({
providers: [
{
provide: CORE_SITE_SCHEMAS,
useValue: [
COURSE_SITE_SCHEMA,
COURSE_OFFLINE_SITE_SCHEMA,
],
multi: true,
},
],
})
export class CoreCourseModule {
}

View File

@ -0,0 +1,36 @@
{
"activitydisabled": "Your organisation has disabled this activity in the mobile app.",
"activitynotyetviewableremoteaddon": "Your organisation installed a plugin that is not yet supported.",
"activitynotyetviewablesiteupgradeneeded": "Your organisation's Moodle installation needs to be updated.",
"allsections": "All sections",
"askadmintosupport": "Contact the site administrator and tell them you want to use this activity with the Moodle Mobile app.",
"availablespace": " You currently have about {{available}} free space.",
"cannotdeletewhiledownloading": "Files cannot be deleted while the activity is being downloaded. Please wait for the download to finish.",
"confirmdeletemodulefiles": "Are you sure you want to delete these files?",
"confirmdeletestoreddata": "Are you sure you want to delete the stored data?",
"confirmdownload": "You are about to download {{size}}.{{availableSpace}} Are you sure you want to continue?",
"confirmdownloadunknownsize": "It was not possible to calculate the size of the download.{{availableSpace}} Are you sure you want to continue?",
"confirmdownloadzerosize": "You are about to start downloading.{{availableSpace}} Are you sure you want to continue?",
"confirmpartialdownloadsize": "You are about to download <strong>at least</strong> {{size}}.{{availableSpace}} Are you sure you want to continue?",
"confirmlimiteddownload": "You are not currently connected to Wi-Fi. ",
"contents": "Contents",
"couldnotloadsectioncontent": "Could not load the section content. Please try again later.",
"couldnotloadsections": "Could not load the sections. Please try again later.",
"coursesummary": "Course summary",
"downloadcourse": "Download course",
"errordownloadingcourse": "Error downloading course.",
"errordownloadingsection": "Error downloading section.",
"errorgetmodule": "Error getting activity data.",
"hiddenfromstudents": "Hidden from students",
"hiddenoncoursepage": "Available but not shown on course page",
"insufficientavailablespace": "You are trying to download {{size}}. This will leave your device with insufficient space to operate normally. Please clear some storage space first.",
"insufficientavailablequota": "Your device could not allocate space to save this download. It may be reserving space for app and system updates. Please clear some storage space first.",
"manualcompletionnotsynced": "Manual completion not synchronised.",
"nocontentavailable": "No content available at the moment.",
"overriddennotice": "Your final grade from this activity was manually adjusted.",
"refreshcourse": "Refresh course",
"sections": "Sections",
"useactivityonbrowser": "You can still use it using your device's web browser.",
"warningmanualcompletionmodified": "The manual completion of an activity was modified on the site.",
"warningofflinemanualcompletiondeleted": "Some offline manual completion of course '{{name}}' has been deleted. {{error}}"
}

View File

@ -0,0 +1,111 @@
// (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 COURSE_STATUS_TABLE = 'course_status';
export const SITE_SCHEMA: CoreSiteSchema = {
name: 'CoreCourseProvider',
version: 1,
tables: [
{
name: COURSE_STATUS_TABLE,
columns: [
{
name: 'id',
type: 'INTEGER',
primaryKey: true,
},
{
name: 'status',
type: 'TEXT',
notNull: true,
},
{
name: 'previous',
type: 'TEXT',
},
{
name: 'updated',
type: 'INTEGER',
},
{
name: 'downloadTime',
type: 'INTEGER',
},
{
name: 'previousDownloadTime',
type: 'INTEGER',
},
],
},
],
};
/**
* Database variables for CoreCourseOffline service.
*/
export const MANUAL_COMPLETION_TABLE = 'course_manual_completion';
export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = {
name: 'CoreCourseOfflineProvider',
version: 1,
tables: [
{
name: MANUAL_COMPLETION_TABLE,
columns: [
{
name: 'cmid',
type: 'INTEGER',
primaryKey: true,
},
{
name: 'completed',
type: 'INTEGER',
},
{
name: 'courseid',
type: 'INTEGER',
},
{
name: 'coursename',
type: 'TEXT',
},
{
name: 'timecompleted',
type: 'INTEGER',
},
],
},
],
};
export type CoreCourseStatusDBRecord = {
id: number;
status: string;
previous: string;
updated: number;
downloadTime: number;
previousDownloadTime: number;
};
export type CoreCourseManualCompletionDBRecord = {
cmid: number;
completed: number;
courseid: number;
coursename: string;
timecompleted: number;
};

View File

@ -0,0 +1,117 @@
// (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/core.singletons';
import { CoreSites } from '@services/sites';
import { CoreCourseManualCompletionDBRecord, MANUAL_COMPLETION_TABLE } from './course-db';
import { CoreStatusWithWarningsWSResponse } from '@services/ws';
/**
* Service to handle offline data for courses.
*/
@Injectable({
providedIn: 'root',
})
export class CoreCourseOfflineProvider {
/**
* Delete a manual completion stored.
*
* @param cmId The module ID to remove the completion.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async deleteManualCompletion(cmId: number, siteId?: string): Promise<void> {
const site = await CoreSites.instance.getSite(siteId);
await site.getDb().deleteRecords(MANUAL_COMPLETION_TABLE, { cmid: cmId });
}
/**
* Get all offline manual completions for a certain course.
*
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the list of completions.
*/
async getAllManualCompletions(siteId?: string): Promise<CoreCourseManualCompletionDBRecord[]> {
const site = await CoreSites.instance.getSite(siteId);
return await site.getDb().getRecords(MANUAL_COMPLETION_TABLE);
}
/**
* Get all offline manual completions for a certain course.
*
* @param courseId Course ID the module belongs to.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the list of completions.
*/
async getCourseManualCompletions(courseId: number, siteId?: string): Promise<CoreCourseManualCompletionDBRecord[]> {
const site = await CoreSites.instance.getSite(siteId);
return await site.getDb().getRecords(MANUAL_COMPLETION_TABLE, { courseid: courseId });
}
/**
* Get the offline manual completion for a certain module.
*
* @param cmId The module ID to remove the completion.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the completion, rejected if failure or not found.
*/
async getManualCompletion(cmId: number, siteId?: string): Promise<CoreCourseManualCompletionDBRecord> {
const site = await CoreSites.instance.getSite(siteId);
return await site.getDb().getRecord(MANUAL_COMPLETION_TABLE, { cmid: cmId });
}
/**
* Offline version for manually marking a module as completed.
*
* @param cmId The module ID to store the completion.
* @param completed Whether the module is completed or not.
* @param courseId Course ID the module belongs to.
* @param courseName Course name. Recommended, it is used to display a better warning message.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when completion is successfully stored.
*/
async markCompletedManually(
cmId: number,
completed: boolean,
courseId: number,
courseName?: string,
siteId?: string,
): Promise<CoreStatusWithWarningsWSResponse> {
// Store the offline data.
const site = await CoreSites.instance.getSite(siteId);
const entry: CoreCourseManualCompletionDBRecord = {
cmid: cmId,
completed: completed ? 1 : 0,
courseid: courseId,
coursename: courseName || '',
timecompleted: Date.now(),
};
await site.getDb().insertRecord(MANUAL_COMPLETION_TABLE, entry);
return ({
status: true,
offline: true,
});
}
}
export class CoreCourseOffline extends makeSingleton(CoreCourseOfflineProvider) { }

View File

@ -0,0 +1,990 @@
// (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 { Params } from '@angular/router';
import { CoreSites } from '@services/sites';
import { CoreCourse, CoreCourseSection } from './course';
import { CoreConstants } from '@/core/constants';
import { CoreLogger } from '@singletons/logger';
import { makeSingleton, Translate } from '@singletons/core.singletons';
import { CoreFilepool } from '@services/filepool';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import {
CoreCourseBasicData,
CoreCourseGetCoursesData,
CoreCourses,
CoreCourseSearchedData,
CoreEnrolledCourseBasicData,
CoreEnrolledCourseData,
} from '@features/courses/services/courses';
import { CoreEnrolledCourseDataWithExtraInfoAndOptions } from '@features/courses/services/courses.helper';
import { CoreArray } from '@singletons/array';
import { CoreLoginHelper, CoreLoginHelperProvider } from '@features/login/services/login.helper';
import { CoreIonLoadingElement } from '@classes/ion-loading';
import { CoreCourseOffline } from './course-offline';
/**
* Prefetch info of a module.
*/
export type CoreCourseModulePrefetchInfo = {
/**
* Downloaded size.
*/
size?: number;
/**
* Downloadable size in a readable format.
*/
sizeReadable?: string;
/**
* Module status.
*/
status?: string;
/**
* Icon's name of the module status.
*/
statusIcon?: string;
/**
* Time when the module was last downloaded.
*/
downloadTime?: number;
/**
* Download time in a readable format.
*/
downloadTimeReadable?: string;
};
/**
* Progress of downloading a list of courses.
*/
export type CoreCourseCoursesProgress = {
/**
* Number of courses downloaded so far.
*/
count: number;
/**
* Toal of courses to download.
*/
total: number;
/**
* Whether the download has been successful so far.
*/
success: boolean;
/**
* Last downloaded course.
*/
courseId?: number;
};
export type CorePrefetchStatusInfo = {
status: string; // Status of the prefetch.
statusTranslatable: string; // Status translatable string.
icon: string; // Icon based on the status.
loading: boolean; // If it's a loading status.
badge?: string; // Progress badge string if any.
};
/**
* Helper to gather some common course functions.
*/
@Injectable({
providedIn: 'root',
})
export class CoreCourseHelperProvider {
protected courseDwnPromises: { [s: string]: { [id: number]: Promise<void> } } = {};
protected logger: CoreLogger;
constructor() {
this.logger = CoreLogger.getInstance('CoreCourseHelperProvider');
}
/**
* This function treats every module on the sections provided to load the handler data, treat completion
* and navigate to a module page if required. It also returns if sections has content.
*
* @param sections List of sections to treat modules.
* @param courseId Course ID of the modules.
* @param completionStatus List of completion status.
* @param courseName Course name. Recommended if completionStatus is supplied.
* @param forCoursePage Whether the data will be used to render the course page.
* @return Whether the sections have content.
*/
addHandlerDataForModules(): void {
// @todo params and logic
}
/**
* Calculate the status of a section.
*
* @param section Section to calculate its status. It can't be "All sections".
* @param courseId Course ID the section belongs to.
* @param refresh True if it shouldn't use module status cache (slower).
* @param checkUpdates Whether to use the WS to check updates. Defaults to true.
* @return Promise resolved when the status is calculated.
*/
calculateSectionStatus(): void {
// @todo params and logic
}
/**
* Calculate the status of a list of sections, setting attributes to determine the icons/data to be shown.
*
* @param sections Sections to calculate their status.
* @param courseId Course ID the sections belong to.
* @param refresh True if it shouldn't use module status cache (slower).
* @param checkUpdates Whether to use the WS to check updates. Defaults to true.
* @return Promise resolved when the states are calculated.
*/
calculateSectionsStatus(): void {
// @todo params and logic
}
/**
* Show a confirm and prefetch a course. It will retrieve the sections and the course options if not provided.
* This function will set the icon to "spinner" when starting and it will also set it back to the initial icon if the
* user cancels. All the other updates of the icon should be made when CoreEvents.COURSE_STATUS_CHANGED is received.
*
* @param data An object where to store the course icon and title: "prefetchCourseIcon", "title" and "downloadSucceeded".
* @param course Course to prefetch.
* @param sections List of course sections.
* @param courseHandlers List of course handlers.
* @param menuHandlers List of course menu handlers.
* @return Promise resolved when the download finishes, rejected if an error occurs or the user cancels.
*/
confirmAndPrefetchCourse(): void {
// @todo params and logic
}
/**
* Confirm and prefetches a list of courses.
*
* @param courses List of courses to download.
* @param onProgress Function to call everytime a course is downloaded.
* @return Resolved when downloaded, rejected if error or canceled.
*/
async confirmAndPrefetchCourses(
courses: CoreEnrolledCourseDataWithExtraInfoAndOptions[],
onProgress?: (data: CoreCourseCoursesProgress) => void,
): Promise<void> {
const siteId = CoreSites.instance.getCurrentSiteId();
// Confirm the download without checking size because it could take a while.
await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.areyousure'));
const total = courses.length;
let count = 0;
const promises = courses.map((course) => {
const subPromises: Promise<void>[] = [];
let sections: CoreCourseSection[];
let handlers: any;
let menuHandlers: any;
let success = true;
// Get the sections and the handlers.
subPromises.push(CoreCourse.instance.getSections(course.id, false, true).then((courseSections) => {
sections = courseSections;
return;
}));
/**
* @todo
subPromises.push(this.courseOptionsDelegate.getHandlersToDisplay(this.injector, course).then((cHandlers: any) => {
handlers = cHandlers;
}));
subPromises.push(this.courseOptionsDelegate.getMenuHandlersToDisplay(this.injector, course).then((mHandlers: any) => {
menuHandlers = mHandlers;
}));
*/
return Promise.all(subPromises).then(() => this.prefetchCourse(course, sections, handlers, menuHandlers, siteId))
.catch((error) => {
success = false;
throw error;
}).finally(() => {
// Course downloaded or failed, notify the progress.
count++;
if (onProgress) {
onProgress({ count: count, total: total, courseId: course.id, success: success });
}
});
});
if (onProgress) {
// Notify the start of the download.
onProgress({ count: 0, total: total, success: true });
}
return CoreUtils.instance.allPromises(promises);
}
/**
* Show confirmation dialog and then remove a module files.
*
* @param module Module to remove the files.
* @param courseId Course ID the module belongs to.
* @param done Function to call when done. It will close the context menu.
* @return Promise resolved when done.
* @todo module type.
*/
async confirmAndRemoveFiles(module: any, courseId: number, done?: () => void): Promise<void> {
let modal: CoreIonLoadingElement | undefined;
try {
await CoreDomUtils.instance.showDeleteConfirm('core.course.confirmdeletestoreddata');
modal = await CoreDomUtils.instance.showModalLoading();
await this.removeModuleStoredData(module, courseId);
done && done();
} catch (error) {
if (error) {
CoreDomUtils.instance.showErrorModal(error);
}
} finally {
modal?.dismiss();
}
}
/**
* Calculate the size to download a section and show a confirm modal if needed.
*
* @param courseId Course ID the section belongs to.
* @param section Section. If not provided, all sections.
* @param sections List of sections. Used when downloading all the sections.
* @param alwaysConfirm True to show a confirm even if the size isn't high, false otherwise.
* @return Promise resolved if the user confirms or there's no need to confirm.
*/
confirmDownloadSizeSection(): void {
// @todo params and logic
}
/**
* Helper function to prefetch a module, showing a confirmation modal if the size is big.
* This function is meant to be called from a context menu option. It will also modify some data like the prefetch icon.
*
* @param instance The component instance that has the context menu. It should have prefetchStatusIcon and isDestroyed.
* @param module Module to be prefetched
* @param courseId Course ID the module belongs to.
* @param done Function to call when done. It will close the context menu.
* @return Promise resolved when done.
*/
contextMenuPrefetch(): void {
// @todo params and logic
}
/**
* Determine the status of a list of courses.
*
* @param courses Courses
* @return Promise resolved with the status.
*/
async determineCoursesStatus(courses: CoreCourseBasicData[]): Promise<string> {
// Get the status of each course.
const promises: Promise<string>[] = [];
const siteId = CoreSites.instance.getCurrentSiteId();
courses.forEach((course) => {
promises.push(CoreCourse.instance.getCourseStatus(course.id, siteId));
});
const statuses = await Promise.all(promises);
// Now determine the status of the whole list.
let status = statuses[0];
const filepool = CoreFilepool.instance;
for (let i = 1; i < statuses.length; i++) {
status = filepool.determinePackagesStatus(status, statuses[i]);
}
return status;
}
/**
* Convenience function to open a module main file, downloading the package if needed.
* This is meant for modules like mod_resource.
*
* @param module The module to download.
* @param courseId The course ID of the module.
* @param component The component to link the files to.
* @param componentId An ID to use in conjunction with the component.
* @param files List of files of the module. If not provided, use module.contents.
* @param siteId The site ID. If not defined, current site.
* @return Resolved on success.
*/
downloadModuleAndOpenFile(): void {
// @todo params and logic
}
/**
* Convenience function to download a module that has a main file and return the local file's path and other info.
* This is meant for modules like mod_resource.
*
* @param module The module to download.
* @param courseId The course ID of the module.
* @param component The component to link the files to.
* @param componentId An ID to use in conjunction with the component.
* @param files List of files of the module. If not provided, use module.contents.
* @param siteId The site ID. If not defined, current site.
* @return Promise resolved when done.
*/
downloadModuleWithMainFileIfNeeded(): void {
// @todo params and logic
}
/**
* Convenience function to download a module that has a main file and return the local file's path and other info.
* This is meant for modules like mod_resource.
*
* @param module The module to download.
* @param courseId The course ID of the module.
* @param fixedUrl Main file's fixed URL.
* @param files List of files of the module.
* @param status The package status.
* @param component The component to link the files to.
* @param componentId An ID to use in conjunction with the component.
* @param siteId The site ID. If not defined, current site.
* @return Promise resolved when done.
*/
protected downloadModuleWithMainFile(): void {
// @todo params and logic
}
/**
* Convenience function to download a module.
*
* @param module The module to download.
* @param courseId The course ID of the module.
* @param component The component to link the files to.
* @param componentId An ID to use in conjunction with the component.
* @param files List of files of the module. If not provided, use module.contents.
* @param siteId The site ID. If not defined, current site.
* @return Promise resolved when done.
*/
downloadModule(): void {
// @todo params and logic
}
/**
* Fill the Context Menu for a certain module.
*
* @param instance The component instance that has the context menu.
* @param module Module to be prefetched
* @param courseId Course ID the module belongs to.
* @param invalidateCache Invalidates the cache first.
* @param component Component of the module.
* @return Promise resolved when done.
*/
fillContextMenu(): void {
// @todo params and logic
}
/**
* Get a course. It will first check the user courses, and fallback to another WS if not enrolled.
*
* @param courseId Course ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the course.
*/
async getCourse(
courseId: number,
siteId?: string,
): Promise<{ enrolled: boolean; course: CoreEnrolledCourseData | CoreCourseSearchedData | CoreCourseGetCoursesData }> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
let course: CoreEnrolledCourseData | CoreCourseSearchedData | CoreCourseGetCoursesData;
// Try with enrolled courses first.
try {
course = await CoreCourses.instance.getUserCourse(courseId, false, siteId);
return ({ enrolled: true, course: course });
} catch {
// Not enrolled or an error happened. Try to use another WebService.
}
const available = await CoreCourses.instance.isGetCoursesByFieldAvailableInSite(siteId);
if (available) {
course = await CoreCourses.instance.getCourseByField('id', courseId, siteId);
} else {
course = await CoreCourses.instance.getCourse(courseId, siteId);
}
return ({ enrolled: false, course: course });
}
/**
* Get a course, wait for any course format plugin to load, and open the course page. It basically chains the functions
* getCourse and openCourse.
*
* @param courseId Course ID.
* @param params Other params to pass to the course page.
* @param siteId Site ID. If not defined, current site.
*/
async getAndOpenCourse(courseId: number, params?: Params, siteId?: string): Promise<any> {
const modal = await CoreDomUtils.instance.showModalLoading();
let course: CoreEnrolledCourseData | CoreCourseSearchedData | CoreCourseGetCoursesData | { id: number };
try {
const data = await this.getCourse(courseId, siteId);
course = data.course;
} catch {
// Cannot get course, return a "fake".
course = { id: courseId };
}
modal?.dismiss();
return this.openCourse(course, params, siteId);
}
/**
* Check if the course has a block with that name.
*
* @param courseId Course ID.
* @param name Block name to search.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with true if the block exists or false otherwise.
* @since 3.3
*/
async hasABlockNamed(courseId: number, name: string, siteId?: string): Promise<boolean> {
try {
const blocks = await CoreCourse.instance.getCourseBlocks(courseId, siteId);
return blocks.some((block) => block.name == name);
} catch {
return false;
}
}
/**
* Initialize the prefetch icon for selected courses.
*
* @param courses Courses array to get info from.
* @param prefetch Prefetch information.
* @param minCourses Min course to show icon.
* @return Resolved with the prefetch information updated when done.
*/
async initPrefetchCoursesIcons(
courses: CoreCourseBasicData[],
prefetch: CorePrefetchStatusInfo,
minCourses: number = 2,
): Promise<CorePrefetchStatusInfo> {
if (!courses || courses.length < minCourses) {
// Not enough courses.
prefetch.icon = '';
return prefetch;
}
const status = await this.determineCoursesStatus(courses);
prefetch = this.getCourseStatusIconAndTitleFromStatus(status);
if (prefetch.loading) {
// It seems all courses are being downloaded, show a download button instead.
prefetch.icon = CoreConstants.NOT_DOWNLOADED_ICON;
}
return prefetch;
}
/**
* Load offline completion into a list of sections.
* This should be used in 3.6 sites or higher, where the course contents already include the completion.
*
* @param courseId The course to get the completion.
* @param sections List of sections of the course.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async loadOfflineCompletion(courseId: number, sections: any[], siteId?: string): Promise<void> {
const offlineCompletions = await CoreCourseOffline.instance.getCourseManualCompletions(courseId, siteId);
if (!offlineCompletions || !offlineCompletions.length) {
// No offline completion.
return;
}
const totalOffline = offlineCompletions.length;
let loaded = 0;
const offlineCompletionsMap = CoreUtils.instance.arrayToObject(offlineCompletions, 'cmid');
// Load the offline data in the modules.
for (let i = 0; i < sections.length; i++) {
const section = sections[i];
if (!section.modules || !section.modules.length) {
// Section has no modules, ignore it.
continue;
}
for (let j = 0; j < section.modules.length; j++) {
const module = section.modules[j];
const offlineCompletion = offlineCompletionsMap[module.id];
if (offlineCompletion && typeof module.completiondata != 'undefined' &&
offlineCompletion.timecompleted >= module.completiondata.timecompleted * 1000) {
// The module has offline completion. Load it.
module.completiondata.state = offlineCompletion.completed;
module.completiondata.offline = true;
// If all completions have been loaded, stop.
loaded++;
if (loaded == totalOffline) {
break;
}
}
}
}
}
/**
* Prefetch all the courses in the array.
*
* @param courses Courses array to prefetch.
* @param prefetch Prefetch information to be updated.
* @return Promise resolved when done.
*/
async prefetchCourses(
courses: CoreEnrolledCourseDataWithExtraInfoAndOptions[],
prefetch: CorePrefetchStatusInfo,
): Promise<void> {
prefetch.loading = true;
prefetch.icon = CoreConstants.DOWNLOADING_ICON;
prefetch.badge = '';
try {
await this.confirmAndPrefetchCourses(courses, (progress) => {
prefetch.badge = progress.count + ' / ' + progress.total;
});
prefetch.icon = CoreConstants.OUTDATED_ICON;
} finally {
prefetch.loading = false;
prefetch.badge = '';
}
}
/**
* Get a course download promise (if any).
*
* @param courseId Course ID.
* @param siteId Site ID. If not defined, current site.
* @return Download promise, undefined if not found.
*/
getCourseDownloadPromise(courseId: number, siteId?: string): Promise<void> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
return this.courseDwnPromises[siteId] && this.courseDwnPromises[siteId][courseId];
}
/**
* Get a course status icon and the langkey to use as a title.
*
* @param courseId Course ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the icon name and the title key.
*/
async getCourseStatusIconAndTitle(courseId: number, siteId?: string): Promise<CorePrefetchStatusInfo> {
const status = await CoreCourse.instance.getCourseStatus(courseId, siteId);
return this.getCourseStatusIconAndTitleFromStatus(status);
}
/**
* Get a course status icon and the langkey to use as a title from status.
*
* @param status Course status.
* @return Title and icon name.
*/
getCourseStatusIconAndTitleFromStatus(status: string): CorePrefetchStatusInfo {
const prefetchStatus: CorePrefetchStatusInfo = {
status: status,
icon: this.getPrefetchStatusIcon(status, false),
statusTranslatable: '',
loading: false,
};
if (status == CoreConstants.DOWNLOADED) {
// Always show refresh icon, we cannot know if there's anything new in course options.
prefetchStatus.statusTranslatable = 'core.course.refreshcourse';
} else if (status == CoreConstants.DOWNLOADING) {
prefetchStatus.statusTranslatable = 'core.downloading';
prefetchStatus.loading = true;
} else {
prefetchStatus.statusTranslatable = 'core.course.downloadcourse';
}
return prefetchStatus;
}
/**
* Get the icon given the status and if trust the download status.
*
* @param status Status constant.
* @param trustDownload True to show download success, false to show an outdated status when downloaded.
* @return Icon name.
*/
getPrefetchStatusIcon(status: string, trustDownload: boolean = false): string {
if (status == CoreConstants.NOT_DOWNLOADED) {
return CoreConstants.NOT_DOWNLOADED_ICON;
}
if (status == CoreConstants.OUTDATED || (status == CoreConstants.DOWNLOADED && !trustDownload)) {
return CoreConstants.OUTDATED_ICON;
}
if (status == CoreConstants.DOWNLOADED && trustDownload) {
return CoreConstants.DOWNLOADED_ICON;
}
if (status == CoreConstants.DOWNLOADING) {
return CoreConstants.DOWNLOADING_ICON;
}
return CoreConstants.DOWNLOADING_ICON;
}
/**
* Get the course ID from a module instance ID, showing an error message if it can't be retrieved.
*
* @param id Instance ID.
* @param module Name of the module. E.g. 'glossary'.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the module's course ID.
* @todo module type.
*/
async getModuleCourseIdByInstance(id: number, module: any, siteId?: string): Promise<number> {
try {
const cm = await CoreCourse.instance.getModuleBasicInfoByInstance(id, module, siteId);
return cm.course;
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errorgetmodule', true);
throw error;
}
}
/**
* Get prefetch info for a module.
*
* @param module Module to get the info from.
* @param courseId Course ID the section belongs to.
* @param invalidateCache Invalidates the cache first.
* @param component Component of the module.
* @return Promise resolved with the info.
*/
getModulePrefetchInfo(): void {
// @todo params and logic
}
/**
* Get the download ID of a section. It's used to interact with CoreCourseModulePrefetchDelegate.
*
* @param section Section.
* @return Section download ID.
* @todo section type.
*/
getSectionDownloadId(section: any): string {
return 'Section-' + section.id;
}
/**
* Navigate to a module using instance ID and module name.
*
* @param instanceId Activity instance ID.
* @param modName Module name of the activity.
* @param siteId Site ID. If not defined, current site.
* @param courseId Course ID. If not defined we'll try to retrieve it from the site.
* @param sectionId Section the module belongs to. If not defined we'll try to retrieve it from the site.
* @param useModNameToGetModule If true, the app will retrieve all modules of this type with a single WS call. This reduces the
* number of WS calls, but it isn't recommended for modules that can return a lot of contents.
* @param modParams Params to pass to the module
* @param navCtrl NavController for adding new pages to the current history. Optional for legacy support, but
* generates a warning if omitted.
* @return Promise resolved when done.
*/
navigateToModuleByInstance(): void {
// @todo params and logic
}
/**
* Navigate to a module.
*
* @param moduleId Module's ID.
* @param siteId Site ID. If not defined, current site.
* @param courseId Course ID. If not defined we'll try to retrieve it from the site.
* @param sectionId Section the module belongs to. If not defined we'll try to retrieve it from the site.
* @param modName If set, the app will retrieve all modules of this type with a single WS call. This reduces the
* number of WS calls, but it isn't recommended for modules that can return a lot of contents.
* @param modParams Params to pass to the module
* @param navCtrl NavController for adding new pages to the current history. Optional for legacy support, but
* generates a warning if omitted.
* @return Promise resolved when done.
*/
navigateToModule(): void {
// @todo params and logic
}
/**
* Open a module.
*
* @param navCtrl The NavController to use.
* @param module The module to open.
* @param courseId The course ID of the module.
* @param sectionId The section ID of the module.
* @param modParams Params to pass to the module
* @param True if module can be opened, false otherwise.
*/
openModule(): void {
// @todo params and logic
}
/**
* Prefetch all the activities in a course and also the course addons.
*
* @param course The course to prefetch.
* @param sections List of course sections.
* @param courseHandlers List of course options handlers.
* @param courseMenuHandlers List of course menu handlers.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when the download finishes.
*/
async prefetchCourse(
course: CoreEnrolledCourseDataWithExtraInfoAndOptions,
sections: CoreCourseSection[],
courseHandlers: any[], // @todo CoreCourseOptionsHandlerToDisplay[],
courseMenuHandlers: any[], // @todo CoreCourseOptionsMenuHandlerToDisplay[],
siteId?: string,
): Promise<void> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
if (this.courseDwnPromises[siteId] && this.courseDwnPromises[siteId][course.id]) {
// There's already a download ongoing for this course, return the promise.
return this.courseDwnPromises[siteId][course.id];
} else if (!this.courseDwnPromises[siteId]) {
this.courseDwnPromises[siteId] = {};
}
// First of all, mark the course as being downloaded.
this.courseDwnPromises[siteId][course.id] = CoreCourse.instance.setCourseStatus(
course.id,
CoreConstants.DOWNLOADING,
siteId,
).then(async () => {
const promises: Promise<any>[] = [];
// Prefetch all the sections. If the first section is "All sections", use it. Otherwise, use a fake "All sections".
/*
* @todo
let allSectionsSection = sections[0];
if (sections[0].id != CoreCourseProvider.ALL_SECTIONS_ID) {
allSectionsSection = { id: CoreCourseProvider.ALL_SECTIONS_ID };
}
promises.push(this.prefetchSection(allSectionsSection, course.id, sections));
// Prefetch course options.
courseHandlers.forEach((handler) => {
if (handler.prefetch) {
promises.push(handler.prefetch(course));
}
});
courseMenuHandlers.forEach((handler) => {
if (handler.prefetch) {
promises.push(handler.prefetch(course));
}
});*/
// Prefetch other data needed to render the course.
if (CoreCourses.instance.isGetCoursesByFieldAvailable()) {
promises.push(CoreCourses.instance.getCoursesByField('id', course.id));
}
const sectionWithModules = sections.find((section) => section.modules && section.modules.length > 0);
if (!sectionWithModules || typeof sectionWithModules.modules[0].completion == 'undefined') {
promises.push(CoreCourse.instance.getActivitiesCompletionStatus(course.id));
}
// @todo promises.push(this.filterHelper.getFilters('course', course.id));
return CoreUtils.instance.allPromises(promises);
}).then(() =>
// Download success, mark the course as downloaded.
CoreCourse.instance.setCourseStatus(course.id, CoreConstants.DOWNLOADED, siteId)).catch(async (error) => {
// Error, restore previous status.
await CoreCourse.instance.setCoursePreviousStatus(course.id, siteId);
throw error;
}).finally(() => {
delete this.courseDwnPromises[siteId!][course.id];
});
return this.courseDwnPromises[siteId][course.id];
}
/**
* Helper function to prefetch a module, showing a confirmation modal if the size is big
* and invalidating contents if refreshing.
*
* @param handler Prefetch handler to use. Must implement 'prefetch' and 'invalidateContent'.
* @param module Module to download.
* @param size Object containing size to download (in bytes) and a boolean to indicate if its totally calculated.
* @param courseId Course ID of the module.
* @param refresh True if refreshing, false otherwise.
* @return Promise resolved when downloaded.
*/
prefetchModule(): void {
// @todo params and logic
}
/**
* Prefetch one section or all the sections.
* If the section is "All sections" it will prefetch all the sections.
*
* @param section Section.
* @param courseId Course ID the section belongs to.
* @param sections List of sections. Used when downloading all the sections.
* @return Promise resolved when the prefetch is finished.
*/
async prefetchSection(): Promise<void> {
// @todo params and logic
}
/**
* Prefetch a certain section if it needs to be prefetched.
* If the section is "All sections" it will be ignored.
*
* @param section Section to prefetch.
* @param courseId Course ID the section belongs to.
* @return Promise resolved when the section is prefetched.
*/
protected prefetchSingleSectionIfNeeded(): void {
// @todo params and logic
}
/**
* Start or restore the prefetch of a section.
* If the section is "All sections" it will be ignored.
*
* @param section Section to download.
* @param result Result of CoreCourseModulePrefetchDelegate.getModulesStatus for this section.
* @param courseId Course ID the section belongs to.
* @return Promise resolved when the section has been prefetched.
*/
protected prefetchSingleSection(): void {
// @todo params and logic
}
/**
* Check if a section has content.
*
* @param section Section to check.
* @return Whether the section has content.
* @todo section type.
*/
sectionHasContent(section: any): boolean {
if (section.hiddenbynumsections) {
return false;
}
return (typeof section.availabilityinfo != 'undefined' && section.availabilityinfo != '') ||
section.summary != '' || (section.modules && section.modules.length > 0);
}
/**
* Wait for any course format plugin to load, and open the course page.
*
* If the plugin's promise is resolved, the course page will be opened. If it is rejected, they will see an error.
* If the promise for the plugin is still in progress when the user tries to open the course, a loader
* will be displayed until it is complete, before the course page is opened. If the promise is already complete,
* they will see the result immediately.
*
* @param course Course to open
* @param params Params to pass to the course page.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
openCourse(course: CoreEnrolledCourseBasicData | { id: number }, params?: Params, siteId?: string): Promise<void> {
if (!siteId || siteId == CoreSites.instance.getCurrentSiteId()) {
// Current site, we can open the course.
return CoreCourse.instance.openCourse(course, params);
} else {
// We need to load the site first.
params = params || {};
Object.assign(params, { course: course });
return CoreLoginHelper.instance.redirect(CoreLoginHelperProvider.OPEN_COURSE, params, siteId);
}
}
/**
* Delete course files.
*
* @param courseId Course id.
* @return Promise to be resolved once the course files are deleted.
*/
async deleteCourseFiles(courseId: number): Promise<void> {
const sections = await CoreCourse.instance.getSections(courseId);
const modules = CoreArray.flatten(sections.map((section) => section.modules));
await Promise.all(
modules.map((module) => this.removeModuleStoredData(module, courseId)),
);
await CoreCourse.instance.setCourseStatus(courseId, CoreConstants.NOT_DOWNLOADED);
}
/**
* Remove module stored data.
*
* @param module Module to remove the files.
* @param courseId Course ID the module belongs to.
* @return Promise resolved when done.
*/
// @todo remove when done.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async removeModuleStoredData(module: any, courseId: number): Promise<void> {
const promises: Promise<void>[] = [];
// @todo
// promises.push(this.prefetchDelegate.removeModuleFiles(module, courseId));
// @todo
// const handler = this.prefetchDelegate.getPrefetchHandlerFor(module);
// if (handler) {
// promises.push(CoreSites.instance.getCurrentSite().deleteComponentFromCache(handler.component, module.id));
// }
await Promise.all(promises);
}
}
export class CoreCourseHelper extends makeSingleton(CoreCourseHelperProvider) {}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,56 @@
// (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 { FormsModule } from '@angular/forms';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CorePipesModule } from '@pipes/pipes.module';
import { CoreCoursesCourseListItemComponent } from './course-list-item/course-list-item';
import { CoreCoursesCourseProgressComponent } from './course-progress/course-progress';
import { CoreCoursesCourseOptionsMenuComponent } from './course-options-menu/course-options-menu';
import { CoreCoursesSelfEnrolPasswordComponent } from './self-enrol-password/self-enrol-password';
@NgModule({
declarations: [
CoreCoursesCourseListItemComponent,
CoreCoursesCourseProgressComponent,
CoreCoursesCourseOptionsMenuComponent,
CoreCoursesSelfEnrolPasswordComponent,
],
imports: [
CommonModule,
IonicModule,
FormsModule,
TranslateModule.forChild(),
CoreComponentsModule,
CoreDirectivesModule,
CorePipesModule,
],
exports: [
CoreCoursesCourseListItemComponent,
CoreCoursesCourseProgressComponent,
CoreCoursesCourseOptionsMenuComponent,
CoreCoursesSelfEnrolPasswordComponent,
],
entryComponents: [
CoreCoursesCourseOptionsMenuComponent,
],
})
export class CoreCoursesComponentsModule {}

View File

@ -0,0 +1,34 @@
<ion-item class="ion-text-wrap" (click)="openCourse()" [class.item-disabled]="course.visible == 0"
[title]="course.displayname || course.fullname" detail>
<ion-icon *ngIf="!course.courseImage" name="fas-graduation-cap" slot="start" class="course-icon"
[attr.course-color]="course.color ? null : course.colorNumber" [style.color]="course.color"></ion-icon>
<ion-avatar *ngIf="course.courseImage" slot="start">
<img [src]="course.courseImage" core-external-content alt=""/>
</ion-avatar>
<ion-label>
<p *ngIf="course.categoryname || (course.displayname && course.shortname && course.fullname != course.displayname)"
class="core-course-additional-info">
<span *ngIf="course.categoryname" class="core-course-category">
<core-format-text [text]="course.categoryname"></core-format-text>
</span>
<span *ngIf="course.categoryname && course.displayname && course.shortname && course.fullname != course.displayname"
class="core-course-category"> | </span>
<span *ngIf="course.displayname && course.shortname && course.fullname != course.displayname"
class="core-course-shortname">
<core-format-text [text]="course.shortname" contextLevel="course" [contextInstanceId]="course.id">
</core-format-text>
</span>
</p>
<h2>
<core-format-text [text]="course.displayname || course.fullname" contextLevel="course" [contextInstanceId]="course.id">
</core-format-text>
</h2>
<p *ngIf="isEnrolled && course.progress != null && course.progress! >= 0 && course.completionusertracked !== false">
<core-progress-bar [progress]="course.progress"></core-progress-bar>
</p>
</ion-label>
<ng-container *ngIf="!isEnrolled">
<ion-icon *ngFor="let icon of icons" color="dark" size="small"
[name]="icon.icon" [attr.aria-label]="icon.label | translate" slot="end"></ion-icon>
</ng-container>
</ion-item>

View File

@ -0,0 +1,48 @@
:host {
.course-icon {
color: white;
background: var(--gray-light);
padding: 8px;
font-size: 24px;
border-radius: 50%;
margin-inline-end: 16px;
-webkit-transition: all 50ms ease-in-out;
transition: all 50ms ease-in-out;
}
ion-icon[course-color="0"] {
color: var(--core-course-color-0);
}
ion-icon[course-color="1"] {
color: var(--core-course-color-1);
}
ion-icon[course-color="2"] {
color: var(--core-course-color-2);
}
ion-icon[course-color="3"] {
color: var(--core-course-color-3);
}
ion-icon[course-color="4"] {
color: var(--core-course-color-4);
}
ion-icon[course-color="5"] {
color: var(--core-course-color-5);
}
ion-icon[course-color="6"] {
color: var(--core-course-color-6);
}
ion-icon[course-color="7"] {
color: var(--core-course-color-7);
}
ion-icon[course-color="8"] {
color: var(--core-course-color-8);
}
ion-icon[course-color="9"] {
color: var(--core-course-color-9);
}
ion-avatar {
-webkit-transition: all 50ms ease-in-out;
transition: all 50ms ease-in-out;
}
}

View File

@ -0,0 +1,115 @@
// (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 { NavController } from '@ionic/angular';
import { CoreCourses, CoreCourseSearchedData } from '../../services/courses';
import { CoreCoursesHelper, CoreCourseWithImageAndColor } from '../../services/courses.helper';
/**
* This directive is meant to display an item for a list of courses.
*
* Example usage:
*
* <core-courses-course-list-item [course]="course"></core-courses-course-list-item>
*/
@Component({
selector: 'core-courses-course-list-item',
templateUrl: 'core-courses-course-list-item.html',
styleUrls: ['course-list-item.scss'],
})
export class CoreCoursesCourseListItemComponent implements OnInit {
@Input() course!: CoreCourseSearchedData & CoreCourseWithImageAndColor & {
completionusertracked?: boolean; // If the user is completion tracked.
progress?: number; // Progress percentage.
}; // The course to render.
icons: CoreCoursesEnrolmentIcons[] = [];
isEnrolled = false;
constructor(
protected navCtrl: NavController,
) {
}
/**
* Component being initialized.
*/
async ngOnInit(): Promise<void> {
CoreCoursesHelper.instance.loadCourseColorAndImage(this.course);
// Check if the user is enrolled in the course.
try {
const course = await CoreCourses.instance.getUserCourse(this.course.id);
this.course.progress = course.progress;
this.course.completionusertracked = course.completionusertracked;
this.isEnrolled = true;
} catch {
this.isEnrolled = false;
this.icons = [];
this.course.enrollmentmethods.forEach((instance) => {
if (instance === 'self') {
this.icons.push({
label: 'core.courses.selfenrolment',
icon: 'fas-key',
});
} else if (instance === 'guest') {
this.icons.push({
label: 'core.courses.allowguests',
icon: 'fas-unlock',
});
} else if (instance === 'paypal') {
this.icons.push({
label: 'core.courses.paypalaccepted',
icon: 'fab-paypal',
});
}
});
if (this.icons.length == 0) {
this.icons.push({
label: 'core.courses.notenrollable',
icon: 'fas-lock',
});
}
}
}
/**
* Open a course.
*
* @param course The course to open.
*/
openCourse(): void {
/* if (this.isEnrolled) {
CoreCourseHelper.instance.openCourse(this.course);
} else {
this.navCtrl.navigateForward('/courses/preview', { queryParams: { course: this.course } });
} */
// @todo while opencourse function is not completed, open preview page.
this.navCtrl.navigateForward('/courses/preview', { queryParams: { course: this.course } });
}
}
/**
* Enrolment icons to show on the list with a label.
*/
export type CoreCoursesEnrolmentIcons = {
label: string;
icon: string;
};

View File

@ -0,0 +1,26 @@
<ion-item button class="ion-text-wrap" (click)="action('download')" *ngIf="downloadCourseEnabled">
<ion-icon *ngIf="!prefetch.loading" [name]="prefetch.icon" slot="start"></ion-icon>
<ion-spinner *ngIf="prefetch.loading" slot="start"></ion-spinner>
<ion-label><h2>{{ prefetch.statusTranslatable | translate }}</h2></ion-label>
</ion-item>
<ion-item button class="ion-text-wrap" (click)="action('delete')" *ngIf="prefetch.status == 'downloaded' || prefetch.status == 'outdated'">
<ion-icon name="fas-trash" slot="start"></ion-icon>
<ion-label><h2>{{ 'addon.storagemanager.deletecourse' | translate }}</h2></ion-label>
</ion-item>
<ion-item button class="ion-text-wrap" (click)="action('hide')" *ngIf="!course.hidden">
<ion-icon name="fas-eye" slot="start"></ion-icon>
<ion-label><h2>{{ 'core.courses.hidecourse' | translate }}</h2></ion-label>
</ion-item>
<ion-item button class="ion-text-wrap" (click)="action('show')" *ngIf="course.hidden">
<ion-icon name="fas-eye-slash" slot="start"></ion-icon>
<ion-label><h2>{{ 'core.courses.show' | translate }}</h2></ion-label>
</ion-item>
<ion-item button class="ion-text-wrap" (click)="action('favourite')" *ngIf="!course.isfavourite">
<ion-icon name="fas-star" slot="start"></ion-icon>
<ion-label><h2>{{ 'core.courses.addtofavourites' | translate }}</h2></ion-label>
</ion-item>
<ion-item button class="ion-text-wrap" (click)="action('unfavourite')" *ngIf="course.isfavourite">
<ion-icon name="far-star" slot="start"></ion-icon>
<ion-label><h2>{{ 'core.courses.removefromfavourites' | translate }}</h2></ion-label>
</ion-item>

View File

@ -0,0 +1,59 @@
// (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 { NavParams, PopoverController } from '@ionic/angular';
import { CoreCourses } from '../../services/courses';
import { CoreEnrolledCourseDataWithExtraInfoAndOptions } from '../../services/courses.helper';
import { CorePrefetchStatusInfo } from '@features/course/services/course.helper';
/**
* This component is meant to display a popover with the course options.
*/
@Component({
selector: 'core-courses-course-options-menu',
templateUrl: 'core-courses-course-options-menu.html',
})
export class CoreCoursesCourseOptionsMenuComponent implements OnInit {
course!: CoreEnrolledCourseDataWithExtraInfoAndOptions; // The course.
prefetch!: CorePrefetchStatusInfo; // The prefecth info.
downloadCourseEnabled = false;
constructor(
navParams: NavParams,
protected popoverController: PopoverController,
) {
this.course = navParams.get('course') || {};
this.prefetch = navParams.get('prefetch') || {};
}
/**
* Component being initialized.
*/
ngOnInit(): void {
this.downloadCourseEnabled = !CoreCourses.instance.isDownloadCourseDisabledInSite();
}
/**
* Do an action over the course.
*
* @param action Action name to take.
*/
action(action: string): void {
this.popoverController.dismiss(action);
}
}

View File

@ -0,0 +1,57 @@
<ion-card [attr.course-color]="course.color ? null : course.colorNumber">
<div (click)="openCourse()" class="core-course-thumb" [class.core-course-color-img]="course.courseImage"
[style.background-color]="course.color">
<img *ngIf="course.courseImage" [src]="course.courseImage" core-external-content alt=""/>
</div>
<ion-item button lines="none" (click)="openCourse()" [title]="course.displayname || course.fullname"
class="core-course-header" [class.item-disabled]="course.visible == 0"
[class.core-course-more-than-title]="(course.progress != null && course.progress! >= 0)">
<ion-label
class="ion-text-wrap core-course-title"
[class.core-course-with-buttons]="courseOptionMenuEnabled || (downloadCourseEnabled && showDownload)"
[class.core-course-with-spinner]="(downloadCourseEnabled && prefetchCourseData.icon == 'spinner') || showSpinner">
<p *ngIf="course.categoryname || (course.displayname && course.shortname && course.fullname != course.displayname)"
class="core-course-additional-info">
<span *ngIf="course.categoryname" class="core-course-category">
<core-format-text [text]="course.categoryname"></core-format-text>
</span>
<span *ngIf="course.categoryname && course.displayname && course.shortname && course.fullname != course.displayname"
class="core-course-category"> | </span>
<span *ngIf="course.displayname && course.shortname && course.fullname != course.displayname"
class="core-course-shortname">
<core-format-text [text]="course.shortname" contextLevel="course" [contextInstanceId]="course.id">
</core-format-text>
</span>
</p>
<h2>
<ion-icon name="fas-star" *ngIf="course.isfavourite"></ion-icon>
<core-format-text [text]="course.fullname" contextLevel="course" [contextInstanceId]="course.id"></core-format-text>
</h2>
</ion-label>
<div class="core-button-spinner" *ngIf="downloadCourseEnabled && !courseOptionMenuEnabled && showDownload" slot="end">
<core-download-refresh
[status]="prefetchCourseData.status"
[enabled]="downloadCourseEnabled"
[statusTranslatable]="prefetchCourseData.statusTranslatable"
canTrustDownload="false"
[loading]="prefetchCourseData.loading"
action="prefetchCourse()"></core-download-refresh>
</div>
<div class="core-button-spinner" *ngIf="courseOptionMenuEnabled" slot="end">
<!-- Download course spinner. -->
<ion-spinner *ngIf="(downloadCourseEnabled && prefetchCourseData.icon == 'spinner') || showSpinner"></ion-spinner>
<!-- Options menu. -->
<ion-button fill="clear" color="dark" (click)="showCourseOptionsMenu($event)" *ngIf="!showSpinner"
[attr.aria-label]="('core.displayoptions' | translate)">
<ion-icon name="ellipsis-vertical" slot="icon-only"></ion-icon>
</ion-button>
</div>
</ion-item>
<ion-item *ngIf="showAll && course.progress != null && course.progress! >= 0 && course.completionusertracked !== false" lines="none">
<ion-label><core-progress-bar [progress]="course.progress"></core-progress-bar></ion-label>
</ion-item>
<ng-content></ng-content>
</ion-card>

View File

@ -0,0 +1,163 @@
:host {
ion-card {
display: flex;
flex-direction: column;
align-self: stretch;
height: calc(100% - 20px);
&[course-color="0"] .core-course-thumb {
background: var(--core-course-color-0);
}
&[course-color="1"] .core-course-thumb {
background: var(--core-course-color-1);
}
&[course-color="2"] .core-course-thumb {
background: var(--core-course-color-2);
}
&[course-color="3"] .core-course-thumb {
background: var(--core-course-color-3);
}
&[course-color="4"] .core-course-thumb {
background: var(--core-course-color-4);
}
&[course-color="5"] .core-course-thumb {
background: var(--core-course-color-5);
}
&[course-color="6"] .core-course-thumb {
background: var(--core-course-color-6);
}
&[course-color="7"] .core-course-thumb {
background: var(--core-course-color-7);
}
&[course-color="8"] .core-course-thumb {
background: var(--core-course-color-8);
}
&[course-color="9"] .core-course-thumb {
background: var(--core-course-color-9);
}
.core-course-thumb {
padding-top: 40%;
width: 100%;
overflow: hidden;
cursor: pointer;
pointer-events: auto;
position: relative;
background-position: center;
background-size: cover;
-webkit-transition: all 50ms ease-in-out;
transition: all 50ms ease-in-out;
&.core-course-color-img {
background: white;
}
img {
position: absolute;
top: 0;
bottom: 0;
margin: auto;
}
}
.core-course-additional-info {
margin-bottom: 8px;
}
.core-course-header {
padding-top: 8px;
padding-bottom: 8px;
.core-course-title {
margin: 5px 0;
flex-grow: 1;
h2 ion-icon {
margin-right: 4px;
color: var(--core-star-color);
}
}
&.core-course-more-than-title {
padding-bottom: 0;
}
.core-button-spinner {
margin: 0;
}
.core-button-spinner ion-spinner {
vertical-align: top; // the better option for most scenarios
vertical-align: -webkit-baseline-middle; // the best for those that support it
}
.core-button-spinner .core-icon-downloaded {
font-size: 28.8px;
margin-top: 8px;
vertical-align: top;
}
.item-button[icon-only] {
min-width: 50px;
width: 50px;
}
}
}
button {
z-index: 1;
}
}
// @todo
:host-context(.core-horizontal-scroll) {
/*@include horizontal_scroll_item(80%, 250px, 300px);*/
ion-card {
.core-course-thumb {
padding-top: 30%;
}
.core-course-link {
/*@include padding(4px, 0px, 4px, 8px);*/
.core-course-additional-info {
font-size: 1.2rem;
}
.core-course-title {
margin: 3px 0;
h2 {
font-size: 1.5rem;
ion-icon {
margin-right: 2px;
}
}
&.core-course-with-buttons {
max-width: calc(100% - 40px);
}
}
.core-button-spinner {
min-height: 40px;
min-width: 40px;
ion-spinner {
width: 20px;
height: 20px;
}
}
.item-button[icon-only] {
min-width: 40px;
width: 40px;
font-size: 1.5rem;
padding: 8px;
}
}
}
}
:host-context(body.version-3-1) {
.core-course-thumb{
display: none;
}
}

View File

@ -0,0 +1,283 @@
// (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, OnDestroy } from '@angular/core';
import { PopoverController } from '@ionic/angular';
import { CoreEventCourseStatusChanged, CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
// import { CoreUserProvider } from '@core/user/providers/user';
import { CoreCourses } from '@features/courses/services/courses';
import { CoreCourse, CoreCourseProvider } from '@features/course/services/course';
import { CoreCourseHelper, CorePrefetchStatusInfo } from '@features/course/services/course.helper';
import { Translate } from '@singletons/core.singletons';
import { CoreConstants } from '@/core/constants';
import { CoreEnrolledCourseDataWithExtraInfoAndOptions } from '../../services/courses.helper';
import { CoreCoursesCourseOptionsMenuComponent } from '../course-options-menu/course-options-menu';
/**
* This component is meant to display a course for a list of courses with progress.
*
* Example usage:
*
* <core-courses-course-progress [course]="course">
* </core-courses-course-progress>
*/
@Component({
selector: 'core-courses-course-progress',
templateUrl: 'core-courses-course-progress.html',
styleUrls: ['course-progress.scss'],
})
export class CoreCoursesCourseProgressComponent implements OnInit, OnDestroy {
@Input() course!: CoreEnrolledCourseDataWithExtraInfoAndOptions; // The course to render.
@Input() showAll = false; // If true, will show all actions, options, star and progress.
@Input() showDownload = true; // If true, will show download button. Only works if the options menu is not shown.
courseStatus = CoreConstants.NOT_DOWNLOADED;
isDownloading = false;
prefetchCourseData: CorePrefetchStatusInfo = {
icon: '',
statusTranslatable: 'core.loading',
status: '',
loading: true,
};
showSpinner = false;
downloadCourseEnabled = false;
courseOptionMenuEnabled = false;
protected isDestroyed = false;
protected courseStatusObserver?: CoreEventObserver;
protected siteUpdatedObserver?: CoreEventObserver;
constructor(
protected popoverCtrl: PopoverController,
) { }
/**
* Component being initialized.
*/
ngOnInit(): void {
this.downloadCourseEnabled = !CoreCourses.instance.isDownloadCourseDisabledInSite();
if (this.downloadCourseEnabled) {
this.initPrefetchCourse();
}
// This field is only available from 3.6 onwards.
this.courseOptionMenuEnabled = this.showAll && typeof this.course.isfavourite != 'undefined';
// Refresh the enabled flag if site is updated.
this.siteUpdatedObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => {
const wasEnabled = this.downloadCourseEnabled;
this.downloadCourseEnabled = !CoreCourses.instance.isDownloadCourseDisabledInSite();
if (!wasEnabled && this.downloadCourseEnabled) {
// Download course is enabled now, initialize it.
this.initPrefetchCourse();
}
}, CoreSites.instance.getCurrentSiteId());
}
/**
* Initialize prefetch course.
*/
async initPrefetchCourse(): Promise<void> {
if (typeof this.courseStatusObserver != 'undefined') {
// Already initialized.
return;
}
// Listen for status change in course.
this.courseStatusObserver = CoreEvents.on(CoreEvents.COURSE_STATUS_CHANGED, (data: CoreEventCourseStatusChanged) => {
if (data.courseId == this.course.id || data.courseId == CoreCourseProvider.ALL_COURSES_CLEARED) {
this.updateCourseStatus(data.status);
}
}, CoreSites.instance.getCurrentSiteId());
// Determine course prefetch icon.
const status = await CoreCourse.instance.getCourseStatus(this.course.id);
this.prefetchCourseData = CoreCourseHelper.instance.getCourseStatusIconAndTitleFromStatus(status);
this.courseStatus = status;
if (this.prefetchCourseData.loading) {
// Course is being downloaded. Get the download promise.
const promise = CoreCourseHelper.instance.getCourseDownloadPromise(this.course.id);
if (promise) {
// There is a download promise. If it fails, show an error.
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.
CoreCourse.instance.setCoursePreviousStatus(this.course.id);
}
}
}
/**
* Open a course.
*/
openCourse(): void {
CoreCourseHelper.instance.openCourse(this.course);
}
/**
* Prefetch the course.
*
* @param e Click event.
*/
prefetchCourse(e: Event): void {
e.preventDefault();
e.stopPropagation();
/* @ todo try {
CoreCourseHelper.instance.confirmAndPrefetchCourse(this.prefetchCourseData, this.course);
} catch (error) {
if (!this.isDestroyed) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true);
}
}*/
}
/**
* Delete the course.
*/
async deleteCourse(): Promise<void> {
try {
await CoreDomUtils.instance.showDeleteConfirm('core.course.confirmdeletestoreddata');
} catch (error) {
if (CoreDomUtils.instance.isCanceledError(error)) {
throw error;
}
return;
}
const modal = await CoreDomUtils.instance.showModalLoading();
try {
await CoreCourseHelper.instance.deleteCourseFiles(this.course.id);
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, Translate.instance.instant('core.errordeletefile'));
} finally {
modal.dismiss();
}
}
/**
* Update the course status icon and title.
*
* @param status Status to show.
*/
protected updateCourseStatus(status: string): void {
this.prefetchCourseData = CoreCourseHelper.instance.getCourseStatusIconAndTitleFromStatus(status);
this.courseStatus = status;
}
/**
* Show the context menu.
*
* @param e Click Event.
* @todo
*/
async showCourseOptionsMenu(e: Event): Promise<void> {
e.preventDefault();
e.stopPropagation();
const popover = await this.popoverCtrl.create({
component: CoreCoursesCourseOptionsMenuComponent,
componentProps: {
course: this.course,
courseStatus: this.courseStatus,
prefetch: this.prefetchCourseData,
},
event: e,
});
popover.present();
const action = await popover.onDidDismiss<string>();
if (action.data) {
switch (action.data) {
case 'download':
if (!this.prefetchCourseData.loading) {
this.prefetchCourse(e);
}
break;
case 'delete':
if (this.courseStatus == 'downloaded' || this.courseStatus == 'outdated') {
this.deleteCourse();
}
break;
case 'hide':
this.setCourseHidden(true);
break;
case 'show':
this.setCourseHidden(false);
break;
case 'favourite':
this.setCourseFavourite(true);
break;
case 'unfavourite':
this.setCourseFavourite(false);
break;
default:
break;
}
}
}
/**
* Hide/Unhide the course from the course list.
*
* @param hide True to hide and false to show.
* @todo
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected setCourseHidden(hide: boolean): void {
return;
}
/**
* Favourite/Unfavourite the course from the course list.
*
* @param favourite True to favourite and false to unfavourite.
* @todo
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected setCourseFavourite(favourite: boolean): void {
return;
}
/**
* Component destroyed.
*/
ngOnDestroy(): void {
this.isDestroyed = true;
this.siteUpdatedObserver?.off();
this.courseStatusObserver?.off();
}
}

View File

@ -0,0 +1,34 @@
<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.courses.selfenrolment' | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="close()" [attr.aria-label]="'core.close' | translate">
<ion-icon name="fas-times" slot="icon-only"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<form (ngSubmit)="submitPassword($event)" #enrolPasswordForm>
<ion-item>
<core-show-password [name]="'password'">
<ion-input
class="ion-text-wrap core-ioninput-password"
name="password"
type="password"
placeholder="{{ 'core.courses.password' | translate }}"
[(ngModel)]="password"
[core-auto-focus]
[clearOnEdit]="false">
</ion-input>
</core-show-password>
</ion-item>
<div class="ion-padding">
<ion-button expand="block" [disabled]="!password" type="submit">{{ 'core.courses.enrolme' | translate }}</ion-button>
</div>
</form>
</ion-content>

View File

@ -0,0 +1,63 @@
// (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, ElementRef } from '@angular/core';
import { ModalController, NavParams } from '@ionic/angular';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
/**
* Modal that displays a form to enter a password to self enrol in a course.
*/
@Component({
selector: 'page-core-courses-self-enrol-password',
templateUrl: 'self-enrol-password.html',
})
export class CoreCoursesSelfEnrolPasswordComponent {
@ViewChild('enrolPasswordForm') formElement!: ElementRef;
password = '';
constructor(
protected modalCtrl: ModalController,
navParams: NavParams,
) {
this.password = navParams.get('password') || '';
}
/**
* Close help modal.
*/
close(): void {
CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId());
this.modalCtrl.dismiss();
}
/**
* Submit password.
*
* @param e Event.
* @param password Password to submit.
*/
submitPassword(e: Event): void {
e.preventDefault();
e.stopPropagation();
CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, false, CoreSites.instance.getCurrentSiteId());
this.modalCtrl.dismiss(this.password);
}
}

View File

@ -13,24 +13,86 @@
// limitations under the License.
import { NgModule } from '@angular/core';
import { Routes } from '@angular/router';
import { RouterModule, Routes } from '@angular/router';
import { CoreHomeRoutingModule } from '../mainmenu/pages/home/home-routing.module';
import { CoreHomeDelegate } from '../mainmenu/services/home.delegate';
import { CoreDashboardHomeHandler } from './services/handlers/dashboard.home';
import { CoreCoursesMyCoursesHomeHandler } from './services/handlers/my-courses.home';
const routes: Routes = [
const homeRoutes: Routes = [
{
path: 'dashboard',
loadChildren: () =>
import('@features/courses/pages/dashboard/dashboard.page.module').then(m => m.CoreCoursesDashboardPageModule),
},
{
path: 'courses/my',
loadChildren: () =>
import('@features/courses/pages/my-courses/my-courses.page.module')
.then(m => m.CoreCoursesMyCoursesPageModule),
},
];
const routes: Routes = [
{
path: 'courses',
children: [
{
path: '',
redirectTo: 'my',
pathMatch: 'full',
},
{
path: 'categories',
redirectTo: 'categories/root', // Fake "id".
pathMatch: 'full',
},
{
path: 'categories/:id',
loadChildren: () =>
import('@features/courses/pages/categories/categories.page.module')
.then(m => m.CoreCoursesCategoriesPageModule),
},
{
path: 'all',
loadChildren: () =>
import('@features/courses/pages/available-courses/available-courses.page.module')
.then(m => m.CoreCoursesAvailableCoursesPageModule),
},
{
path: 'search',
loadChildren: () =>
import('@features/courses/pages/search/search.page.module')
.then(m => m.CoreCoursesSearchPageModule),
},
{
path: 'my',
loadChildren: () =>
import('@features/courses/pages/my-courses/my-courses.page.module')
.then(m => m.CoreCoursesMyCoursesPageModule),
},
{
path: 'preview',
loadChildren: () =>
import('@features/courses/pages/course-preview/course-preview.page.module')
.then(m => m.CoreCoursesCoursePreviewPageModule),
},
],
},
];
@NgModule({
imports: [CoreHomeRoutingModule.forChild(routes)],
exports: [CoreHomeRoutingModule],
imports: [
CoreHomeRoutingModule.forChild(homeRoutes),
RouterModule.forChild(routes),
],
exports: [
CoreHomeRoutingModule,
RouterModule,
],
providers: [
CoreDashboardHomeHandler,
CoreCoursesMyCoursesHomeHandler,
],
})
export class CoreCoursesModule {
@ -38,8 +100,10 @@ export class CoreCoursesModule {
constructor(
homeDelegate: CoreHomeDelegate,
coursesDashboardHandler: CoreDashboardHomeHandler,
coursesMyCoursesHandler: CoreCoursesMyCoursesHomeHandler,
) {
homeDelegate.registerHandler(coursesDashboardHandler);
homeDelegate.registerHandler(coursesMyCoursesHandler);
}
}

View File

@ -0,0 +1,19 @@
<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.courses.availablecourses' | translate }}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!coursesLoaded" (ionRefresh)="refreshCourses($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="coursesLoaded">
<ng-container *ngIf="courses.length > 0">
<core-courses-course-list-item *ngFor="let course of courses" [course]="course"></core-courses-course-list-item>
</ng-container>
<core-empty-box *ngIf="!courses.length" icon="fas-graduation-cap" [message]="'core.courses.nocourses' | translate"></core-empty-box>
</core-loading>
</ion-content>

View File

@ -0,0 +1,50 @@
// (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 { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CoreCoursesComponentsModule } from '../../components/components.module';
import { CoreCoursesAvailableCoursesPage } from './available-courses.page';
const routes: Routes = [
{
path: '',
component: CoreCoursesAvailableCoursesPage,
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
CommonModule,
IonicModule,
TranslateModule.forChild(),
CoreComponentsModule,
CoreDirectivesModule,
CoreCoursesComponentsModule,
],
declarations: [
CoreCoursesAvailableCoursesPage,
],
exports: [RouterModule],
})
export class CoreCoursesAvailableCoursesPageModule { }

View File

@ -0,0 +1,78 @@
// (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 { IonRefresher } from '@ionic/angular';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreCourses, CoreCourseSearchedData } from '../../services/courses';
/**
* Page that displays available courses in current site.
*/
@Component({
selector: 'page-core-courses-available-courses',
templateUrl: 'available-courses.html',
})
export class CoreCoursesAvailableCoursesPage implements OnInit {
courses: CoreCourseSearchedData[] = [];
coursesLoaded = false;
/**
* View loaded.
*/
ngOnInit(): void {
this.loadCourses().finally(() => {
this.coursesLoaded = true;
});
}
/**
* Load the courses.
*
* @return Promise resolved when done.
*/
protected async loadCourses(): Promise<void> {
const frontpageCourseId = CoreSites.instance.getCurrentSite()!.getSiteHomeId();
try {
const courses = await CoreCourses.instance.getCoursesByField();
this.courses = courses.filter((course) => course.id != frontpageCourseId);
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.courses.errorloadcourses', true);
}
}
/**
* Refresh the courses.
*
* @param refresher Refresher.
*/
refreshCourses(refresher: CustomEvent<IonRefresher>): void {
const promises: Promise<void>[] = [];
promises.push(CoreCourses.instance.invalidateUserCourses());
promises.push(CoreCourses.instance.invalidateCoursesByField());
Promise.all(promises).finally(() => {
this.loadCourses().finally(() => {
refresher?.detail.complete();
});
});
}
}

View File

@ -0,0 +1,67 @@
<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="coursecat" [contextInstanceId]="currentCategory && currentCategory!.id">
</core-format-text>
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!categoriesLoaded" (ionRefresh)="refreshCategories($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="categoriesLoaded">
<ion-item *ngIf="currentCategory" class="ion-text-wrap">
<ion-icon name="fas-folder" slot="start"></ion-icon>
<ion-label>
<h2>
<core-format-text [text]="currentCategory!.name" contextLevel="coursecat"
[contextInstanceId]="currentCategory!.id"></core-format-text>
</h2>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="currentCategory && currentCategory!.description">
<ion-label>
<h2>
<core-format-text [text]="currentCategory!.description" maxHeight="60" contextLevel="coursecat"
[contextInstanceId]="currentCategory!.id"></core-format-text>
</h2>
</ion-label>
</ion-item>
<div *ngIf="categories.length > 0">
<ion-item-divider>
<ion-label>
<h2>{{ 'core.courses.categories' | translate }}</h2>
</ion-label>
</ion-item-divider>
<section *ngFor="let category of categories">
<ion-item class="ion-text-wrap" router-direction="forward" [routerLink]="['/courses/categories', category.id]"
[title]="category.name" detail>
<ion-icon name="fas-folder" slot="start"></ion-icon>
<ion-label>
<h2>
<core-format-text [text]="category.name" contextLevel="coursecat" [contextInstanceId]="category.id">
</core-format-text>
</h2>
</ion-label>
<ion-badge slot="end" *ngIf="category.coursecount > 0" color="light">{{category.coursecount}}</ion-badge>
</ion-item>
</section>
</div>
<div *ngIf="courses.length > 0">
<ion-item-divider>
<ion-label>
<h2>{{ 'core.courses.courses' | translate }}</h2>
</ion-label>
</ion-item-divider>
<core-courses-course-list-item *ngFor="let course of courses" [course]="course"></core-courses-course-list-item>
</div>
<core-empty-box *ngIf="!categories.length && !courses.length" icon="fas-graduation-cap" [message]="'core.courses.nocoursesyet' | translate">
</core-empty-box>
</core-loading>
</ion-content>

View File

@ -0,0 +1,50 @@
// (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 { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CoreCoursesComponentsModule } from '../../components/components.module';
import { CoreCoursesCategoriesPage } from './categories.page';
const routes: Routes = [
{
path: '',
component: CoreCoursesCategoriesPage,
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
CommonModule,
IonicModule,
TranslateModule.forChild(),
CoreComponentsModule,
CoreDirectivesModule,
CoreCoursesComponentsModule,
],
declarations: [
CoreCoursesCategoriesPage,
],
exports: [RouterModule],
})
export class CoreCoursesCategoriesPageModule { }

View File

@ -0,0 +1,123 @@
// (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 { IonRefresher, NavController } from '@ionic/angular';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { CoreCategoryData, CoreCourses, CoreCourseSearchedData } from '../../services/courses';
import { Translate } from '@singletons/core.singletons';
import { ActivatedRoute } from '@angular/router';
/**
* Page that displays a list of categories and the courses in the current category if any.
*/
@Component({
selector: 'page-core-courses-categories',
templateUrl: 'categories.html',
})
export class CoreCoursesCategoriesPage implements OnInit {
title: string;
currentCategory?: CoreCategoryData;
categories: CoreCategoryData[] = [];
courses: CoreCourseSearchedData[] = [];
categoriesLoaded = false;
protected categoryId = 0;
constructor(
protected navCtrl: NavController,
protected route: ActivatedRoute,
) {
this.title = Translate.instance.instant('core.courses.categories');
}
/**
* View loaded.
*/
ngOnInit(): void {
this.categoryId = parseInt(this.route.snapshot.params['id'], 0) || 0;
this.fetchCategories().finally(() => {
this.categoriesLoaded = true;
});
}
/**
* Fetch the categories.
*
* @return Promise resolved when done.
*/
protected async fetchCategories(): Promise<void> {
try{
const categories: CoreCategoryData[] = await CoreCourses.instance.getCategories(this.categoryId, true);
this.currentCategory = undefined;
const index = categories.findIndex((category) => category.id == this.categoryId);
if (index >= 0) {
this.currentCategory = categories[index];
// Delete current Category to avoid problems with the formatTree.
delete categories[index];
}
// Sort by depth and sortorder to avoid problems formatting Tree.
categories.sort((a, b) => {
if (a.depth == b.depth) {
return (a.sortorder > b.sortorder) ? 1 : ((b.sortorder > a.sortorder) ? -1 : 0);
}
return a.depth > b.depth ? 1 : -1;
});
this.categories = CoreUtils.instance.formatTree(categories, 'parent', 'id', this.categoryId);
if (this.currentCategory) {
this.title = this.currentCategory.name;
try {
this.courses = await CoreCourses.instance.getCoursesByField('category', this.categoryId);
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.courses.errorloadcourses', true);
}
}
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.courses.errorloadcategories', true);
}
}
/**
* Refresh the categories.
*
* @param refresher Refresher.
*/
refreshCategories(refresher?: CustomEvent<IonRefresher>): void {
const promises: Promise<void>[] = [];
promises.push(CoreCourses.instance.invalidateUserCourses());
promises.push(CoreCourses.instance.invalidateCategories(this.categoryId, true));
promises.push(CoreCourses.instance.invalidateCoursesByField('category', this.categoryId));
promises.push(CoreSites.instance.getCurrentSite()!.invalidateConfig());
Promise.all(promises).finally(() => {
this.fetchCategories().finally(() => {
refresher?.detail.complete();
});
});
}
}

View File

@ -0,0 +1,125 @@
<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]="course?.fullname" contextLevel="course" [contextInstanceId]="course?.id"></core-format-text></ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!dataLoaded" (ionRefresh)="refreshData($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="dataLoaded">
<div class="core-course-thumb-parallax">
<div *ngIf="courseImageUrl" (click)="openCourse()" class="core-course-thumb">
<img [src]="courseImageUrl" core-external-content alt=""/>
</div>
</div>
<div class="core-course-thumb-parallax-content">
<ion-item class="ion-text-wrap" (click)="openCourse()" [title]="course.fullname" [attr.details]="!avoidOpenCourse && canAccessCourse">
<ion-icon name="fas-graduation-cap" fixed-width slot="start"></ion-icon>
<ion-label>
<h2><core-format-text [text]="course.fullname" contextLevel="course" [contextInstanceId]="course.id"></core-format-text></h2>
<p *ngIf="course.categoryname"><core-format-text [text]="course.categoryname"
contextLevel="coursecat" [contextInstanceId]="course.categoryid"></core-format-text></p>
<p *ngIf="course.startdate">
{{course.startdate * 1000 | coreFormatDate:"strftimedatefullshort" }}
<span *ngIf="course.enddate"> - {{course.enddate * 1000 | coreFormatDate:"strftimedatefullshort" }}</span>
</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="course.summary" detail="false">
<ion-label>
<core-format-text [text]="course.summary" maxHeight="120" contextLevel="course" [contextInstanceId]="course.id">
</core-format-text>
</ion-label>
</ion-item>
<ng-container class="ion-text-wrap" *ngIf="course.contacts && course.contacts.length">
<ion-item-divider>
<ion-label>
<h2>{{ 'core.teachers' | translate }}</h2>
</ion-label>
</ion-item-divider>
<ion-item class="ion-text-wrap" *ngFor="let contact of course.contacts" core-user-link
[userId]="contact.id"
[courseId]="isEnrolled ? course.id : null"
[attr.aria-label]="'core.viewprofile' | translate">
<ion-avatar core-user-avatar
[user]="contact" slot="start"
[userId]="contact.id"
[courseId]="isEnrolled ? course.id : null">
</ion-avatar>
<ion-label>
<h2>{{contact.fullname}}</h2>
</ion-label>
</ion-item>
<ion-item-divider></ion-item-divider>
</ng-container>
<ion-item class="ion-text-wrap" *ngIf="course.customfields">
<ion-label>
<ng-container *ngFor="let field of course.customfields">
<div *ngIf="field.value"
class="core-customfield core-customfield_{{field.type}} core-customfield_{{field.shortname}}">
<span class="core-customfieldname">
<core-format-text [text]="field.name" contextLevel="course" [contextInstanceId]="course.id">
</core-format-text>
</span><span class="core-customfieldseparator">: </span>
<span class="core-customfieldvalue">
<core-format-text [text]="field.value" maxHeight="120" contextLevel="course"
[contextInstanceId]="course.id">
</core-format-text>
</span>
</div>
</ng-container>
</ion-label>
</ion-item>
<div *ngIf="!isEnrolled" detail="false">
<ion-item class="ion-text-wrap" *ngFor="let instance of selfEnrolInstances">
<ion-label>
<h2>{{ instance.name }}</h2>
<ion-button expand="block" class="ion-margin-top" (click)="selfEnrolClicked(instance.id)">
{{ 'core.courses.enrolme' | translate }}
</ion-button>
</ion-label>
</ion-item>
</div>
<ion-item class="ion-text-wrap" *ngIf="!isEnrolled && paypalEnabled">
<ion-label>
<h2>{{ 'core.courses.paypalaccepted' | translate }}</h2>
<p>{{ 'core.paymentinstant' | translate }}</p>
<ion-button expand="block" class="ion-margin-top" (click)="paypalEnrol()" *ngIf="isMobile">
{{ 'core.courses.sendpaymentbutton' | translate }}
</ion-button>
</ion-label>
</ion-item>
<ion-item *ngIf="!isEnrolled && !selfEnrolInstances.length && !paypalEnabled">
<ion-label><p>{{ 'core.courses.notenrollable' | translate }}</p></ion-label>
</ion-item>
<ion-item *ngIf="canAccessCourse && downloadCourseEnabled" (click)="prefetchCourse()" detail="false"
[attr.aria-label]="prefetchCourseData.statusTranslatable | translate">
<ion-icon *ngIf="!prefetchCourseData.status != statusDownloaded && !prefetchCourseData.loading"
[name]="prefetchCourseData.icon" slot="start">
</ion-icon>
<ion-icon *ngIf="prefetchCourseData.status == statusDownloaded && !prefetchCourseData.loading"
slot="start" [name]="prefetchCourseData.icon" color="success"
[attr.aria-label]="prefetchCourseData.statusTranslatable | translate" role="status">
</ion-icon>
<ion-spinner *ngIf="prefetchCourseData.loading" slot="start"></ion-spinner>
<ion-label><h2>{{ 'core.course.downloadcourse' | translate }}</h2></ion-label>
</ion-item>
<ion-item (click)="openCourse()" [title]="course.fullname" *ngIf="!avoidOpenCourse && canAccessCourse">
<ion-icon name="fas-briefcase" slot="start"></ion-icon>
<ion-label><h2>{{ 'core.course.contents' | translate }}</h2></ion-label>
</ion-item>
<ion-item [href]="courseUrl" core-link [title]="course.fullname">
<ion-icon name="fas-external-link-alt" slot="start"></ion-icon>
<ion-label><h2>{{ 'core.openinbrowser' | translate }}</h2></ion-label>
</ion-item>
</div>
</core-loading>
</ion-content>

View File

@ -0,0 +1,51 @@
// (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 { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CorePipesModule } from '@pipes/pipes.module';
import { CoreCoursesCoursePreviewPage } from './course-preview.page';
import { CoreCoursesComponentsModule } from '../../components/components.module';
const routes: Routes = [
{
path: '',
component: CoreCoursesCoursePreviewPage,
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
CommonModule,
IonicModule,
TranslateModule.forChild(),
CoreComponentsModule,
CoreDirectivesModule,
CorePipesModule,
CoreCoursesComponentsModule,
],
declarations: [
CoreCoursesCoursePreviewPage,
],
exports: [RouterModule],
})
export class CoreCoursesCoursePreviewPageModule { }

View File

@ -0,0 +1,475 @@
// (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, OnDestroy, NgZone, OnInit } from '@angular/core';
import { ModalController, IonRefresher, NavController } from '@ionic/angular';
import { CoreApp } from '@services/app';
import { CoreEventCourseStatusChanged, CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text';
import {
CoreCourseEnrolmentMethod,
CoreCourseGetCoursesData,
CoreCourses,
CoreCourseSearchedData,
CoreCoursesProvider,
CoreEnrolledCourseData,
} from '@features/courses/services/courses';
// import { CoreCourseOptionsDelegate } from '@features/course/services/options-delegate';
import { CoreCourse, CoreCourseProvider } from '@features/course/services/course';
import { CoreCourseHelper, CorePrefetchStatusInfo } from '@features/course/services/course.helper';
import { Translate } from '@singletons/core.singletons';
import { ActivatedRoute } from '@angular/router';
import { CoreConstants } from '@/core/constants';
import { CoreCoursesSelfEnrolPasswordComponent } from '../../components/self-enrol-password/self-enrol-password';
/**
* Page that allows "previewing" a course and enrolling in it if enabled and not enrolled.
*/
@Component({
selector: 'page-core-courses-course-preview',
templateUrl: 'course-preview.html',
styleUrls: ['course-preview.scss'],
})
export class CoreCoursesCoursePreviewPage implements OnInit, OnDestroy {
course?: CoreCourseSearchedData;
isEnrolled = false;
canAccessCourse = true;
selfEnrolInstances: CoreCourseEnrolmentMethod[] = [];
paypalEnabled = false;
dataLoaded = false;
avoidOpenCourse = false;
prefetchCourseData: CorePrefetchStatusInfo = {
icon: '',
statusTranslatable: 'core.loading',
status: '',
loading: true,
};
statusDownloaded = CoreConstants.DOWNLOADED;
downloadCourseEnabled: boolean;
courseUrl = '';
courseImageUrl?: string;
protected isGuestEnabled = false;
protected guestInstanceId?: number;
protected enrolmentMethods: CoreCourseEnrolmentMethod[] = [];
protected waitStart = 0;
protected enrolUrl = '';
protected paypalReturnUrl = '';
protected isMobile: boolean;
protected pageDestroyed = false;
protected courseStatusObserver?: CoreEventObserver;
constructor(
protected modalCtrl: ModalController,
// protected courseOptionsDelegate: CoreCourseOptionsDelegate,
protected zone: NgZone,
protected route: ActivatedRoute,
protected navCtrl: NavController,
) {
this.isMobile = CoreApp.instance.isMobile();
this.downloadCourseEnabled = !CoreCourses.instance.isDownloadCourseDisabledInSite();
if (this.downloadCourseEnabled) {
// Listen for status change in course.
this.courseStatusObserver = CoreEvents.on(CoreEvents.COURSE_STATUS_CHANGED, (data: CoreEventCourseStatusChanged) => {
if (data.courseId == this.course!.id || data.courseId == CoreCourseProvider.ALL_COURSES_CLEARED) {
this.updateCourseStatus(data.status);
}
}, CoreSites.instance.getCurrentSiteId());
}
}
/**
* View loaded.
*/
async ngOnInit(): Promise<void> {
const navParams = this.route.snapshot.queryParams;
this.course = navParams['course'];
this.avoidOpenCourse = !!navParams['avoidOpenCourse'];
if (!this.course) {
this.navCtrl.back();
return;
}
const currentSite = CoreSites.instance.getCurrentSite();
const currentSiteUrl = currentSite && currentSite.getURL();
this.paypalEnabled = this.course!.enrollmentmethods?.indexOf('paypal') > -1;
this.enrolUrl = CoreTextUtils.instance.concatenatePaths(currentSiteUrl!, 'enrol/index.php?id=' + this.course!.id);
this.courseUrl = CoreTextUtils.instance.concatenatePaths(currentSiteUrl!, 'course/view.php?id=' + this.course!.id);
this.paypalReturnUrl = CoreTextUtils.instance.concatenatePaths(currentSiteUrl!, 'enrol/paypal/return.php');
if (this.course.overviewfiles.length > 0) {
this.courseImageUrl = this.course.overviewfiles[0].fileurl;
}
try {
await this.getCourse();
} finally {
if (this.downloadCourseEnabled) {
// Determine course prefetch icon.
this.prefetchCourseData = await CoreCourseHelper.instance.getCourseStatusIconAndTitle(this.course!.id);
if (this.prefetchCourseData.loading) {
// Course is being downloaded. Get the download promise.
const promise = CoreCourseHelper.instance.getCourseDownloadPromise(this.course!.id);
if (promise) {
// There is a download promise. If it fails, show an error.
promise.catch((error) => {
if (!this.pageDestroyed) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true);
}
});
} else {
// No download, this probably means that the app was closed while downloading. Set previous status.
CoreCourse.instance.setCoursePreviousStatus(this.course!.id);
}
}
}
}
}
/**
* Check if the user can access as guest.
*
* @return Promise resolved if can access as guest, rejected otherwise. Resolve param indicates if
* password is required for guest access.
*/
protected async canAccessAsGuest(): Promise<boolean> {
if (!this.isGuestEnabled) {
throw Error('Guest access is not enabled.');
}
// Search instance ID of guest enrolment method.
const method = this.enrolmentMethods.find((method) => method.type == 'guest');
this.guestInstanceId = method?.id;
if (this.guestInstanceId) {
const info = await CoreCourses.instance.getCourseGuestEnrolmentInfo(this.guestInstanceId);
if (!info.status) {
// Not active, reject.
throw Error('Guest access is not enabled.');
}
return info.passwordrequired;
}
throw Error('Guest enrollment method not found.');
}
/**
* Convenience function to get course. We use this to determine if a user can see the course or not.
*/
protected async getCourse(): Promise<void> {
// Get course enrolment methods.
this.selfEnrolInstances = [];
try {
this.enrolmentMethods = await CoreCourses.instance.getCourseEnrolmentMethods(this.course!.id);
this.enrolmentMethods.forEach((method) => {
if (method.type === 'self') {
this.selfEnrolInstances.push(method);
} else if (method.type === 'guest') {
this.isGuestEnabled = true;
}
});
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting enrolment data');
}
try {
let course: CoreEnrolledCourseData | CoreCourseGetCoursesData;
// Check if user is enrolled in the course.
try {
course = await CoreCourses.instance.getUserCourse(this.course!.id);
this.isEnrolled = true;
} catch {
// The user is not enrolled in the course. Use getCourses to see if it's an admin/manager and can see the course.
this.isEnrolled = false;
course = await CoreCourses.instance.getCourse(this.course!.id);
}
// Success retrieving the course, we can assume the user has permissions to view it.
this.course!.fullname = course.fullname || this.course!.fullname;
this.course!.summary = course.summary || this.course!.summary;
this.canAccessCourse = true;
} catch {
// The user is not an admin/manager. Check if we can provide guest access to the course.
try {
this.canAccessCourse = !(await this.canAccessAsGuest());
} catch {
this.canAccessCourse = false;
}
}
if (!CoreSites.instance.getCurrentSite()?.isVersionGreaterEqualThan('3.7')) {
try {
const available = await CoreCourses.instance.isGetCoursesByFieldAvailableInSite();
if (available) {
const course = await CoreCourses.instance.getCourseByField('id', this.course!.id);
this.course!.customfields = course.customfields;
}
} catch {
// Ignore errors.
}
}
this.dataLoaded = true;
}
/**
* Open the course.
*/
openCourse(): void {
if (!this.canAccessCourse || this.avoidOpenCourse) {
// Course cannot be opened or we are avoiding opening because we accessed from inside a course.
return;
}
CoreCourseHelper.instance.openCourse(this.course!);
}
/**
* Enrol using PayPal.
*/
async paypalEnrol(): Promise<void> {
// We cannot control browser in browser.
if (!this.isMobile || !CoreSites.instance.getCurrentSite()) {
return;
}
let hasReturnedFromPaypal = false;
const urlLoaded = (event: InAppBrowserEvent): void => {
if (event.url.indexOf(this.paypalReturnUrl) != -1) {
hasReturnedFromPaypal = true;
} else if (event.url.indexOf(this.courseUrl) != -1 && hasReturnedFromPaypal) {
// User reached the course index page after returning from PayPal, close the InAppBrowser.
inAppClosed();
window.close();
}
};
const inAppClosed = (): void => {
// InAppBrowser closed, refresh data.
unsubscribeAll();
if (!this.dataLoaded) {
return;
}
this.dataLoaded = false;
this.refreshData();
};
const unsubscribeAll = (): void => {
inAppLoadSubscription?.unsubscribe();
inAppExitSubscription?.unsubscribe();
};
// Open the enrolment page in InAppBrowser.
const window = await CoreSites.instance.getCurrentSite()!.openInAppWithAutoLogin(this.enrolUrl);
// Observe loaded pages in the InAppBrowser to check if the enrol process has ended.
const inAppLoadSubscription = window.on('loadstart').subscribe((event) => {
// Execute the callback in the Angular zone, so change detection doesn't stop working.
this.zone.run(() => urlLoaded(event));
});
// Observe window closed.
const inAppExitSubscription = window.on('exit').subscribe(() => {
// Execute the callback in the Angular zone, so change detection doesn't stop working.
this.zone.run(inAppClosed);
});
}
/**
* User clicked in a self enrol button.
*
* @param instanceId The instance ID of the enrolment method.
*/
async selfEnrolClicked(instanceId: number): Promise<void> {
try {
await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.courses.confirmselfenrol'));
this.selfEnrolInCourse('', instanceId);
} catch {
// User cancelled.
}
}
/**
* Self enrol in a course.
*
* @param password Password to use.
* @param instanceId The instance ID.
* @return Promise resolved when self enrolled.
*/
async selfEnrolInCourse(password: string, instanceId: number): Promise<void> {
const modal = await CoreDomUtils.instance.showModalLoading('core.loading', true);
try {
await CoreCourses.instance.selfEnrol(this.course!.id, password, instanceId);
// Close modal and refresh data.
this.isEnrolled = true;
this.dataLoaded = false;
// Sometimes the list of enrolled courses takes a while to be updated. Wait for it.
await this.waitForEnrolled(true);
this.refreshData().finally(() => {
// My courses have been updated, trigger event.
CoreEvents.trigger(CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, {
courseId: this.course!.id,
course: this.course,
action: CoreCoursesProvider.ACTION_ENROL,
}, CoreSites.instance.getCurrentSiteId());
});
modal?.dismiss();
} catch (error) {
modal?.dismiss();
if (error && error.errorcode === CoreCoursesProvider.ENROL_INVALID_KEY) {
// Initialize the self enrol modal.
const selfEnrolModal = await this.modalCtrl.create(
{
component: CoreCoursesSelfEnrolPasswordComponent,
componentProps: { password },
},
);
// Invalid password, show the modal to enter the password.
await selfEnrolModal.present();
const data = await selfEnrolModal.onDidDismiss<string>();
if (typeof data?.data != 'undefined') {
this.selfEnrolInCourse(data.data, instanceId);
return;
}
if (!password) {
// No password entered, don't show error.
return;
}
}
CoreDomUtils.instance.showErrorModalDefault(error, 'core.courses.errorselfenrol', true);
}
}
/**
* Refresh the data.
*
* @param refresher The refresher if this was triggered by a Pull To Refresh.
*/
async refreshData(refresher?: CustomEvent<IonRefresher>): Promise<void> {
const promises: Promise<void>[] = [];
promises.push(CoreCourses.instance.invalidateUserCourses());
promises.push(CoreCourses.instance.invalidateCourse(this.course!.id));
promises.push(CoreCourses.instance.invalidateCourseEnrolmentMethods(this.course!.id));
// @todo promises.push(this.courseOptionsDelegate.clearAndInvalidateCoursesOptions(this.course!.id));
if (CoreSites.instance.getCurrentSite() && !CoreSites.instance.getCurrentSite()!.isVersionGreaterEqualThan('3.7')) {
promises.push(CoreCourses.instance.invalidateCoursesByField('id', this.course!.id));
}
if (this.guestInstanceId) {
promises.push(CoreCourses.instance.invalidateCourseGuestEnrolmentInfo(this.guestInstanceId));
}
await Promise.all(promises).finally(() => this.getCourse()).finally(() => {
refresher?.detail.complete();
});
}
/**
* Update the course status icon and title.
*
* @param status Status to show.
*/
protected updateCourseStatus(status: string): void {
this.prefetchCourseData = CoreCourseHelper.instance.getCourseStatusIconAndTitleFromStatus(status);
}
/**
* Wait for the user to be enrolled in the course.
*
* @param first If it's the first call (true) or it's a recursive call (false).
* @return Promise resolved when enrolled or timeout.
*/
protected async waitForEnrolled(first?: boolean): Promise<void> {
if (first) {
this.waitStart = Date.now();
}
// Check if user is enrolled in the course.
try {
CoreCourses.instance.invalidateUserCourses();
} catch {
// Ignore errors.
}
try {
CoreCourses.instance.getUserCourse(this.course!.id);
} catch {
// Not enrolled, wait a bit and try again.
if (this.pageDestroyed || (Date.now() - this.waitStart > 60000)) {
// Max time reached or the user left the view, stop.
return;
}
return new Promise((resolve): void => {
setTimeout(async () => {
if (!this.pageDestroyed) {
// Wait again.
await this.waitForEnrolled();
}
resolve();
}, 5000);
});
}
}
/**
* Prefetch the course.
*/
prefetchCourse(): void {
/* @todo CoreCourseHelper.instance.confirmAndPrefetchCourse(this.prefetchCourseData, this.course).catch((error) => {
if (!this.pageDestroyed) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true);
}
});*/
}
/**
* Page destroyed.
*/
ngOnDestroy(): void {
this.pageDestroyed = true;
if (this.courseStatusObserver) {
this.courseStatusObserver.off();
}
}
}

View File

@ -0,0 +1,40 @@
:host {
--scroll-factor: 0.5;
--translate-z: calc(-2 * var(--scroll-factor))px;
--scale: calc(1 + var(--scroll-factor) * 2);
perspective: 1px;
perspective-origin: center top;
transform-style: preserve-3d;
.core-course-thumb-parallax-content {
transform: translateZ(0);
-webkit-filter: drop-shadow(0px -3px 3px rgba(var(--drop-shadow)));
filter: drop-shadow(0px -3px 3px rgba(var(--drop-shadow)));
}
.core-course-thumb-parallax {
height: 40vw;
max-height: 35vh;
z-index: -1;
overflow: hidden;
}
.core-course-thumb {
overflow: hidden;
text-align: center;
cursor: pointer;
pointer-events: auto;
transform-origin: center top;
/**
* Calculated with scroll-factor: 0.5;
* translate-z: -2 * $scroll-factor px;
* scale: 1 + $scroll-factor * 2;
*/
transform: translateZ(-1px) scale(2);
}
.core-customfieldvalue core-format-text {
display: inline;
}
}

View File

@ -1,3 +1,16 @@
<core-navbar-buttons slot="end">
<ion-button *ngIf="searchEnabled" (click)="openSearch()" [attr.aria-label]="'core.courses.searchcourses' | translate">
<ion-icon name="fas-search" slot="icon-only"></ion-icon>
</ion-button>
<core-context-menu>
<core-context-menu-item *ngIf="(downloadCourseEnabled || downloadCoursesEnabled)" [priority]="1000"
[content]="'core.settings.showdownloadoptions' | translate" (action)="toggleDownload()"
[iconAction]="downloadEnabledIcon"></core-context-menu-item>
<core-context-menu-item *ngIf="(downloadCourseEnabled || downloadCoursesEnabled)" [priority]="500"
[content]="'addon.storagemanager.managestorage' | translate"
(action)="manageCoursesStorage()" iconAction="fas-archive"></core-context-menu-item>
</core-context-menu>
</core-navbar-buttons>
<ion-content>
<!-- @todo -->
<core-empty-box icon="fa-home" [message]="'core.courses.nocourses' | translate">

View File

@ -12,25 +12,93 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit } from '@angular/core';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { NavController } from '@ionic/angular';
import { CoreCourses, CoreCoursesProvider } from '../../services/courses';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreSites } from '@services/sites';
/**
* Page that displays the Home.
* Page that displays the dashboard page.
*/
@Component({
selector: 'page-core-courses-dashboard',
templateUrl: 'dashboard.html',
styleUrls: ['dashboard.scss'],
})
export class CoreCoursesDashboardPage implements OnInit {
export class CoreCoursesDashboardPage implements OnInit, OnDestroy {
searchEnabled = false;
downloadEnabled = false;
downloadCourseEnabled = false;
downloadCoursesEnabled = false;
downloadEnabledIcon = 'far-square';
protected updateSiteObserver?: CoreEventObserver;
siteName = 'Hello world';
constructor(
protected navCtrl: NavController,
) { }
/**
* Initialize the component.
*/
ngOnInit(): void {
// @todo
this.searchEnabled = !CoreCourses.instance.isSearchCoursesDisabledInSite();
this.downloadCourseEnabled = !CoreCourses.instance.isDownloadCourseDisabledInSite();
this.downloadCoursesEnabled = !CoreCourses.instance.isDownloadCoursesDisabledInSite();
// Refresh the enabled flags if site is updated.
this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => {
this.searchEnabled = !CoreCourses.instance.isSearchCoursesDisabledInSite();
this.downloadCourseEnabled = !CoreCourses.instance.isDownloadCourseDisabledInSite();
this.downloadCoursesEnabled = !CoreCourses.instance.isDownloadCoursesDisabledInSite();
this.switchDownload(this.downloadEnabled && this.downloadCourseEnabled && this.downloadCoursesEnabled);
}, CoreSites.instance.getCurrentSiteId());
}
/**
* Toggle download enabled.
*/
toggleDownload(): void {
this.switchDownload(!this.downloadEnabled);
}
/**
* Convenience function to switch download enabled.
*
* @param enable If enable or disable.
*/
protected switchDownload(enable: boolean): void {
this.downloadEnabled = (this.downloadCourseEnabled || this.downloadCoursesEnabled) && enable;
this.downloadEnabledIcon = this.downloadEnabled ? 'far-check-square' : 'far-square';
CoreEvents.trigger(CoreCoursesProvider.EVENT_DASHBOARD_DOWNLOAD_ENABLED_CHANGED, { enabled: this.downloadEnabled });
}
/**
* Open page to manage courses storage.
*/
manageCoursesStorage(): void {
// @todo this.navCtrl.navigateForward(['/courses/storage']);
}
/**
* Go to search courses.
*/
openSearch(): void {
this.navCtrl.navigateForward(['/courses/search']);
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
this.updateSiteObserver?.off();
}
}

View File

@ -0,0 +1,50 @@
<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.courses.mycourses' | translate }}</ion-title>
<ion-buttons slot="end">
<core-navbar-buttons>
<ion-button *ngIf="searchEnabled" (click)="openSearch()" [attr.aria-label]="'core.courses.searchcourses' | translate">
<ion-icon name="fas-search" slot="icon-only"></ion-icon>
</ion-button>
<ion-button [hidden]="!downloadAllCoursesEnabled || !courses || courses.length < 2 || downloadAllCoursesLoading"
(click)="prefetchCourses()" [attr.aria-label]="'core.courses.downloadcourses' | translate">
<ion-icon [name]="downloadAllCoursesIcon" slot="icon-only"></ion-icon>
</ion-button>
<ion-spinner [hidden]="!downloadAllCoursesEnabled || !courses || courses.length < 2 ||
downloadAllCoursesBadge != '' || !downloadAllCoursesLoading"
[attr.aria-label]="'core.loading' | translate"></ion-spinner>
<ion-badge [hidden]="!downloadAllCoursesEnabled || !courses || courses.length < 2 || !downloadAllCoursesLoading ||
downloadAllCoursesBadge == '' || !downloadAllCoursesLoading"
[attr.aria-label]="'core.downloading' | translate">{{downloadAllCoursesBadge}}</ion-badge>
</core-navbar-buttons>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!coursesLoaded" (ionRefresh)="refreshCourses($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="coursesLoaded">
<ion-searchbar #searchbar *ngIf="courses && courses.length > 5" [(ngModel)]="filter" (ionInput)="filterChanged($event)"
(ionCancel)="filterChanged()" [placeholder]="'core.courses.filtermycourses' | translate">
</ion-searchbar>
<ion-grid class="ion-no-padding safe-area-page">
<ion-row class="ion-no-padding">
<ion-col *ngFor="let course of filteredCourses" class="ion-no-padding" size="12" size-sm="6" size-md="6"
size-lg="4" size-xl="4" align-self-stretch>
<core-courses-course-progress [course]="course" class="core-courseoverview" showAll="true">
</core-courses-course-progress>
</ion-col>
</ion-row>
</ion-grid>
<core-empty-box *ngIf="!courses || !courses.length" icon="fas-graduation-cap"
[message]="'core.courses.nocourses' | translate">
<p *ngIf="searchEnabled">{{ 'core.courses.searchcoursesadvice' | translate }}</p>
</core-empty-box>
</core-loading>
</ion-content>

View File

@ -0,0 +1,51 @@
// (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 { FormsModule } from '@angular/forms';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CoreCoursesMyCoursesPage } from './my-courses.page';
import { CoreCoursesComponentsModule } from '../../components/components.module';
const routes: Routes = [
{
path: '',
component: CoreCoursesMyCoursesPage,
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
CommonModule,
FormsModule,
IonicModule,
TranslateModule.forChild(),
CoreComponentsModule,
CoreDirectivesModule,
CoreCoursesComponentsModule,
],
declarations: [
CoreCoursesMyCoursesPage,
],
exports: [RouterModule],
})
export class CoreCoursesMyCoursesPageModule { }

View File

@ -0,0 +1,215 @@
// (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, OnDestroy, ViewChild } from '@angular/core';
import { NavController, IonSearchbar, IonRefresher } from '@ionic/angular';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import {
CoreCoursesProvider,
CoreCoursesMyCoursesUpdatedEventData,
CoreCourses,
} from '../../services/courses';
import { CoreCoursesHelper, CoreEnrolledCourseDataWithExtraInfoAndOptions } from '../../services/courses.helper';
import { CoreCourseHelper } from '@features/course/services/course.helper';
import { CoreConstants } from '@/core/constants';
// import { CoreCourseOptionsDelegate } from '@core/course/services/options-delegate';
/**
* Page that displays the list of courses the user is enrolled in.
*/
@Component({
selector: 'page-core-courses-my-courses',
templateUrl: 'my-courses.html',
})
export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy {
@ViewChild(IonSearchbar) searchbar!: IonSearchbar;
courses: CoreEnrolledCourseDataWithExtraInfoAndOptions[] = [];
filteredCourses: CoreEnrolledCourseDataWithExtraInfoAndOptions[] = [];
searchEnabled = false;
filter = '';
showFilter = false;
coursesLoaded = false;
downloadAllCoursesIcon = CoreConstants.NOT_DOWNLOADED_ICON;
downloadAllCoursesLoading = false;
downloadAllCoursesBadge = '';
downloadAllCoursesEnabled = false;
protected myCoursesObserver: CoreEventObserver;
protected siteUpdatedObserver: CoreEventObserver;
protected isDestroyed = false;
protected courseIds = '';
constructor(
protected navCtrl: NavController,
) {
// Update list if user enrols in a course.
this.myCoursesObserver = CoreEvents.on(
CoreCoursesProvider.EVENT_MY_COURSES_UPDATED,
(data: CoreCoursesMyCoursesUpdatedEventData) => {
if (data.action == CoreCoursesProvider.ACTION_ENROL) {
this.fetchCourses();
}
},
CoreSites.instance.getCurrentSiteId(),
);
// Refresh the enabled flags if site is updated.
this.siteUpdatedObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => {
this.searchEnabled = !CoreCourses.instance.isSearchCoursesDisabledInSite();
this.downloadAllCoursesEnabled = !CoreCourses.instance.isDownloadCoursesDisabledInSite();
}, CoreSites.instance.getCurrentSiteId());
}
/**
* Component being initialized.
*/
ngOnInit(): void {
this.searchEnabled = !CoreCourses.instance.isSearchCoursesDisabledInSite();
this.downloadAllCoursesEnabled = !CoreCourses.instance.isDownloadCoursesDisabledInSite();
this.fetchCourses().finally(() => {
this.coursesLoaded = true;
});
}
/**
* Fetch the user courses.
*
* @return Promise resolved when done.
*/
protected async fetchCourses(): Promise<void> {
try {
const courses: CoreEnrolledCourseDataWithExtraInfoAndOptions[] = await CoreCourses.instance.getUserCourses();
const courseIds = courses.map((course) => course.id);
this.courseIds = courseIds.join(',');
await CoreCoursesHelper.instance.loadCoursesExtraInfo(courses);
if (CoreCourses.instance.canGetAdminAndNavOptions()) {
const options = await CoreCourses.instance.getCoursesAdminAndNavOptions(courseIds);
courses.forEach((course) => {
course.navOptions = options.navOptions[course.id];
course.admOptions = options.admOptions[course.id];
});
}
this.courses = courses;
this.filteredCourses = this.courses;
this.filter = '';
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.courses.errorloadcourses', true);
}
}
/**
* Refresh the courses.
*
* @param refresher Refresher.
*/
refreshCourses(refresher: CustomEvent<IonRefresher>): void {
const promises: Promise<void>[] = [];
promises.push(CoreCourses.instance.invalidateUserCourses());
// @todo promises.push(this.courseOptionsDelegate.clearAndInvalidateCoursesOptions());
if (this.courseIds) {
promises.push(CoreCourses.instance.invalidateCoursesByField('ids', this.courseIds));
}
Promise.all(promises).finally(() => {
this.fetchCourses().finally(() => {
refresher?.detail.complete();
});
});
}
/**
* Show or hide the filter.
*/
switchFilter(): void {
this.filter = '';
this.showFilter = !this.showFilter;
this.filteredCourses = this.courses;
if (this.showFilter) {
setTimeout(() => {
this.searchbar.setFocus();
}, 500);
}
}
/**
* The filter has changed.
*
* @param Received Event.
*/
filterChanged(event?: Event): void {
const target = <HTMLInputElement>event?.target || null;
const newValue = target ? String(target.value).trim().toLowerCase() : null;
if (!newValue || !this.courses) {
this.filteredCourses = this.courses;
} else {
// Use displayname if avalaible, or fullname if not.
if (this.courses.length > 0 && typeof this.courses[0].displayname != 'undefined') {
this.filteredCourses = this.courses.filter((course) => course.displayname!.toLowerCase().indexOf(newValue) > -1);
} else {
this.filteredCourses = this.courses.filter((course) => course.fullname.toLowerCase().indexOf(newValue) > -1);
}
}
}
/**
* Prefetch all the courses.
*
* @return Promise resolved when done.
*/
async prefetchCourses(): Promise<void> {
this.downloadAllCoursesLoading = true;
try {
await CoreCourseHelper.instance.confirmAndPrefetchCourses(this.courses, (progress) => {
this.downloadAllCoursesBadge = progress.count + ' / ' + progress.total;
});
} catch (error) {
if (!this.isDestroyed) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true);
}
}
this.downloadAllCoursesBadge = '';
this.downloadAllCoursesLoading = false;
}
/**
* Go to search courses.
*/
openSearch(): void {
this.navCtrl.navigateForward(['/courses/search']);
}
/**
* Page destroyed.
*/
ngOnDestroy(): void {
this.isDestroyed = true;
this.myCoursesObserver?.off();
this.siteUpdatedObserver?.off();
}
}

View File

@ -0,0 +1,24 @@
<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.courses.searchcourses' | translate }}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<core-search-box (onSubmit)="search($event)" (onClear)="clearSearch($event)"
[placeholder]="'core.courses.search' | translate" [searchLabel]="'core.courses.search' | translate" autoFocus="true"
searchArea="CoreCoursesSearch"></core-search-box>
<ng-container *ngIf="total > 0">
<ion-item-divider>
<ion-label><h2>{{ 'core.courses.totalcoursesearchresults' | translate:{$a: total} }}</h2></ion-label>
</ion-item-divider>
<core-courses-course-list-item *ngFor="let course of courses" [course]="course"></core-courses-course-list-item>
<core-infinite-loading [enabled]="canLoadMore" (action)="loadMoreResults($event)" [error]="loadMoreError">
</core-infinite-loading>
</ng-container>
<core-empty-box *ngIf="total == 0" icon="search" [message]="'core.courses.nosearchresults' | translate"></core-empty-box>
</ion-content>

View File

@ -0,0 +1,52 @@
// (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 { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CoreCoursesComponentsModule } from '../../components/components.module';
import { CoreSearchComponentsModule } from '@features/search/components/components.module';
import { CoreCoursesSearchPage } from './search.page';
const routes: Routes = [
{
path: '',
component: CoreCoursesSearchPage,
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
CommonModule,
IonicModule,
TranslateModule.forChild(),
CoreComponentsModule,
CoreDirectivesModule,
CoreCoursesComponentsModule,
CoreSearchComponentsModule,
],
declarations: [
CoreCoursesSearchPage,
],
exports: [RouterModule],
})
export class CoreCoursesSearchPageModule { }

View File

@ -0,0 +1,100 @@
// (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 } from '@angular/core';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreCourseBasicSearchedData, CoreCourses } from '../../services/courses';
/**
* Page that allows searching for courses.
*/
@Component({
selector: 'page-core-courses-search',
templateUrl: 'search.html',
})
export class CoreCoursesSearchPage {
total = 0;
courses: CoreCourseBasicSearchedData[] = [];
canLoadMore = false;
loadMoreError = false;
protected page = 0;
protected currentSearch = '';
/**
* Search a new text.
*
* @param text The text to search.
*/
async search(text: string): Promise<void> {
this.currentSearch = text;
this.courses = [];
this.page = 0;
this.total = 0;
const modal = await CoreDomUtils.instance.showModalLoading('core.searching', true);
this.searchCourses().finally(() => {
modal.dismiss();
});
}
/**
* Clear search box.
*/
clearSearch(): void {
this.currentSearch = '';
this.courses = [];
this.page = 0;
this.total = 0;
}
/**
* Load more results.
*
* @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading.
*/
loadMoreResults(infiniteComplete?: () => void ): void {
this.searchCourses().finally(() => {
infiniteComplete && infiniteComplete();
});
}
/**
* Search courses or load the next page of current search.
*
* @return Promise resolved when done.
*/
protected async searchCourses(): Promise<void> {
this.loadMoreError = false;
try {
const response = await CoreCourses.instance.search(this.currentSearch, this.page);
if (this.page === 0) {
this.courses = response.courses;
} else {
this.courses = this.courses.concat(response.courses);
}
this.total = response.total;
this.page++;
this.canLoadMore = this.courses.length < this.total;
} catch (error) {
this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading.
CoreDomUtils.instance.showErrorModalDefault(error, 'core.courses.errorsearching', true);
}
}
}

View File

@ -0,0 +1,217 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
// import { PopoverController } from '@ionic/angular';
import { CoreUtils } from '@services/utils/utils';
import { CoreSites } from '@services/sites';
import { CoreCourses, CoreCourseSearchedData, CoreCourseUserAdminOrNavOptionIndexed, CoreEnrolledCourseData } from './courses';
import { makeSingleton } from '@singletons/core.singletons';
import { CoreWSExternalFile } from '@services/ws';
// import { AddonCourseCompletionProvider } from '@addon/coursecompletion/providers/coursecompletion';
// import { CoreCoursePickerMenuPopoverComponent } from '@components/course-picker-menu/course-picker-menu-popover';
/**
* Helper to gather some common courses functions.
*/
@Injectable({
providedIn: 'root',
})
export class CoreCoursesHelperProvider {
/**
* Get the courses to display the course picker popover. If a courseId is specified, it will also return its categoryId.
*
* @param courseId Course ID to get the category.
* @return Promise resolved with the list of courses and the category.
*/
async getCoursesForPopover(): Promise<void> {
// @todo params and logic
}
/**
* Given a course object returned by core_enrol_get_users_courses and another one returned by core_course_get_courses_by_field,
* load some extra data to the first one.
*
* @param course Course returned by core_enrol_get_users_courses.
* @param courseByField Course returned by core_course_get_courses_by_field.
* @param addCategoryName Whether add category name or not.
*/
loadCourseExtraInfo(
course: CoreEnrolledCourseDataWithExtraInfo,
courseByField: CoreCourseSearchedData,
addCategoryName: boolean = false,
colors?: (string | undefined)[],
): void {
if (courseByField) {
course.displayname = courseByField.displayname;
course.categoryname = addCategoryName ? courseByField.categoryname : undefined;
course.overviewfiles = course.overviewfiles || courseByField.overviewfiles;
} else {
delete course.displayname;
}
this.loadCourseColorAndImage(course, colors);
}
/**
* Given a list of courses returned by core_enrol_get_users_courses, load some extra data using the WebService
* core_course_get_courses_by_field if available.
*
* @param courses List of courses.
* @param loadCategoryNames Whether load category names or not.
* @return Promise resolved when done.
*/
async loadCoursesExtraInfo(courses: CoreEnrolledCourseDataWithExtraInfo[], loadCategoryNames: boolean = false): Promise<void> {
if (!courses.length ) {
// No courses or cannot get the data, stop.
return;
}
let coursesInfo = {};
let courseInfoAvailable = false;
const promises: Promise<void>[] = [];
let colors: (string | undefined)[] = [];
promises.push(this.loadCourseSiteColors().then((loadedColors) => {
colors = loadedColors;
return;
}));
if (CoreCourses.instance.isGetCoursesByFieldAvailable() && (loadCategoryNames ||
(typeof courses[0].overviewfiles == 'undefined' && typeof courses[0].displayname == 'undefined'))) {
const courseIds = courses.map((course) => course.id).join(',');
courseInfoAvailable = true;
// Get the extra data for the courses.
promises.push(CoreCourses.instance.getCoursesByField('ids', courseIds).then((coursesInfos) => {
coursesInfo = CoreUtils.instance.arrayToObject(coursesInfos, 'id');
return;
}));
}
await Promise.all(promises);
courses.forEach((course) => {
this.loadCourseExtraInfo(course, courseInfoAvailable ? coursesInfo[course.id] : course, loadCategoryNames, colors);
});
}
/**
* Load course colors from site config.
*
* @return course colors RGB.
*/
protected async loadCourseSiteColors(): Promise<(string | undefined)[]> {
const site = CoreSites.instance.getCurrentSite();
const colors: (string | undefined)[] = [];
if (site?.isVersionGreaterEqualThan('3.8')) {
try {
const configs = await site.getConfig();
for (let x = 0; x < 10; x++) {
colors[x] = configs['core_admin_coursecolor' + (x + 1)] || undefined;
}
} catch {
// Ignore errors.
}
}
return colors;
}
/**
* Loads the color of the course or the thumb image.
*
* @param course Course data.
* @param colors Colors loaded.
*/
async loadCourseColorAndImage(course: CoreCourseWithImageAndColor, colors?: (string | undefined)[]): Promise<void> {
if (!colors) {
colors = await this.loadCourseSiteColors();
}
if (course.overviewfiles && course.overviewfiles[0]) {
course.courseImage = course.overviewfiles[0].fileurl;
} else {
course.colorNumber = course.id % 10;
course.color = colors.length ? colors[course.colorNumber] : undefined;
}
}
/**
* Get user courses with admin and nav options.
*
* @param sort Sort courses after get them. If sort is not defined it won't be sorted.
* @param slice Slice results to get the X first one. If slice > 0 it will be done after sorting.
* @param filter Filter using some field.
* @param loadCategoryNames Whether load category names or not.
* @return Courses filled with options.
*/
async getUserCoursesWithOptions(): Promise<void> {
// @todo params and logic
}
/**
* Show a context menu to select a course, and return the courseId and categoryId of the selected course (-1 for all courses).
* Returns an empty object if popover closed without picking a course.
*
* @param event Click event.
* @param courses List of courses, from CoreCoursesHelperProvider.getCoursesForPopover.
* @param courseId The course to select at start.
* @return Promise resolved with the course ID and category ID.
*/
async selectCourse(): Promise<void> {
// @todo params and logic
}
}
export class CoreCoursesHelper extends makeSingleton(CoreCoursesHelperProvider) { }
/**
* Course with colors info and course image.
*/
export type CoreCourseWithImageAndColor = {
id: number; // Course id.
overviewfiles?: CoreWSExternalFile[];
colorNumber?: number; // Color index number.
color?: string; // Color RGB.
courseImage?: string; // Course thumbnail.
};
/**
* Enrolled course data with extra rendering info.
*/
export type CoreEnrolledCourseDataWithExtraInfo = CoreCourseWithImageAndColor & CoreEnrolledCourseData & {
categoryname?: string; // Category name,
};
/**
* Enrolled course data with admin and navigation option availability.
*/
export type CoreEnrolledCourseDataWithOptions = CoreEnrolledCourseData & {
navOptions?: CoreCourseUserAdminOrNavOptionIndexed;
admOptions?: CoreCourseUserAdminOrNavOptionIndexed;
};
/**
* Enrolled course data with admin and navigation option availability and extra rendering info.
*/
export type CoreEnrolledCourseDataWithExtraInfoAndOptions = CoreEnrolledCourseDataWithExtraInfo & CoreEnrolledCourseDataWithOptions;

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,7 @@ import { Injectable } from '@angular/core';
import { CoreHomeHandler, CoreHomeHandlerToDisplay } from '@features/mainmenu/services/home.delegate';
/**
* Handler to add Home into main menu.
* Handler to add dashboard into home page.
*/
Injectable();
export class CoreDashboardHomeHandler implements CoreHomeHandler {
@ -41,7 +41,7 @@ export class CoreDashboardHomeHandler implements CoreHomeHandler {
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async isEnabledForSite(siteId?: string): Promise<boolean> {
// @todo
// @todo return this.blockDelegate.hasSupportedBlock(this.blocks);
return true;
}

View File

@ -0,0 +1,63 @@
// (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 { CoreHomeHandler, CoreHomeHandlerToDisplay } from '@features/mainmenu/services/home.delegate';
/**
* Handler to add my courses into home page.
*/
Injectable();
export class CoreCoursesMyCoursesHomeHandler implements CoreHomeHandler {
name = 'CoreCoursesMyCourses';
priority = 900;
/**
* Check if the handler is enabled on a site level.
*
* @return Whether or not the handler is enabled on a site level.
*/
isEnabled(): Promise<boolean> {
return this.isEnabledForSite();
}
/**
* Check if the handler is enabled on a certain site.
*
* @param siteId Site ID. If not defined, current site.
* @return Whether or not the handler is enabled on a site level.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async isEnabledForSite(siteId?: string): Promise<boolean> {
// @todo return !this.blockDelegate.hasSupportedBlock(this.blocks) && !CoreSiteHome.instance.isAvailable(siteId);
return true;
}
/**
* Returns the data needed to render the handler.
*
* @return Data needed to render the handler.
*/
getDisplayData(): CoreHomeHandlerToDisplay {
return {
title: 'core.courses.mycourses',
page: 'courses/my',
class: 'core-courses-my-courses-handler',
icon: 'fas-graduation-cap',
selectPriority: 900,
};
}
}

View File

@ -30,8 +30,8 @@
<!-- Button to start/stop in mobile devices. -->
<ion-button fill="clear" *ngIf="!hasCaptured && isCordovaAudioCapture" (click)="actionClicked()"
[attr.aria-label]="title">
<ion-icon *ngIf="!isCapturing" name="fa-microphone" slot="icon-only"></ion-icon>
<ion-icon *ngIf="isCapturing" name="fa-square" slot="icon-only"></ion-icon>
<ion-icon *ngIf="!isCapturing" name="fas-microphone" slot="icon-only"></ion-icon>
<ion-icon *ngIf="isCapturing" name="fas-square" slot="icon-only"></ion-icon>
</ion-button>
<!-- Audio player to listen to the result. -->
@ -47,13 +47,13 @@
<ion-col class="ion-text-center">
<ion-button fill="clear" *ngIf="!hasCaptured && !isCordovaAudioCapture" (click)="actionClicked()"
[attr.aria-label]="title">
<ion-icon *ngIf="!isCapturing && isAudio" name="fa-microphone" slot="icon-only"></ion-icon>
<ion-icon *ngIf="!isCapturing && isVideo" name="fa-video" slot="icon-only"></ion-icon>
<ion-icon *ngIf="isImage" name="fa-camera" slot="icon-only"></ion-icon>
<ion-icon *ngIf="isCapturing" name="fa-square" slot="icon-only"></ion-icon>
<ion-icon *ngIf="!isCapturing && isAudio" name="fas-microphone" slot="icon-only"></ion-icon>
<ion-icon *ngIf="!isCapturing && isVideo" name="fas-video" slot="icon-only"></ion-icon>
<ion-icon *ngIf="isImage" name="fas-camera" slot="icon-only"></ion-icon>
<ion-icon *ngIf="isCapturing" name="fas-square" slot="icon-only"></ion-icon>
</ion-button>
<ion-button fill="clear" *ngIf="hasCaptured" (click)="discard()" [attr.aria-label]="'core.discard' | translate">
<ion-icon color="danger" name="fa-trash" slot="icon-only"></ion-icon>
<ion-icon color="danger" name="fas-trash" slot="icon-only"></ion-icon>
</ion-button>
</ion-col>
<ion-col class="ion-padding ion-text-end chrono-container">

View File

@ -14,19 +14,23 @@
import { NgModule } from '@angular/core';
import { CoreCourseModule } from './course/course.module';
import { CoreCoursesModule } from './courses/courses.module';
import { CoreEmulatorModule } from './emulator/emulator.module';
import { CoreFileUploaderInitModule } from './fileuploader/fileuploader-init.module';
import { CoreLoginModule } from './login/login.module';
import { CoreSettingsInitModule } from './settings/settings-init.module';
import { CoreSiteHomeInitModule } from './sitehome/sitehome-init.module';
@NgModule({
imports: [
CoreEmulatorModule,
CoreLoginModule,
CoreCourseModule,
CoreCoursesModule,
CoreSettingsInitModule,
CoreFileUploaderInitModule,
CoreSiteHomeInitModule,
],
})
export class CoreFeaturesModule {}

View File

@ -4,7 +4,7 @@
<ion-buttons slot="end">
<ion-button (click)="closeHelp()" [attr.aria-label]="'core.close' | translate">
<ion-icon slot="icon-only" name="fa-times"></ion-icon>
<ion-icon slot="icon-only" name="fas-times"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
@ -73,4 +73,4 @@
</ion-label>
</ion-item>
</ion-list>
</ion-content>
</ion-content>

View File

@ -2,7 +2,7 @@
<ion-toolbar>
<ion-buttons slot="start">
<ion-button (click)="previous($event)" [attr.aria-label]="'core.back' | translate">
<ion-icon slot="icon-only" name="fa-arrow-left"></ion-icon>
<ion-icon slot="icon-only" name="fas-arrow-left"></ion-icon>
</ion-button>
</ion-buttons>

View File

@ -9,7 +9,7 @@
<ion-buttons slot="end">
<ion-button router-direction="forward" routerLink="/settings"
[attr.aria-label]="'core.settings.appsettings' | translate">
<ion-icon slot="icon-only" name="fa-cog"></ion-icon>
<ion-icon slot="icon-only" name="fas-cog"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
@ -53,7 +53,7 @@
<ng-container *ngIf="showScanQR">
<div class="ion-text-center ion-padding">{{ 'core.login.or' | translate }}</div>
<ion-button expand="block" color="light" class="ion-margin" lines="none" (click)="showInstructionsAndScanQR()">
<ion-icon slot="start" name="fa-qrcode" aria-hidden="true"></ion-icon>
<ion-icon slot="start" name="fas-qrcode" aria-hidden="true"></ion-icon>
<ion-label>{{ 'core.scanqr' | translate }}</ion-label>
</ion-button>
</ng-container>

View File

@ -9,7 +9,7 @@
<ion-buttons slot="end">
<ion-button router-direction="forward" routerLink="/settings"
[attr.aria-label]="'core.settings.appsettings' | translate">
<ion-icon slot="icon-only" name="fa-cog"></ion-icon>
<ion-icon slot="icon-only" name="fas-cog"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
@ -50,7 +50,7 @@
<ion-item button *ngIf="enteredSiteUrl" (click)="connect($event, enteredSiteUrl.url)"
[attr.aria-label]="'core.login.connect' | translate" detail-push class="core-login-entered-site">
<ion-thumbnail slot="start">
<ion-icon name="fa-pencil-alt"></ion-icon>
<ion-icon name="fas-pencil-alt"></ion-icon>
</ion-thumbnail>
<ion-label>
<h2 text-wrap>{{ 'core.login.yourenteredsite' | translate }}</h2>
@ -98,7 +98,7 @@
<ng-container *ngIf="showScanQR && !hasSites && !enteredSiteUrl">
<div class="ion-text-center ion-padding ion-margin-top">{{ 'core.login.or' | translate }}</div>
<ion-button expand="block" color="light" class="ion-margin" lines="none" (click)="showInstructionsAndScanQR()">
<ion-icon slot="start" name="fa-qrcode" aria-hidden="true"></ion-icon>
<ion-icon slot="start" name="fas-qrcode" aria-hidden="true"></ion-icon>
<ion-label>{{ 'core.scanqr' | translate }}</ion-label>
</ion-button>
</ng-container>

View File

@ -8,11 +8,11 @@
<ion-buttons slot="end">
<ion-button *ngIf="sites && sites.length > 0" (click)="toggleDelete()" [attr.aria-label]="'core.delete' | translate">
<ion-icon slot="icon-only" name="fa-pencil-alt"></ion-icon>
<ion-icon slot="icon-only" name="fas-pencil-alt"></ion-icon>
</ion-button>
<ion-button router-direction="forward" routerLink="/settings"
[attr.aria-label]="'core.settings.appsettings' | translate">
<ion-icon slot="icon-only" name="fa-cog"></ion-icon>
<ion-icon slot="icon-only" name="fas-cog"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
@ -32,13 +32,13 @@
<ion-badge slot="end" *ngIf="!showDelete && site.badge">{{site.badge}}</ion-badge>
<ion-button *ngIf="showDelete" slot="end" fill="clear" color="danger" (click)="deleteSite($event, site)"
[attr.aria-label]="'core.delete' | translate">
<ion-icon name="fa-trash" slot="icon-only"></ion-icon>
<ion-icon name="fas-trash" slot="icon-only"></ion-icon>
</ion-button>
</ion-item>
</ion-list>
<ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end">
<ion-fab-button (click)="add()" [attr.aria-label]="'core.add' | translate">
<ion-icon name="fa-plus"></ion-icon>
<ion-icon name="fas-plus"></ion-icon>
</ion-fab-button>
</ion-fab>
</ion-content>

View File

@ -8,9 +8,7 @@
<core-format-text [text]="siteName" contextLevel="system" [contextInstanceId]="0"></core-format-text>
<img src="assets/img/login_logo.png" class="core-header-logo" [alt]="siteName">
</ion-title>
<ion-buttons slot="end">
<!-- @todo -->
</ion-buttons>
</ion-toolbar>
</ion-header>

View File

@ -12,10 +12,13 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { CoreSites } from '@services/sites';
import { Component, OnInit } from '@angular/core';
import { Component, OnInit, ViewChild } from '@angular/core';
import { Subscription } from 'rxjs';
import { CoreSites } from '@services/sites';
import { CoreHomeDelegate, CoreHomeHandlerToDisplay } from '../../services/home.delegate';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreTabsComponent } from '@components/tabs/tabs';
/**
* Page that displays the Home.
@ -27,12 +30,16 @@ import { CoreHomeDelegate, CoreHomeHandlerToDisplay } from '../../services/home.
})
export class CoreHomePage implements OnInit {
@ViewChild(CoreTabsComponent) tabsComponent?: CoreTabsComponent;
siteName!: string;
tabs: CoreHomeHandlerToDisplay[] = [];
loaded = false;
selectedTab?: number;
protected subscription?: Subscription;
protected updateSiteObserver?: CoreEventObserver;
constructor(
protected homeDelegate: CoreHomeDelegate,
@ -47,6 +54,11 @@ export class CoreHomePage implements OnInit {
this.subscription = this.homeDelegate.getHandlersObservable().subscribe((handlers) => {
handlers && this.initHandlers(handlers);
});
// Refresh the enabled flags if site is updated.
this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => {
this.loadSiteName();
}, CoreSites.instance.getCurrentSiteId());
}
/**
@ -91,4 +103,18 @@ export class CoreHomePage implements OnInit {
this.siteName = CoreSites.instance.getCurrentSite()!.getSiteName();
}
/**
* User entered the page.
*/
ionViewDidEnter(): void {
this.tabsComponent?.ionViewDidEnter();
}
/**
* User left the page.
*/
ionViewDidLeave(): void {
this.tabsComponent?.ionViewDidLeave();
}
}

View File

@ -13,7 +13,7 @@
</ion-tab-button>
<ion-tab-button (ionTabButtonClick)="tabClicked($event, 'more')" [hidden]="!loaded" tab="more" layout="label-hide">
<ion-icon name="fa-bars"></ion-icon>
<ion-icon name="fas-bars"></ion-icon>
<ion-label>{{ 'core.more' | translate }}</ion-label>
</ion-tab-button>
</ion-tab-bar>

View File

@ -51,34 +51,34 @@
</ion-item>
</ng-container>
<ion-item button *ngIf="showScanQR" (click)="scanQR()" detail>
<ion-icon name="fa-qrcode" slot="start" aria-hidden="true"></ion-icon>
<ion-icon name="fas-qrcode" slot="start" aria-hidden="true"></ion-icon>
<ion-label>
<h2>{{ 'core.scanqr' | translate }}</h2>
</ion-label>
</ion-item>
<ion-item button *ngIf="showWeb && siteInfo" [href]="siteInfo.siteurl" core-link autoLogin="yes"
title="{{ 'core.mainmenu.website' | translate }}" detail>
<ion-icon name="globe" slot="start" aria-hidden="true"></ion-icon>
<ion-icon name="fas-globe" slot="start" aria-hidden="true"></ion-icon>
<ion-label>
<h2>{{ 'core.mainmenu.website' | translate }}</h2>
</ion-label>
</ion-item>
<ion-item button *ngIf="showHelp" [href]="docsUrl" core-link autoLogin="no"
title="{{ 'core.mainmenu.help' | translate }}" detail>
<ion-icon name="help-buoy" slot="start" aria-hidden="true"></ion-icon>
<ion-icon name="far-life-ring" slot="start" aria-hidden="true"></ion-icon>
<ion-label>
<h2>{{ 'core.mainmenu.help' | translate }}</h2>
</ion-label>
</ion-item>
<ion-item button router-direction="forward" routerLink="preferences"
title="{{ 'core.settings.preferences' | translate }}" detail>
<ion-icon name="fa-wrench" slot="start"></ion-icon>
<ion-icon name="fas-wrench" slot="start"></ion-icon>
<ion-label>
<h2>{{ 'core.settings.preferences' | translate }}</h2>
</ion-label>
</ion-item>
<ion-item button (click)="logout()" title="{{ logoutLabel | translate }}" detail>
<ion-icon name="log-out" slot="start" aria-hidden="true"></ion-icon>
<ion-icon name="fas-sign-out-alt" slot="start" aria-hidden="true"></ion-icon>
<ion-label>
<h2>{{ logoutLabel | translate }}</h2>
</ion-label>
@ -86,7 +86,7 @@
<ion-item-divider></ion-item-divider>
<ion-item button router-direction="forward" routerLink="settings"
title="{{ 'core.settings.appsettings' | translate }}" detail>
<ion-icon name="fa-cogs" slot="start"></ion-icon>
<ion-icon name="fas-cogs" slot="start"></ion-icon>
<ion-label>
<h2>{{ 'core.settings.appsettings' | translate }}</h2>
</ion-label>

View File

@ -17,9 +17,9 @@
</ion-button>
</ion-item>
<ion-list class="core-search-history" [hidden]="!historyShown">
<ion-item class="ion-text-wrap" *ngFor="let item of history"
(click)="historyClicked($event, item.searchedtext)" class="core-clickable" tabindex="1">
<ion-icon name="fa-history" slot="start">
<ion-item button class="ion-text-wrap" *ngFor="let item of history"
(click)="historyClicked($event, item.searchedtext)" tabindex="1" detail>
<ion-icon name="fas-history" slot="start">
</ion-icon>
{{item.searchedtext}}
</ion-item>

View File

@ -18,11 +18,11 @@
<ion-label>{{ 'core.settings.opensourcelicenses' | translate }}</ion-label>
</ion-item>
<ion-item button class="ion-text-wrap" *ngIf="privacyPolicy" [href]="privacyPolicy" core-link auto-login="no" detail>
<ion-icon name="fa-user-shield" slot="start"></ion-icon>
<ion-icon name="fas-user-shield" slot="start"></ion-icon>
<ion-label>{{ 'core.settings.privacypolicy' | translate }}</ion-label>
</ion-item>
<ion-item button class="ion-text-wrap" (click)="openPage('deviceinfo')" detail>
<ion-icon name="fa-mobile" slot="start"></ion-icon>
<ion-icon name="fas-mobile" slot="start"></ion-icon>
<ion-label>{{ 'core.settings.deviceinfo' | translate }}</ion-label>
</ion-item>
</ion-content>

View File

@ -10,25 +10,25 @@
</ion-header>
<ion-content>
<ion-item button (click)="openSettings('general')" [class.core-split-item-selected]="'general' == selectedPage" detail>
<ion-icon name="fa-wrench" slot="start"></ion-icon>
<ion-icon name="fas-wrench" slot="start"></ion-icon>
<ion-label>{{ 'core.settings.general' | translate }}</ion-label>
</ion-item>
<ion-item button (click)="openSettings('spaceusage')" [class.core-split-item-selected]="'spaceusage' == selectedPage"
detail>
<ion-icon name="fa-tasks" slot="start"></ion-icon>
<ion-icon name="fas-tasks" slot="start"></ion-icon>
<ion-label>{{ 'core.settings.spaceusage' | translate }}</ion-label>
</ion-item>
<ion-item button (click)="openSettings('sync')" [class.core-split-item-selected]="'sync' == selectedPage" detail>
<ion-icon name="fa-sync-alt" slot="start"></ion-icon>
<ion-icon name="fas-sync-alt" slot="start"></ion-icon>
<ion-label>{{ 'core.settings.synchronization' | translate }}</ion-label>
</ion-item>
<ion-item button *ngIf="isIOS" (click)="openSettings('sharedfiles', {manage: true})"
[class.core-split-item-selected]="'sharedfiles' == selectedPage" detail>
<ion-icon name="fa-folder" slot="start"></ion-icon>
<ion-icon name="fas-folder" slot="start"></ion-icon>
<ion-label>{{ 'core.sharedfiles.sharedfiles' | translate }}</ion-label>
</ion-item>
<ion-item button (click)="openSettings('about')" [class.core-split-item-selected]="'about' == selectedPage" detail>
<ion-icon name="fa-id-card" slot="start"></ion-icon>
<ion-icon name="fas-id-card" slot="start"></ion-icon>
<ion-label>{{ 'core.settings.about' | translate }}</ion-label>
</ion-item>
</ion-content>

View File

@ -8,7 +8,7 @@
</ion-title>
<ion-buttons slot="end">
<ion-button (click)="copyInfo()" [attr.aria-label]="'core.settings.copyinfo' | translate">
<ion-icon slot="icon-only" name="fa-clipboard" color="light"></ion-icon>
<ion-icon slot="icon-only" name="fas-clipboard" color="light"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>

View File

@ -28,7 +28,7 @@
<ion-item *ngIf="isIOS"
(click)="openHandler('CoreSharedFilesListPage', {manage: true, siteId: siteId, hideSitePicker: true})"
[title]="'core.sharedfiles.sharedfiles' | translate"
[class.core-split-item-selected]="'CoreSharedFilesListPage' == selectedPage" details>
[class.core-split-item-selected]="'CoreSharedFilesListPage' == selectedPage" detail>
<ion-icon name="fas-folder" slot="start"></ion-icon>
<ion-label>
<h2>{{ 'core.sharedfiles.sharedfiles' | translate }}</h2>
@ -37,7 +37,7 @@
</ion-item>
<ion-item *ngFor="let handler of handlers" [ngClass]="['core-settings-handler', handler.class]"
(click)="openHandler(handler.page, handler.params)" [title]="handler.title | translate" details
(click)="openHandler(handler.page, handler.params)" [title]="handler.title | translate" detail
[class.core-split-item-selected]="handler.page == selectedPage">
<ion-icon [name]="handler.icon" slot="start" *ngIf="handler.icon">
</ion-icon>

View File

@ -5,10 +5,11 @@
</ion-buttons>
<ion-title>{{ 'core.settings.spaceusage' | translate }}</ion-title>
<ion-buttons slot="end">
<!-- @todo <core-navbar-buttons></core-navbar-buttons>-->
<ion-button (click)="showInfo()" [attr.aria-label]="'core.info' | translate">
<ion-icon name="fas-info-circle" slot="icon-only"></ion-icon>
</ion-button>
<core-navbar-buttons>
<ion-button (click)="showInfo()" [attr.aria-label]="'core.info' | translate">
<ion-icon name="fas-info-circle" slot="icon-only"></ion-icon>
</ion-button>
</core-navbar-buttons>
</ion-buttons>
</ion-toolbar>
</ion-header>

View File

@ -5,10 +5,11 @@
</ion-buttons>
<ion-title>{{ 'core.settings.synchronization' | translate }}</ion-title>
<ion-buttons slot="end">
<!-- @todo <core-navbar-buttons></core-navbar-buttons>-->
<ion-button (click)="showInfo()" [attr.aria-label]="'core.info' | translate">
<ion-icon name="fas-info-circle" slot="icon-only"></ion-icon>
</ion-button>
<core-navbar-buttons>
<ion-button (click)="showInfo()" [attr.aria-label]="'core.info' | translate">
<ion-icon name="fas-info-circle" slot="icon-only"></ion-icon>
</ion-button>
</core-navbar-buttons>
</ion-buttons>
</ion-toolbar>
</ion-header>

View File

@ -24,7 +24,7 @@ import { CoreConstants } from '@/core/constants';
import { CoreConfig } from '@services/config';
// import { CoreFilterProvider } from '@features/filter/providers/filter';
import { CoreDomUtils } from '@services/utils/dom';
// import { CoreCourseProvider } from '@features/course/providers/course';
import { CoreCourse } from '@features/course/services/course';
import { makeSingleton, Translate } from '@singletons/core.singletons';
import { CoreError } from '@classes/errors/error';
@ -58,7 +58,6 @@ export class CoreSettingsHelperProvider {
constructor() {
// protected filterProvider: CoreFilterProvider,
// protected courseProvider: CoreCourseProvider,
if (!CoreConstants.CONFIG.forceColorScheme) {
// Update color scheme when a user enters or leaves a site, or when the site info is updated.
@ -116,7 +115,7 @@ export class CoreSettingsHelperProvider {
promises.push(site.deleteFolder().then(() => {
filepoolService.clearAllPackagesStatus(siteId);
filepoolService.clearFilepool(siteId);
// this.courseProvider.clearAllCoursesStatus(siteId);
CoreCourse.instance.clearAllCoursesStatus(siteId);
siteInfo.spaceUsage = 0;

View File

@ -0,0 +1,4 @@
{
"sitehome": "Site home",
"sitenews": "Site announcements"
}

View File

@ -0,0 +1,100 @@
<core-navbar-buttons slot="end">
<ion-button *ngIf="searchEnabled" (click)="openSearch()" [attr.aria-label]="'core.courses.searchcourses' | translate">
<ion-icon name="fas-search" slot="icon-only"></ion-icon>
</ion-button>
<core-context-menu>
<core-context-menu-item *ngIf="(downloadCourseEnabled || downloadCoursesEnabled)" [priority]="1000"
[content]="'core.settings.showdownloadoptions' | translate" (action)="toggleDownload()"
[iconAction]="downloadEnabledIcon"></core-context-menu-item>
<core-context-menu-item *ngIf="(downloadCourseEnabled || downloadCoursesEnabled)" [priority]="500"
[content]="'addon.storagemanager.managestorage' | translate"
(action)="manageCoursesStorage()" iconAction="fas-archive"></core-context-menu-item>
</core-context-menu>
</core-navbar-buttons>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!dataLoaded"
(ionRefresh)="doRefresh($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<!-- @todo <core-block-course-blocks [courseId]="siteHomeId" [downloadEnabled]="downloadEnabled">-->
<core-loading [hideUntil]="dataLoaded">
<ion-list>
<!-- Site home main contents. -->
<!-- @todo <ng-container *ngIf="section && section.hasContent">
<ion-item class="ion-text-wrap" *ngIf="section.summary">
<core-format-text [text]="section.summary" contextLevel="course" [contextInstanceId]="siteHomeId"></core-format-text>
</ion-item>
<core-course-module *ngFor="let module of section.modules" [module]="module" [courseId]="siteHomeId" [downloadEnabled]="downloadEnabled" [section]="section"></core-course-module>
</ng-container> -->
<!-- Site home items: news, categories, courses, etc. -->
<ng-container *ngIf="items.length > 0">
<ion-item-divider *ngIf="section && section!.hasContent"></ion-item-divider>
<ng-container *ngFor="let item of items">
<ng-container [ngSwitch]="item">
<ng-container *ngSwitchCase="'LIST_OF_COURSE'">
<ng-template *ngTemplateOutlet="allCourseList"></ng-template>
</ng-container>
<ng-container *ngSwitchCase="'LIST_OF_CATEGORIES'">
<ng-template *ngTemplateOutlet="categories"></ng-template>
</ng-container>
<ng-container *ngSwitchCase="'COURSE_SEARCH_BOX'">
<ng-template *ngTemplateOutlet="courseSearch"></ng-template>
</ng-container>
<ng-container *ngSwitchCase="'ENROLLED_COURSES'">
<ng-template *ngTemplateOutlet="enrolledCourseList"></ng-template>
</ng-container>
<ng-container *ngSwitchCase="'NEWS_ITEMS'">
<ng-template *ngTemplateOutlet="news"></ng-template>
</ng-container>
</ng-container>
</ng-container>
</ng-container>
</ion-list>
<core-empty-box *ngIf="!hasContent" icon="qr-scanner" [message]="'core.course.nocontentavailable' | translate">
</core-empty-box>
</core-loading>
<!-- @todo </core-block-course-blocks> -->
</ion-content>
<ng-template #allCourseList>
<ion-item button class="ion-text-wrap" router-direction="forward" routerLink="/courses/all" detail>
<ion-icon name="fas-graduation-cap" fixed-width slot="start"></ion-icon>
<ion-label>
<h2>{{ 'core.courses.availablecourses' | translate}}</h2>
</ion-label>
</ion-item>
</ng-template>
<ng-template #news>
<ion-item>
<ion-label>News (TODO)</ion-label>
</ion-item>
<!-- @todo <core-course-module class="core-sitehome-news" *ngIf="show" [module]="module" [courseId]="siteHomeId"></core-course-module> -->
</ng-template>
<ng-template #categories>
<ion-item button class="ion-text-wrap" router-direction="forward" routerLink="/courses/categories" detail>
<ion-icon name="folder" slot="start"></ion-icon>
<ion-label>
<h2>{{ 'core.courses.categories' | translate}}</h2>
</ion-label>
</ion-item>
</ng-template>
<ng-template #enrolledCourseList>
<ion-item button class="ion-text-wrap" router-direction="forward" routerLink="/courses/my" detail>
<ion-icon name="fas-graduation-cap" fixed-width slot="start">
</ion-icon>
<ion-label><h2>{{ 'core.courses.mycourses' | translate}}</h2></ion-label>
</ion-item>
</ng-template>
<ng-template #courseSearch>
<ion-item button class="ion-text-wrap" router-direction="forward" routerLink="/courses/search" detail>
<ion-icon name="fas-search" slot="start"></ion-icon>
<ion-label><h2>{{ 'core.courses.searchcourses' | translate}}</h2></ion-label>
</ion-item>
</ng-template>

View File

@ -0,0 +1,47 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { 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 { CoreDirectivesModule } from '@directives/directives.module';
import { CoreComponentsModule } from '@components/components.module';
import { CoreSiteHomeIndexPage } from './index.page';
const routes: Routes = [
{
path: '',
component: CoreSiteHomeIndexPage,
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
CommonModule,
IonicModule,
TranslateModule.forChild(),
CoreDirectivesModule,
CoreComponentsModule,
],
declarations: [
CoreSiteHomeIndexPage,
],
exports: [RouterModule],
})
export class CoreSiteHomeIndexPageModule {}

View File

@ -0,0 +1,214 @@
// (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, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { IonRefresher, NavController } from '@ionic/angular';
import { CoreSite, CoreSiteConfig } from '@classes/site';
import { CoreCourse, CoreCourseSection } from '@features/course/services/course';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreSites } from '@services/sites';
import { CoreSiteHome } from '@features/sitehome/services/sitehome';
import { CoreCourses, CoreCoursesProvider } from '@features//courses/services/courses';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreCourseHelper } from '@features/course/services/course.helper';
/**
* Page that displays site home index.
*/
@Component({
selector: 'page-core-sitehome-index',
templateUrl: 'index.html',
})
export class CoreSiteHomeIndexPage implements OnInit, OnDestroy {
// @todo @ViewChild(CoreBlockCourseBlocksComponent) courseBlocksComponent: CoreBlockCourseBlocksComponent;
dataLoaded = false;
section?: CoreCourseSection & {
hasContent?: boolean;
};
hasContent = false;
items: string[] = [];
siteHomeId?: number;
currentSite?: CoreSite;
searchEnabled = false;
downloadEnabled = false;
downloadCourseEnabled = false;
downloadCoursesEnabled = false;
downloadEnabledIcon = 'far-square';
protected updateSiteObserver?: CoreEventObserver;
constructor(
protected route: ActivatedRoute,
protected navCtrl: NavController,
// @todo private prefetchDelegate: CoreCourseModulePrefetchDelegate,
) {}
/**
* Page being initialized.
*/
ngOnInit(): void {
const navParams = this.route.snapshot.queryParams;
this.searchEnabled = !CoreCourses.instance.isSearchCoursesDisabledInSite();
this.downloadCourseEnabled = !CoreCourses.instance.isDownloadCourseDisabledInSite();
this.downloadCoursesEnabled = !CoreCourses.instance.isDownloadCoursesDisabledInSite();
// Refresh the enabled flags if site is updated.
this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => {
this.searchEnabled = !CoreCourses.instance.isSearchCoursesDisabledInSite();
this.downloadCourseEnabled = !CoreCourses.instance.isDownloadCourseDisabledInSite();
this.downloadCoursesEnabled = !CoreCourses.instance.isDownloadCoursesDisabledInSite();
this.switchDownload(this.downloadEnabled && this.downloadCourseEnabled && this.downloadCoursesEnabled);
}, CoreSites.instance.getCurrentSiteId());
this.currentSite = CoreSites.instance.getCurrentSite()!;
this.siteHomeId = this.currentSite.getSiteHomeId();
const module = navParams['module'];
if (module) {
// @todo const modParams = navParams.get('modParams');
// CoreCourseHelper.instance.openModule(module, this.siteHomeId, undefined, modParams);
}
this.loadContent().finally(() => {
this.dataLoaded = true;
});
}
/**
* Convenience function to fetch the data.
*
* @return Promise resolved when done.
*/
protected async loadContent(): Promise<void> {
this.hasContent = false;
const config = this.currentSite!.getStoredConfig() || { numsections: 1, frontpageloggedin: undefined };
this.items = await CoreSiteHome.instance.getFrontPageItems(config.frontpageloggedin);
this.hasContent = this.items.length > 0;
try {
const sections = await CoreCourse.instance.getSections(this.siteHomeId!, false, true);
// Check "Include a topic section" setting from numsections.
this.section = config.numsections ? sections.find((section) => section.section == 1) : undefined;
if (this.section) {
this.section.hasContent = false;
this.section.hasContent = CoreCourseHelper.instance.sectionHasContent(this.section);
/* @todo this.hasContent = CoreCourseHelper.instance.addHandlerDataForModules(
[this.section],
this.siteHomeId,
undefined,
undefined,
true,
) || this.hasContent;*/
}
// Add log in Moodle.
CoreCourse.instance.logView(
this.siteHomeId!,
undefined,
undefined,
this.currentSite!.getInfo()?.sitename,
).catch(() => {
// Ignore errors.
});
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.couldnotloadsectioncontent', true);
}
}
/**
* Refresh the data.
*
* @param refresher Refresher.
*/
doRefresh(refresher?: CustomEvent<IonRefresher>): void {
const promises: Promise<unknown>[] = [];
promises.push(CoreCourse.instance.invalidateSections(this.siteHomeId!));
promises.push(this.currentSite!.invalidateConfig().then(async () => {
// Config invalidated, fetch it again.
const config: CoreSiteConfig = await this.currentSite!.getConfig();
this.currentSite!.setConfig(config);
return;
}));
if (this.section && this.section.modules) {
// Invalidate modules prefetch data.
// @todo promises.push(this.prefetchDelegate.invalidateModules(this.section.modules, this.siteHomeId));
}
// @todo promises.push(this.courseBlocksComponent.invalidateBlocks());
Promise.all(promises).finally(async () => {
const p2: Promise<unknown>[] = [];
p2.push(this.loadContent());
// @todo p2.push(this.courseBlocksComponent.loadContent());
await Promise.all(p2).finally(() => {
refresher?.detail.complete();
});
});
}
/**
* Toggle download enabled.
*/
toggleDownload(): void {
this.switchDownload(!this.downloadEnabled);
}
/**
* Convenience function to switch download enabled.
*
* @param enable If enable or disable.
*/
protected switchDownload(enable: boolean): void {
this.downloadEnabled = (this.downloadCourseEnabled || this.downloadCoursesEnabled) && enable;
this.downloadEnabledIcon = this.downloadEnabled ? 'far-check-square' : 'far-square';
CoreEvents.trigger(CoreCoursesProvider.EVENT_DASHBOARD_DOWNLOAD_ENABLED_CHANGED, { enabled: this.downloadEnabled });
}
/**
* Open page to manage courses storage.
*/
manageCoursesStorage(): void {
// @todo this.navCtrl.navigateForward(['/courses/storage']);
}
/**
* Go to search courses.
*/
openSearch(): void {
this.navCtrl.navigateForward(['/courses/search']);
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
this.updateSiteObserver?.off();
}
}

View File

@ -0,0 +1,75 @@
// (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 { Params } from '@angular/router';
import { CoreSites } from '@services/sites';
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks.helper';
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks.delegate';
import { CoreSiteHome } from '../sitehome';
/**
* Handler to treat links to site home index.
*/
Injectable();
export class CoreSiteHomeIndexLinkHandler extends CoreContentLinksHandlerBase {
name = 'CoreSiteHomeIndexLinkHandler';
featureName = 'CoreMainMenuDelegate_CoreSiteHome';
pattern = /\/course\/view\.php.*([?&]id=\d+)/;
/**
* Get the list of actions for a link (url).
*
* @param siteIds List of sites the URL belongs to.
* @param url The URL to treat.
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param courseId Course ID related to the URL. Optional but recommended.
* @return List of (or promise resolved with list of) actions.
*/
getActions(): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
return [{
action: (siteId: string): void => {
CoreContentLinksHelper.instance.goInSite('sitehome', [], siteId);
},
}];
}
/**
* Check if the handler is enabled for a certain site (site + user) and a URL.
* If not defined, defaults to true.
*
* @param siteId The site ID.
* @param url The URL to treat.
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param courseId Course ID related to the URL. Optional but recommended.
* @return Whether the handler is enabled for the URL and site.
*/
async isEnabled(siteId: string, url: string, params: Params, courseId?: number): Promise<boolean> {
courseId = parseInt(params.id, 10);
if (!courseId) {
return false;
}
const site = await CoreSites.instance.getSite(siteId);
if (courseId != site.getSiteHomeId()) {
// The course is not site home.
return false;
}
return CoreSiteHome.instance.isAvailable(siteId).then(() => true).catch(() => false);
}
}

View File

@ -0,0 +1,66 @@
// (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 { CoreSites } from '@services/sites';
import { CoreHomeHandler, CoreHomeHandlerToDisplay } from '@features/mainmenu/services/home.delegate';
import { CoreSiteHome } from '../sitehome';
/**
* Handler to add site home into home page.
*/
Injectable();
export class CoreSiteHomeHomeHandler implements CoreHomeHandler {
name = 'CoreSiteHomeDashboard';
priority = 1200;
/**
* Check if the handler is enabled on a site level.
*
* @return Whether or not the handler is enabled on a site level.
*/
isEnabled(): Promise<boolean> {
return this.isEnabledForSite();
}
/**
* Check if the handler is enabled on a certain site.
*
* @param siteId Site ID. If not defined, current site.
* @return Whether or not the handler is enabled on a site level.
*/
async isEnabledForSite(siteId?: string): Promise<boolean> {
return CoreSiteHome.instance.isAvailable(siteId);
}
/**
* Returns the data needed to render the handler.
*
* @return Data needed to render the handler.
*/
getDisplayData(): CoreHomeHandlerToDisplay {
const site = CoreSites.instance.getCurrentSite();
const displaySiteHome = site?.getInfo() && site?.getInfo()?.userhomepage === 0;
return {
title: 'core.sitehome.sitehome',
page: 'sitehome',
class: 'core-sitehome-dashboard-handler',
icon: 'fas-home',
selectPriority: displaySiteHome ? 1100 : 900,
};
}
}

View File

@ -0,0 +1,196 @@
// (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 { CoreSites } from '@services/sites';
import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
import { makeSingleton } from '@singletons/core.singletons';
import { CoreCourse, CoreCourseSection } from '../../course/services/course';
import { CoreCourses } from '../../courses/services/courses';
/**
* Items with index 1 and 3 were removed on 2.5 and not being supported in the app.
*/
export enum FrontPageItemNames {
NEWS_ITEMS = 0,
LIST_OF_CATEGORIES = 2,
COMBO_LIST = 3,
ENROLLED_COURSES = 5,
LIST_OF_COURSE = 6,
COURSE_SEARCH_BOX = 7,
}
/**
* Service that provides some features regarding site home.
*/
@Injectable({
providedIn: 'root',
})
export class CoreSiteHomeProvider {
/**
* Get the news forum for the Site Home.
*
* @param siteHomeId Site Home ID.
* @return Promise resolved with the forum if found, rejected otherwise.
*/
getNewsForum(): void {
// @todo params and logic.
}
/**
* Invalidate the WS call to get the news forum for the Site Home.
*
* @param siteHomeId Site Home ID.
* @return Promise resolved when invalidated.
*/
invalidateNewsForum(): void {
// @todo params and logic.
}
/**
* Returns whether or not the frontpage is available for the current site.
*
* @param siteId The site ID. If not defined, current site.
* @return Promise resolved with boolean: whether it's available.
*/
async isAvailable(siteId?: string): Promise<boolean> {
try {
const site = await CoreSites.instance.getSite(siteId);
// First check if it's disabled.
if (this.isDisabledInSite(site)) {
return false;
}
// Use a WS call to check if there's content in the site home.
const siteHomeId = site.getSiteHomeId();
const preSets: CoreSiteWSPreSets = { emergencyCache: false };
try {
const sections: CoreCourseSection[] =
await CoreCourse.instance.getSections(siteHomeId, false, true, preSets, site.id);
if (!sections || !sections.length) {
throw Error('No sections found');
}
const hasContent = sections.some((section) => section.summary || (section.modules && section.modules.length));
if (hasContent) {
// There's a section with content.
return true;
}
} catch {
// Ignore errors.
}
const config = site.getStoredConfig();
if (config && config.frontpageloggedin) {
const items = await this.getFrontPageItems(config.frontpageloggedin);
// There are items to show.
return items.length > 0;
}
} catch {
// Ignore errors.
}
return false;
}
/**
* Check if Site Home is disabled in a certain site.
*
* @param siteId Site Id. If not defined, use current site.
* @return Promise resolved with true if disabled, rejected or resolved with false otherwise.
*/
async isDisabled(siteId?: string): Promise<boolean> {
const site = await CoreSites.instance.getSite(siteId);
return this.isDisabledInSite(site);
}
/**
* Check if Site Home is disabled in a certain site.
*
* @param site Site. If not defined, use current site.
* @return Whether it's disabled.
*/
isDisabledInSite(site: CoreSite): boolean {
site = site || CoreSites.instance.getCurrentSite();
return site.isFeatureDisabled('CoreMainMenuDelegate_CoreSiteHome');
}
/**
* Get the nams of the valid frontpage items.
*
* @param frontpageItemIds CSV string with indexes of site home components.
* @return Valid names for each item.
*/
async getFrontPageItems(frontpageItemIds?: string): Promise<string[]> {
if (!frontpageItemIds) {
return [];
}
const items = frontpageItemIds.split(',');
const filteredItems: string[] = [];
for (const item of items) {
let itemNumber = parseInt(item, 10);
let add = false;
switch (itemNumber) {
case FrontPageItemNames['NEWS_ITEMS']:
// @todo
add = true;
break;
case FrontPageItemNames['LIST_OF_CATEGORIES']:
case FrontPageItemNames['COMBO_LIST']:
case FrontPageItemNames['LIST_OF_COURSE']:
add = CoreCourses.instance.isGetCoursesByFieldAvailable();
if (add && itemNumber == FrontPageItemNames['COMBO_LIST']) {
itemNumber = FrontPageItemNames['LIST_OF_CATEGORIES'];
}
break;
case FrontPageItemNames['ENROLLED_COURSES']:
if (!CoreCourses.instance.isMyCoursesDisabledInSite()) {
const courses = await CoreCourses.instance.getUserCourses();
add = courses.length > 0;
}
break;
case FrontPageItemNames['COURSE_SEARCH_BOX']:
add = !CoreCourses.instance.isSearchCoursesDisabledInSite();
break;
default:
break;
}
// Do not add an item twice.
if (add && filteredItems.indexOf(FrontPageItemNames[itemNumber]) < 0) {
filteredItems.push(FrontPageItemNames[itemNumber]);
}
}
return filteredItems;
}
}
export class CoreSiteHome extends makeSingleton(CoreSiteHomeProvider) {}

View File

@ -0,0 +1,53 @@
// (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 { Routes } from '@angular/router';
import { CoreSiteHomeIndexLinkHandler } from './services/handlers/index.link';
import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks.delegate';
import { CoreSiteHomeHomeHandler } from './services/handlers/sitehome.home';
import { CoreHomeDelegate } from '@features/mainmenu/services/home.delegate';
import { CoreHomeRoutingModule } from '@features/mainmenu/pages/home/home-routing.module';
const routes: Routes = [
{
path: 'sitehome',
loadChildren: () =>
import('@features/sitehome/pages/index/index.page.module').then(m => m.CoreSiteHomeIndexPageModule),
},
];
@NgModule({
imports: [CoreHomeRoutingModule.forChild(routes)],
exports: [CoreHomeRoutingModule],
providers: [
CoreSiteHomeIndexLinkHandler,
CoreSiteHomeHomeHandler,
],
})
export class CoreSiteHomeInitModule {
constructor(
contentLinksDelegate: CoreContentLinksDelegate,
homeDelegate: CoreHomeDelegate,
siteHomeIndexLinkHandler: CoreSiteHomeIndexLinkHandler,
siteHomeDashboardHandler: CoreSiteHomeHomeHandler,
) {
contentLinksDelegate.registerHandler(siteHomeIndexLinkHandler);
homeDelegate.registerHandler(siteHomeDashboardHandler);
}
}

View File

@ -21,6 +21,8 @@ import { makeSingleton, Translate } from '@singletons/core.singletons';
import { CoreWSExternalWarning } from '@services/ws';
import { CoreCourseBase } from '@/types/global';
const ROOT_CACHE_KEY = 'mmGroups:';
/*
* Service to handle groups.
*/
@ -31,7 +33,6 @@ export class CoreGroupsProvider {
static readonly NOGROUPS = 0;
static readonly SEPARATEGROUPS = 1;
static readonly VISIBLEGROUPS = 2;
protected readonly ROOT_CACHE_KEY = 'mmGroups:';
/**
* Check if group mode of an activity is enabled.
@ -65,12 +66,12 @@ export class CoreGroupsProvider {
userId?: number,
siteId?: string,
ignoreCache?: boolean,
): Promise<CoreGroupGetActivityAllowedGroupsResponse> {
): Promise<CoreGroupGetActivityAllowedGroupsWSResponse> {
const site = await CoreSites.instance.getSite(siteId);
userId = userId || site.getUserId();
const params = {
const params: CoreGroupGetActivityAllowedGroupsWSParams = {
cmid: cmId,
userid: userId,
};
@ -84,7 +85,7 @@ export class CoreGroupsProvider {
preSets.emergencyCache = false;
}
const response: CoreGroupGetActivityAllowedGroupsResponse =
const response: CoreGroupGetActivityAllowedGroupsWSResponse =
await site.read('core_group_get_activity_allowed_groups', params, preSets);
if (!response || !response.groups) {
@ -102,7 +103,7 @@ export class CoreGroupsProvider {
* @return Cache key.
*/
protected getActivityAllowedGroupsCacheKey(cmId: number, userId: number): string {
return this.ROOT_CACHE_KEY + 'allowedgroups:' + cmId + ':' + userId;
return ROOT_CACHE_KEY + 'allowedgroups:' + cmId + ':' + userId;
}
/**
@ -115,7 +116,7 @@ export class CoreGroupsProvider {
* @return Promise resolved when the groups are retrieved. If not allowed, empty array will be returned.
*/
async getActivityAllowedGroupsIfEnabled(cmId: number, userId?: number, siteId?: string, ignoreCache?: boolean):
Promise<CoreGroupGetActivityAllowedGroupsResponse> {
Promise<CoreGroupGetActivityAllowedGroupsWSResponse> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
// Get real groupmode, in case it's forced by the course.
@ -157,7 +158,7 @@ export class CoreGroupsProvider {
groupInfo.separateGroups = groupMode === CoreGroupsProvider.SEPARATEGROUPS;
groupInfo.visibleGroups = groupMode === CoreGroupsProvider.VISIBLEGROUPS;
let result: CoreGroupGetActivityAllowedGroupsResponse;
let result: CoreGroupGetActivityAllowedGroupsWSResponse;
if (groupInfo.separateGroups || groupInfo.visibleGroups) {
result = await this.getActivityAllowedGroups(cmId, userId, siteId, ignoreCache);
} else {
@ -195,7 +196,7 @@ export class CoreGroupsProvider {
*/
async getActivityGroupMode(cmId: number, siteId?: string, ignoreCache?: boolean): Promise<number> {
const site = await CoreSites.instance.getSite(siteId);
const params = {
const params: CoreGroupGetActivityGroupmodeWSParams = {
cmid: cmId,
};
const preSets: CoreSiteWSPreSets = {
@ -208,7 +209,7 @@ export class CoreGroupsProvider {
preSets.emergencyCache = false;
}
const response: CoreGroupGetActivityGroupModeResponse =
const response: CoreGroupGetActivityGroupModeWSResponse =
await site.read('core_group_get_activity_groupmode', params, preSets);
if (!response || typeof response.groupmode == 'undefined') {
@ -225,7 +226,7 @@ export class CoreGroupsProvider {
* @return Cache key.
*/
protected getActivityGroupModeCacheKey(cmId: number): string {
return this.ROOT_CACHE_KEY + 'groupmode:' + cmId;
return ROOT_CACHE_KEY + 'groupmode:' + cmId;
}
/**
@ -274,7 +275,7 @@ export class CoreGroupsProvider {
async getUserGroupsInCourse(courseId: number, siteId?: string, userId?: number): Promise<CoreGroup[]> {
const site = await CoreSites.instance.getSite(siteId);
userId = userId || site.getUserId();
const data = {
const data: CoreGroupGetCourseUserGroupsWSParams = {
userid: userId,
courseid: courseId,
};
@ -283,7 +284,7 @@ export class CoreGroupsProvider {
updateFrequency: CoreSite.FREQUENCY_RARELY,
};
const response: CoreGroupGetCourseUserGroupsResponse =
const response: CoreGroupGetCourseUserGroupsWSResponse =
await site.read('core_group_get_course_user_groups', data, preSets);
if (!response || !response.groups) {
@ -299,7 +300,7 @@ export class CoreGroupsProvider {
* @return Prefix Cache key.
*/
protected getUserGroupsInCoursePrefixCacheKey(): string {
return this.ROOT_CACHE_KEY + 'courseGroups:';
return ROOT_CACHE_KEY + 'courseGroups:';
}
/**
@ -474,24 +475,48 @@ export type CoreGroupInfo = {
/**
* WS core_group_get_activity_allowed_groups response type.
*/
export type CoreGroupGetActivityAllowedGroupsResponse = {
export type CoreGroupGetActivityAllowedGroupsWSResponse = {
groups: CoreGroup[]; // List of groups.
canaccessallgroups?: boolean; // Whether the user will be able to access all the activity groups.
warnings?: CoreWSExternalWarning[];
};
/**
* Params of core_group_get_activity_groupmode WS.
*/
type CoreGroupGetActivityGroupmodeWSParams = {
cmid: number; // Course module id.
};
/**
* Result of WS core_group_get_activity_groupmode.
*/
export type CoreGroupGetActivityGroupModeResponse = {
export type CoreGroupGetActivityGroupModeWSResponse = {
groupmode: number; // Group mode: 0 for no groups, 1 for separate groups, 2 for visible groups.
warnings?: CoreWSExternalWarning[];
};
/**
* Params of core_group_get_activity_allowed_groups WS.
*/
type CoreGroupGetActivityAllowedGroupsWSParams = {
cmid: number; // Course module id.
userid?: number; // Id of user, empty for current user.
};
/**
* Params of core_group_get_course_user_groups WS.
*/
type CoreGroupGetCourseUserGroupsWSParams = {
courseid?: number; // Id of course (empty or 0 for all the courses where the user is enrolled).
userid?: number; // Id of user (empty or 0 for current user).
groupingid?: number; // Returns only groups in the specified grouping.
};
/**
* Result of WS core_group_get_course_user_groups.
*/
export type CoreGroupGetCourseUserGroupsResponse = {
export type CoreGroupGetCourseUserGroupsWSResponse = {
groups: {
id: number; // Group record id.
name: string; // Multilang compatible name, course unique.

View File

@ -990,6 +990,13 @@ export type CoreStatusWithWarningsWSResponse = {
warnings?: CoreWSExternalWarning[];
};
/**
* Special response structure of many webservices that contains only warnings.
*/
export type CoreWarningsWSResponse = {
warnings?: CoreWSExternalWarning[];
};
/**
* Structure of files returned by WS.
*/

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