diff --git a/src/app/components/components.module.ts b/src/app/components/components.module.ts
index 185e30155..1e314e6d9 100644
--- a/src/app/components/components.module.ts
+++ b/src/app/components/components.module.ts
@@ -28,6 +28,8 @@ import { CoreRecaptchaComponent } from './recaptcha/recaptcha';
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 { CoreDirectivesModule } from '@app/directives/directives.module';
import { CorePipesModule } from '@app/pipes/pipes.module';
@@ -44,6 +46,7 @@ import { CorePipesModule } from '@app/pipes/pipes.module';
CoreRecaptchaModalComponent,
CoreShowPasswordComponent,
CoreEmptyBoxComponent,
+ CoreTabsComponent,
],
imports: [
CommonModule,
@@ -64,6 +67,7 @@ import { CorePipesModule } from '@app/pipes/pipes.module';
CoreRecaptchaModalComponent,
CoreShowPasswordComponent,
CoreEmptyBoxComponent,
+ CoreTabsComponent,
],
})
export class CoreComponentsModule {}
diff --git a/src/app/components/tabs/core-tabs.html b/src/app/components/tabs/core-tabs.html
new file mode 100644
index 000000000..731d8f3e6
--- /dev/null
+++ b/src/app/components/tabs/core-tabs.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tab.title | translate}}
+ {{ tab.badge }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/components/tabs/tabs.scss b/src/app/components/tabs/tabs.scss
new file mode 100644
index 000000000..3c926bc70
--- /dev/null
+++ b/src/app/components/tabs/tabs.scss
@@ -0,0 +1,72 @@
+:host {
+ --tabs-background: var(--background);
+ --tabs-color: var(--color);
+ height: 100%;
+ display: block;
+
+ ion-tabs {
+ background: transparent;
+ position: relative;
+ }
+
+ ion-tab-bar.core-tabs-bar {
+ position: relative;
+ width: 100%;
+ background: var(--tabs-background);
+ color: var(--tabs-color);
+ -webkit-filter: drop-shadow(0px 3px 3px rgba(var(--drop-shadow)));
+ filter: drop-shadow(0px 3px 3px rgba(var(--drop-shadow)));
+ border: 0;
+
+ ion-row {
+ width: 100%;
+ }
+
+ .tab-slide {
+ border-bottom: 2px solid transparent;
+ min-width: 100px;
+ min-height: 56px;
+ cursor: pointer;
+ overflow: hidden;
+
+ ion-tab-button {
+ ion-label {
+ font-size: 16px;
+ font-weight: 400;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ word-wrap: break-word;
+ max-width: 100%;
+ line-height: 1.2em;
+ margin-top: 16px;
+ margin-bottom: 16px;
+ }
+ }
+
+ &[aria-selected=true] {
+ color: var(--color-active);
+ border-bottom-color: var(--border-color-active);
+ ion-tab-button {
+ color: var(--color-active);
+ }
+ }
+ }
+
+ ion-col {
+ text-align: center;
+ line-height: 1.6rem;
+
+ &.col-with-arrow {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+ }
+
+ &.tabs-hidden {
+ display: none !important;
+ transform: translateY(0) !important;
+ }
+ }
+}
diff --git a/src/app/components/tabs/tabs.ts b/src/app/components/tabs/tabs.ts
new file mode 100644
index 000000000..10b3add1c
--- /dev/null
+++ b/src/app/components/tabs/tabs.ts
@@ -0,0 +1,629 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {
+ Component,
+ Input,
+ Output,
+ EventEmitter,
+ OnInit,
+ OnChanges,
+ OnDestroy,
+ AfterViewInit,
+ ViewChild,
+ ElementRef,
+} from '@angular/core';
+import { Platform, IonSlides, IonTabs, NavController } from '@ionic/angular';
+import { TranslateService } from '@ngx-translate/core';
+import { Subscription } from 'rxjs';
+import { CoreApp } from '@services/app';
+import { CoreConfig } from '@services/config';
+import { CoreConstants } from '@core/constants';
+import { CoreUtils } from '@/app/services/utils/utils';
+import { NavigationOptions } from '@ionic/angular/providers/nav-controller';
+import { Params } from '@angular/router';
+
+/**
+ * This component displays some top scrollable tabs that will autohide on vertical scroll.
+ *
+ * Example usage:
+ *
+ *
+ *
+ * Tab contents will only be shown if that tab is selected.
+ *
+ * @todo: Test behaviour when tabs are added late.
+ * @todo: Test RTL and tab history.
+ */
+@Component({
+ selector: 'core-tabs',
+ templateUrl: 'core-tabs.html',
+ styleUrls: ['tabs.scss'],
+})
+export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDestroy {
+
+
+ // Minimum tab's width to display fully the word "Competencies" which is the longest tab in the app.
+ protected static readonly MIN_TAB_WIDTH = 107;
+ // Max height that allows tab hiding.
+ protected static readonly MAX_HEIGHT_TO_HIDE_TABS = 768;
+
+ @Input() protected selectedIndex = 0; // Index of the tab to select.
+ @Input() hideUntil = false; // Determine when should the contents be shown.
+ /**
+ * Determine tabs layout.
+ */
+ @Input() layout: 'icon-top' | 'icon-start' | 'icon-end' | 'icon-bottom' | 'icon-hide' | 'label-hide' = 'icon-hide';
+ @Input() tabs: CoreTab[] = [];
+ @Output() protected ionChange: EventEmitter = new EventEmitter(); // Emitted when the tab changes.
+
+ @ViewChild(IonSlides) protected slides?: IonSlides;
+ @ViewChild(IonTabs) protected ionTabs?: IonTabs;
+
+ selected?: string; // Selected tab id.
+ showPrevButton = false;
+ showNextButton = false;
+ maxSlides = 3;
+ numTabsShown = 0;
+ direction = 'ltr';
+ description = '';
+ lastScroll = 0;
+ slideOpts = {
+ initialSlide: 0,
+ slidesPerView: 3,
+ centerInsufficientSlides: true,
+ };
+
+ protected initialized = false;
+ protected afterViewInitTriggered = false;
+
+ protected tabBarHeight = 0;
+ protected tabBarElement?: HTMLIonTabBarElement; // The top tab bar element.
+ protected tabsElement?: HTMLIonTabsElement; // The ionTabs native Element.
+ protected tabsShown = true;
+ protected resizeFunction?: EventListenerOrEventListenerObject;
+ protected isDestroyed = false;
+ protected isCurrentView = true;
+ protected shouldSlideToInitial = false; // Whether we need to slide to the initial slide because it's out of view.
+ protected hasSliddenToInitial = false; // Whether we've already slidden to the initial slide or there was no need.
+ protected selectHistory: string[] = [];
+
+ protected firstSelectedTab?: string; // ID of the first selected tab to control history.
+ protected unregisterBackButtonAction: any;
+ protected languageChangedSubscription: Subscription;
+ protected isInTransition = false; // Weather Slides is in transition.
+ protected slidesSwiper: any;
+ protected slidesSwiperLoaded = false;
+
+ constructor(
+ protected element: ElementRef,
+ platform: Platform,
+ translate: TranslateService,
+ protected navCtrl: NavController,
+ ) {
+ this.direction = platform.isRTL ? 'rtl' : 'ltr';
+
+ // Change the side when the language changes.
+ this.languageChangedSubscription = translate.onLangChange.subscribe(() => {
+ setTimeout(() => {
+ this.direction = platform.isRTL ? 'rtl' : 'ltr';
+ });
+ });
+ }
+
+ /**
+ * Component being initialized.
+ */
+ async ngOnInit(): Promise {
+ this.tabs.forEach((tab) => {
+ this.initTab(tab);
+ });
+ }
+
+ /**
+ * Init tab info.
+ *
+ * @param tab Tab class.
+ */
+ protected initTab(tab: CoreTab): void {
+ tab.id = tab.id || 'core-tab-' + CoreUtils.instance.getUniqueId('CoreTabsComponent');
+ if (typeof tab.enabled == 'undefined') {
+ tab.enabled = true;
+ }
+ }
+
+ /**
+ * View has been initialized.
+ */
+ async ngAfterViewInit(): Promise {
+ if (this.isDestroyed) {
+ return;
+ }
+
+ this.tabBarElement = this.element.nativeElement.querySelector('ion-tab-bar');
+ this.tabsElement = this.element.nativeElement.querySelector('ion-tabs');
+
+ this.slidesSwiper = await this.slides?.getSwiper();
+ this.slidesSwiper.once('progress', () => {
+ this.slidesSwiperLoaded = true;
+ this.calculateSlides();
+ });
+
+ this.afterViewInitTriggered = true;
+
+ if (!this.initialized && this.hideUntil) {
+ // Tabs should be shown, initialize them.
+ await this.initializeTabs();
+ }
+
+ this.resizeFunction = this.windowResized.bind(this);
+
+ window.addEventListener('resize', this.resizeFunction!);
+ }
+
+ /**
+ * Detect changes on input properties.
+ */
+ ngOnChanges(): void {
+ this.tabs.forEach((tab) => {
+ this.initTab(tab);
+ });
+
+ // We need to wait for ngAfterViewInit because we need core-tab components to be executed.
+ if (!this.initialized && this.hideUntil && this.afterViewInitTriggered) {
+ // Tabs should be shown, initialize them.
+ // Use a setTimeout so child core-tab update their inputs before initializing the tabs.
+ setTimeout(() => {
+ this.initializeTabs();
+ });
+ }
+ }
+
+ /**
+ * User entered the page that contains the component.
+ */
+ ionViewDidEnter(): void {
+ this.isCurrentView = true;
+
+ this.calculateSlides();
+
+ this.registerBackButtonAction();
+ }
+
+ /**
+ * Register back button action.
+ */
+ protected registerBackButtonAction(): void {
+ this.unregisterBackButtonAction = CoreApp.instance.registerBackButtonAction(() => {
+ // The previous page in history is not the last one, we need the previous one.
+ if (this.selectHistory.length > 1) {
+ const tabIndex = this.selectHistory[this.selectHistory.length - 2];
+
+ // Remove curent and previous tabs from history.
+ this.selectHistory = this.selectHistory.filter((tabId) => this.selected != tabId && tabIndex != tabId);
+
+ this.selectTab(tabIndex);
+
+ return true;
+ } else if (this.selected != this.firstSelectedTab) {
+ // All history is gone but we are not in the first selected tab.
+ this.selectHistory = [];
+
+ this.selectTab(this.firstSelectedTab!);
+
+ return true;
+ }
+
+ return false;
+ }, 750);
+ }
+
+ /**
+ * User left the page that contains the component.
+ */
+ ionViewDidLeave(): void {
+ // Unregister the custom back button action for this page
+ this.unregisterBackButtonAction && this.unregisterBackButtonAction();
+
+ this.isCurrentView = false;
+ }
+
+ /**
+ * Calculate slides.
+ */
+ protected async calculateSlides(): Promise {
+ if (!this.isCurrentView || !this.initialized) {
+ // Don't calculate if component isn't in current view, the calculations are wrong.
+ return;
+ }
+
+ if (!this.tabsShown) {
+ if (window.innerHeight >= CoreTabsComponent.MAX_HEIGHT_TO_HIDE_TABS) {
+ // Ensure tabbar is shown.
+ this.tabsShown = true;
+ this.tabBarElement!.classList.remove('tabs-hidden');
+ this.lastScroll = 0;
+ }
+ }
+
+ await this.calculateMaxSlides();
+
+ this.updateSlides();
+ }
+
+ /**
+ * Calculate the tab bar height.
+ */
+ protected calculateTabBarHeight(): void {
+ if (!this.tabBarElement || !this.tabsElement) {
+ return;
+ }
+
+ this.tabBarHeight = this.tabBarElement.offsetHeight;
+
+ if (this.tabsShown) {
+ // Smooth translation.
+ this.tabsElement.style.top = - this.lastScroll + 'px';
+ this.tabsElement.style.height = 'calc(100% + ' + scroll + 'px';
+ } else {
+ this.tabBarElement.classList.add('tabs-hidden');
+ this.tabsElement.style.top = '0';
+ this.tabsElement.style.height = '';
+ }
+ }
+
+ /**
+ * Get the tab on a index.
+ *
+ * @param tabId Tab ID.
+ * @return Selected tab.
+ */
+ protected getTabIndex(tabId: string): number {
+ return this.tabs.findIndex((tab) => tabId == tab.id);
+ }
+
+ /**
+ * Get the current selected tab.
+ *
+ * @return Selected tab.
+ */
+ getSelected(): CoreTab | undefined {
+ const index = this.selected && this.getTabIndex(this.selected);
+
+ return index && index >= 0 ? this.tabs[index] : undefined;
+ }
+
+ /**
+ * Initialize the tabs, determining the first tab to be shown.
+ */
+ protected async initializeTabs(): Promise {
+ let selectedTab: CoreTab | undefined = this.tabs[this.selectedIndex || 0] || undefined;
+
+ if (!selectedTab || !selectedTab.enabled) {
+ // The tab is not enabled or not shown. Get the first tab that is enabled.
+ selectedTab = this.tabs.find((tab) => tab.enabled) || undefined;
+ }
+
+ if (!selectedTab) {
+ return;
+ }
+
+ this.firstSelectedTab = selectedTab.id;
+ this.selectTab(this.firstSelectedTab);
+
+ // Setup tab scrolling.
+ this.calculateTabBarHeight();
+
+ this.initialized = true;
+
+ // Check which arrows should be shown.
+ this.calculateSlides();
+ }
+
+ /**
+ * Method executed when the slides are changed.
+ */
+ async slideChanged(): Promise {
+ if (!this.slidesSwiperLoaded) {
+ return;
+ }
+
+ this.isInTransition = false;
+ const slidesCount = await this.slides?.length() || 0;
+ if (slidesCount > 0) {
+ this.showPrevButton = !await this.slides?.isBeginning();
+ this.showNextButton = !await this.slides?.isEnd();
+ } else {
+ this.showPrevButton = false;
+ this.showNextButton = false;
+ }
+
+ const currentIndex = await this.slides!.getActiveIndex();
+ if (this.shouldSlideToInitial && currentIndex != this.selectedIndex) {
+ // Current tab has changed, don't slide to initial anymore.
+ this.shouldSlideToInitial = false;
+ }
+ }
+
+ /**
+ * Updates the number of slides to show.
+ */
+ protected async updateSlides(): Promise {
+ this.numTabsShown = this.tabs.reduce((prev: number, current: CoreTab) => current.enabled ? prev + 1 : prev, 0);
+
+ this.slideOpts.slidesPerView = Math.min(this.maxSlides, this.numTabsShown);
+ this.slidesSwiper.params.slidesPerView = this.slideOpts.slidesPerView;
+
+ this.calculateTabBarHeight();
+ await this.slides!.update();
+
+ if (!this.hasSliddenToInitial && this.selectedIndex && this.selectedIndex >= this.slideOpts.slidesPerView) {
+ this.hasSliddenToInitial = true;
+ this.shouldSlideToInitial = true;
+
+ setTimeout(() => {
+ if (this.shouldSlideToInitial) {
+ this.slides!.slideTo(this.selectedIndex, 0);
+ this.shouldSlideToInitial = false;
+ }
+ }, 400);
+
+ return;
+ } else if (this.selectedIndex) {
+ this.hasSliddenToInitial = true;
+ }
+
+ setTimeout(() => {
+ this.slideChanged(); // Call slide changed again, sometimes the slide active index takes a while to be updated.
+ }, 400);
+ }
+
+ /**
+ * Calculate the number of slides that can fit on the screen.
+ */
+ protected async calculateMaxSlides(): Promise {
+ if (!this.slidesSwiperLoaded) {
+ return;
+ }
+
+ this.maxSlides = 3;
+ const width = this.slidesSwiper.width;
+ if (width) {
+ const fontSize = await
+ CoreConfig.instance.get(CoreConstants.SETTINGS_FONT_SIZE, CoreConstants.CONFIG.font_sizes[0]);
+
+ this.maxSlides = Math.floor(width / (fontSize / CoreConstants.CONFIG.font_sizes[0] *
+ CoreTabsComponent.MIN_TAB_WIDTH));
+ }
+ }
+
+ /**
+ * Method that shows the next tab.
+ */
+ async slideNext(): Promise {
+ // Stop if slides are in transition.
+ if (!this.showNextButton || this.isInTransition) {
+ return;
+ }
+
+ if (await this.slides!.isBeginning()) {
+ // Slide to the second page.
+ this.slides!.slideTo(this.maxSlides);
+ } else {
+ const currentIndex = await this.slides!.getActiveIndex();
+ if (typeof currentIndex !== 'undefined') {
+ const nextSlideIndex = currentIndex + this.maxSlides;
+ this.isInTransition = true;
+ if (nextSlideIndex < this.numTabsShown) {
+ // Slide to the next page.
+ await this.slides!.slideTo(nextSlideIndex);
+ } else {
+ // Slide to the latest slide.
+ await this.slides!.slideTo(this.numTabsShown - 1);
+ }
+ }
+
+ }
+ }
+
+ /**
+ * Method that shows the previous tab.
+ */
+ async slidePrev(): Promise {
+ // Stop if slides are in transition.
+ if (!this.showPrevButton || this.isInTransition) {
+ return;
+ }
+
+ if (await this.slides!.isEnd()) {
+ this.slides!.slideTo(this.numTabsShown - this.maxSlides * 2);
+ // Slide to the previous of the latest page.
+ } else {
+ const currentIndex = await this.slides!.getActiveIndex();
+ if (typeof currentIndex !== 'undefined') {
+ const prevSlideIndex = currentIndex - this.maxSlides;
+ this.isInTransition = true;
+ if (prevSlideIndex >= 0) {
+ // Slide to the previous page.
+ await this.slides!.slideTo(prevSlideIndex);
+ } else {
+ // Slide to the first page.
+ await this.slides!.slideTo(0);
+ }
+ }
+ }
+ }
+
+ /**
+ * Show or hide the tabs. This is used when the user is scrolling inside a tab.
+ *
+ * @param scrollEvent Scroll event to check scroll position.
+ * @param content Content element to check measures.
+ */
+ protected showHideTabs(scrollEvent: CustomEvent, content: HTMLElement): void {
+ if (!this.tabBarElement || !this.tabsElement || !content) {
+ return;
+ }
+
+ // Always show on very tall screens.
+ if (window.innerHeight >= CoreTabsComponent.MAX_HEIGHT_TO_HIDE_TABS) {
+ return;
+ }
+
+ if (!this.tabBarHeight && this.tabBarElement.offsetHeight != this.tabBarHeight) {
+ // Wrong tab height, recalculate it.
+ this.calculateTabBarHeight();
+ }
+
+ if (!this.tabBarHeight) {
+ // We don't have the tab bar height, this means the tab bar isn't shown.
+ return;
+ }
+
+ const scroll = parseInt(scrollEvent.detail.scrollTop, 10);
+ if (scroll <= 0) {
+ // Ensure tabbar is shown.
+ this.tabsElement.style.top = '0';
+ this.tabsElement.style.height = '';
+ this.tabBarElement!.classList.remove('tabs-hidden');
+ this.tabsShown = true;
+ this.lastScroll = 0;
+
+ return;
+ }
+
+ if (scroll == this.lastScroll) {
+ // Ensure scroll has been modified to avoid flicks.
+ return;
+ }
+
+ if (this.tabsShown && scroll > this.tabBarHeight) {
+ this.tabsShown = false;
+
+ // Hide tabs.
+ this.tabBarElement.classList.add('tabs-hidden');
+ this.tabsElement.style.top = '0';
+ this.tabsElement.style.height = '';
+ } else if (!this.tabsShown && scroll <= this.tabBarHeight) {
+ this.tabsShown = true;
+ this.tabBarElement!.classList.remove('tabs-hidden');
+ }
+
+ if (this.tabsShown && content.scrollHeight > content.clientHeight + (this.tabBarHeight - scroll)) {
+ // Smooth translation.
+ this.tabsElement.style.top = - scroll + 'px';
+ this.tabsElement.style.height = 'calc(100% + ' + scroll + 'px';
+ }
+ // Use lastScroll after moving the tabs to avoid flickering.
+ this.lastScroll = parseInt(scrollEvent.detail.scrollTop, 10);
+ }
+
+ /**
+ * Tab selected.
+ *
+ * @param tabId Selected tab index.
+ * @param e Event.
+ */
+ async selectTab(tabId: string, e?: Event): Promise {
+ let index = this.tabs.findIndex((tab) => tabId == tab.id);
+ if (index < 0 || index >= this.tabs.length) {
+ if (this.selected) {
+ // Invalid index do not change tab.
+ e && e.preventDefault();
+ e && e.stopPropagation();
+
+ return;
+ }
+
+ // Index isn't valid, select the first one.
+ index = 0;
+ }
+
+ const selectedTab = this.tabs[index];
+ if (tabId == this.selected || !selectedTab || !selectedTab.enabled) {
+ // Already selected or not enabled.
+
+ e && e.preventDefault();
+ e && e.stopPropagation();
+
+ return;
+ }
+
+ if (this.selected) {
+ await this.slides!.slideTo(index);
+ }
+
+ const pageParams: NavigationOptions = {};
+ if (selectedTab.pageParams) {
+ pageParams.queryParams = selectedTab.pageParams;
+ }
+ const ok = await this.navCtrl.navigateForward(selectedTab.page, pageParams);
+
+ if (ok) {
+ this.selectHistory.push(tabId);
+ this.selected = tabId;
+ 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);
+ });
+ }
+ }
+ }
+
+ /**
+ * Adapt tabs to a window resize.
+ */
+ protected windowResized(): void {
+ setTimeout(() => {
+ this.calculateSlides();
+ });
+ }
+
+ /**
+ * Component destroyed.
+ */
+ ngOnDestroy(): void {
+ this.isDestroyed = true;
+
+ if (this.resizeFunction) {
+ window.removeEventListener('resize', this.resizeFunction);
+ }
+ }
+
+}
+
+/**
+ * Core Tab class.
+ */
+class CoreTab {
+
+ id = ''; // Unique tab id.
+ class = ''; // Class, if needed.
+ title = ''; // The translatable tab title.
+ icon?: string; // The tab icon.
+ badge?: string; // A badge to add in the tab.
+ badgeStyle?: string; // The badge color.
+ enabled = true; // Whether the tab is enabled.
+ page = ''; // Page to navigate to.
+ pageParams?: Params; // Page params.
+
+}
diff --git a/src/app/core/courses/courses.module.ts b/src/app/core/courses/courses.module.ts
index 4dd76b82a..3665427fd 100644
--- a/src/app/core/courses/courses.module.ts
+++ b/src/app/core/courses/courses.module.ts
@@ -13,9 +13,27 @@
// limitations under the License.
import { NgModule } from '@angular/core';
+import { RouterModule, Routes } from '@angular/router';
+import { CoreHomeDelegate } from '../mainmenu/services/home.delegate';
+import { CoreCoursesDashboardHandler } from './handlers/dashboard';
+import { CoreCoursesDashboardPage } from './pages/dashboard/dashboard.page';
+
+
+const routes: Routes = [
+ {
+ path: 'dashboard',
+ component: CoreCoursesDashboardPage,
+ },
+];
@NgModule({
- imports: [],
+ imports: [RouterModule.forChild(routes)],
declarations: [],
})
-export class CoreCoursesModule { }
+export class CoreCoursesModule {
+
+ constructor(homeDelegate: CoreHomeDelegate) {
+ homeDelegate.registerHandler(new CoreCoursesDashboardHandler());
+ }
+
+}
diff --git a/src/app/core/courses/handlers/dashboard.ts b/src/app/core/courses/handlers/dashboard.ts
new file mode 100644
index 000000000..0640f7c66
--- /dev/null
+++ b/src/app/core/courses/handlers/dashboard.ts
@@ -0,0 +1,60 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { CoreHomeHandler, CoreHomeHandlerData } from '@core/mainmenu/services/home.delegate';
+
+/**
+ * Handler to add Home into main menu.
+ */
+export class CoreCoursesDashboardHandler implements CoreHomeHandler {
+
+ name = 'CoreCoursesDashboard';
+ priority = 1100;
+
+ /**
+ * Check if the handler is enabled on a site level.
+ *
+ * @return Whether or not the handler is enabled on a site level.
+ */
+ isEnabled(): Promise {
+ 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 {
+ // @todo
+ return true;
+ }
+
+ /**
+ * Returns the data needed to render the handler.
+ *
+ * @return Data needed to render the handler.
+ */
+ getDisplayData(): CoreHomeHandlerData {
+ return {
+ title: 'core.courses.mymoodle',
+ page: 'home/dashboard',
+ class: 'core-courses-dashboard-handler',
+ icon: 'fa-tachometer-alt',
+ };
+ }
+
+}
diff --git a/src/app/core/courses/pages/dashboard/dashboard.html b/src/app/core/courses/pages/dashboard/dashboard.html
new file mode 100644
index 000000000..a2a6c650c
--- /dev/null
+++ b/src/app/core/courses/pages/dashboard/dashboard.html
@@ -0,0 +1,6 @@
+
+
+
+ Dashboard
+
+
diff --git a/src/app/core/courses/pages/dashboard/dashboard.page.module.ts b/src/app/core/courses/pages/dashboard/dashboard.page.module.ts
new file mode 100644
index 000000000..5d71c930a
--- /dev/null
+++ b/src/app/core/courses/pages/dashboard/dashboard.page.module.ts
@@ -0,0 +1,47 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { 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 { CoreCoursesDashboardPage } from './dashboard.page';
+
+const routes: Routes = [
+ {
+ path: '',
+ component: CoreCoursesDashboardPage,
+ },
+];
+
+@NgModule({
+ imports: [
+ RouterModule.forChild(routes),
+ CommonModule,
+ IonicModule,
+ TranslateModule.forChild(),
+ CoreComponentsModule,
+ CoreDirectivesModule,
+ ],
+ declarations: [
+ CoreCoursesDashboardPage,
+ ],
+ exports: [RouterModule],
+})
+export class CoreCoursesDashboardPageModule {}
diff --git a/src/app/core/courses/pages/dashboard/dashboard.page.ts b/src/app/core/courses/pages/dashboard/dashboard.page.ts
new file mode 100644
index 000000000..458512990
--- /dev/null
+++ b/src/app/core/courses/pages/dashboard/dashboard.page.ts
@@ -0,0 +1,36 @@
+// (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';
+
+/**
+ * Page that displays the Home.
+ */
+@Component({
+ selector: 'page-core-courses-dashboard',
+ templateUrl: 'dashboard.html',
+ styleUrls: ['dashboard.scss'],
+})
+export class CoreCoursesDashboardPage implements OnInit {
+
+ siteName = 'Hello world';
+
+ /**
+ * Initialize the component.
+ */
+ ngOnInit(): void {
+ // @todo
+ }
+
+}
diff --git a/src/app/core/courses/pages/dashboard/dashboard.scss b/src/app/core/courses/pages/dashboard/dashboard.scss
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/app/core/mainmenu/pages/home/home.html b/src/app/core/mainmenu/pages/home/home.html
index 2b428a553..d73d9ecaf 100644
--- a/src/app/core/mainmenu/pages/home/home.html
+++ b/src/app/core/mainmenu/pages/home/home.html
@@ -16,7 +16,12 @@
-
- Home page
-
+ 0">
+
+
+
+ Home page
+
+
+
diff --git a/src/app/core/mainmenu/pages/home/home.page.module.ts b/src/app/core/mainmenu/pages/home/home.page.module.ts
index ef2e8f653..205739270 100644
--- a/src/app/core/mainmenu/pages/home/home.page.module.ts
+++ b/src/app/core/mainmenu/pages/home/home.page.module.ts
@@ -27,6 +27,13 @@ const routes: Routes = [
{
path: '',
component: CoreHomePage,
+ children: [
+ {
+ path: 'dashboard', // @todo: Add this route dynamically.
+ loadChildren: () =>
+ import('@core/courses/pages/dashboard/dashboard.page.module').then(m => m.CoreCoursesDashboardPageModule),
+ },
+ ],
},
];
diff --git a/src/app/core/mainmenu/pages/home/home.page.ts b/src/app/core/mainmenu/pages/home/home.page.ts
index b5b2410b2..4add6d7ce 100644
--- a/src/app/core/mainmenu/pages/home/home.page.ts
+++ b/src/app/core/mainmenu/pages/home/home.page.ts
@@ -12,7 +12,10 @@
// 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 { Subscription } from 'rxjs';
+import { CoreHomeDelegate, CoreHomeHandlerToDisplay } from '../../services/home.delegate';
/**
* Page that displays the Home.
@@ -24,13 +27,65 @@ import { Component, OnInit } from '@angular/core';
})
export class CoreHomePage implements OnInit {
- siteName = 'Hello world';
+ siteName!: string;
+ tabs: CoreHomeHandlerToDisplay[] = [];
+ loaded = false;
+ selectedTab?: number;
+
+ protected subscription?: Subscription;
+
+ constructor(
+ protected homeDelegate: CoreHomeDelegate,
+ ) {
+ this.loadSiteName();
+ }
/**
* Initialize the component.
*/
ngOnInit(): void {
- // @todo
+ this.subscription = this.homeDelegate.getHandlers().subscribe((handlers) => {
+ handlers && this.initHandlers(handlers);
+ });
+ }
+
+ /**
+ * Init handlers on change (size or handlers).
+ */
+ initHandlers(handlers: CoreHomeHandlerToDisplay[]): void {
+ // Re-build the list of tabs. If a handler is already in the list, use existing object to prevent re-creating the tab.
+ const newTabs: CoreHomeHandlerToDisplay[] = handlers.map((handler) => {
+ // Check if the handler is already in the tabs list. If so, use it.
+ const tab = this.tabs.find((tab) => tab.title == handler.title);
+
+ return tab || handler;
+ })
+ // Sort them by priority so new handlers are in the right position.
+ .sort((a, b) => (b.priority || 0) - (a.priority || 0));
+
+ if (typeof this.selectedTab == 'undefined') {
+ let maxPriority = 0;
+ let maxIndex = 0;
+ newTabs.forEach((tab, index) => {
+ if ((tab.selectPriority || 0) > maxPriority) {
+ maxPriority = tab.selectPriority || 0;
+ maxIndex = index;
+ }
+ });
+
+ this.selectedTab = maxIndex;
+ }
+
+ this.tabs = newTabs;
+
+ this.loaded = this.homeDelegate.areHandlersLoaded();
+ }
+
+ /**
+ * Load the site name.
+ */
+ protected loadSiteName(): void {
+ this.siteName = CoreSites.instance.getCurrentSite()!.getSiteName();
}
}
diff --git a/src/app/core/mainmenu/services/home.delegate.ts b/src/app/core/mainmenu/services/home.delegate.ts
new file mode 100644
index 000000000..6d500d9a3
--- /dev/null
+++ b/src/app/core/mainmenu/services/home.delegate.ts
@@ -0,0 +1,172 @@
+// (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 { Subject, BehaviorSubject } from 'rxjs';
+
+import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate';
+import { CoreEvents } from '@singletons/events';
+
+/**
+ * Interface that all main menu handlers must implement.
+ */
+export interface CoreHomeHandler extends CoreDelegateHandler {
+ /**
+ * The highest priority is displayed first.
+ */
+ priority?: number;
+
+ /**
+ * Returns the data needed to render the handler.
+ *
+ * @return Data.
+ */
+ getDisplayData(): CoreHomeHandlerData;
+}
+
+/**
+ * Data needed to render a main menu handler. It's returned by the handler.
+ */
+export interface CoreHomeHandlerData {
+ /**
+ * Name of the page to load for the handler.
+ */
+ page: string;
+
+ /**
+ * Title to display for the handler.
+ */
+ title: string;
+
+ /**
+ * Class to add to the displayed handler.
+ */
+ class?: string;
+
+ /**
+ * If true, the badge number is being loaded. Only used if showBadge is true.
+ */
+ loading?: boolean;
+
+ /**
+ * Params to pass to the page.
+ */
+ pageParams?: Params;
+
+ /**
+ * If the handler has badge to show or not.
+ */
+ showBadge?: boolean;
+
+ /**
+ * Text to display on the badge. Only used if showBadge is true.
+ */
+ badge?: string;
+
+ /**
+ * Name of the icon to display for the handler.
+ */
+ icon?: string; // Name of the icon to display in the tab.
+}
+
+/**
+ * Data returned by the delegate for each handler.
+ */
+export interface CoreHomeHandlerToDisplay extends CoreHomeHandlerData {
+ /**
+ * Name of the handler.
+ */
+ name?: string;
+
+ /**
+ * Priority of the handler.
+ */
+ priority?: number;
+
+ /**
+ * Priority to select handler.
+ */
+ selectPriority?: number;
+}
+
+/**
+ * Service to interact with plugins to be shown in the main menu. Provides functions to register a plugin
+ * and notify an update in the data.
+ */
+@Injectable({
+ providedIn: 'root',
+})
+export class CoreHomeDelegate extends CoreDelegate {
+
+ protected loaded = false;
+ protected siteHandlers: Subject = new BehaviorSubject([]);
+ protected featurePrefix = 'CoreHomeDelegate_';
+
+ constructor() {
+ super('CoreHomeDelegate', true);
+
+ CoreEvents.on(CoreEvents.LOGOUT, this.clearSiteHandlers.bind(this));
+ }
+
+ /**
+ * Check if handlers are loaded.
+ *
+ * @return True if handlers are loaded, false otherwise.
+ */
+ areHandlersLoaded(): boolean {
+ return this.loaded;
+ }
+
+ /**
+ * Clear current site handlers. Reserved for core use.
+ */
+ protected clearSiteHandlers(): void {
+ this.loaded = false;
+ this.siteHandlers.next([]);
+ }
+
+ /**
+ * Get the handlers for the current site.
+ *
+ * @return An observable that will receive the handlers.
+ */
+ getHandlers(): Subject {
+ return this.siteHandlers;
+ }
+
+ /**
+ * Update handlers Data.
+ */
+ updateData(): void {
+ const displayData: CoreHomeHandlerToDisplay[] = [];
+
+ for (const name in this.enabledHandlers) {
+ const handler = this.enabledHandlers[name];
+ const data = handler.getDisplayData();
+
+ data.name = name;
+ data.priority = handler.priority;
+
+ displayData.push(data);
+ }
+
+ // Sort them by priority.
+ displayData.sort((a, b) => (b.priority || 0) - (a.priority || 0));
+
+ this.loaded = true;
+ this.siteHandlers.next(displayData);
+ }
+
+}
diff --git a/src/app/services/utils/dom.ts b/src/app/services/utils/dom.ts
index a8ae50b82..8ad03ba23 100644
--- a/src/app/services/utils/dom.ts
+++ b/src/app/services/utils/dom.ts
@@ -753,12 +753,12 @@ export class CoreDomUtilsProvider {
* @param findFunction The function used to find the element.
* @return Resolved if found, rejected if too many tries.
*/
- waitElementToExist(findFunction: () => HTMLElement): Promise {
+ waitElementToExist(findFunction: () => HTMLElement | null): Promise {
const promiseInterval = CoreUtils.instance.promiseDefer();
let tries = 100;
const clear = setInterval(() => {
- const element: HTMLElement = findFunction();
+ const element: HTMLElement | null = findFunction();
if (element) {
clearInterval(clear);
diff --git a/src/theme/variables.scss b/src/theme/variables.scss
index 9ad7d58e6..909025d64 100644
--- a/src/theme/variables.scss
+++ b/src/theme/variables.scss
@@ -23,7 +23,7 @@
--yellow: var(--custom-yellow, #fbad1a); // Accent (never text).
--purple: var(--custom-purple, #8e24aa); // Accent (never text).
- --core-color: var(--custom-main-color, #f98012);
+ --core-color: var(--custom-main-color, var(--orange));
--ion-color-primary: var(--core-color);
--ion-color-primary-rgb: 249,128,18;
@@ -92,21 +92,34 @@
--ion-text-color-rgb: 58,58,58;
ion-content {
- --background: #e9e9e9;
+ --background: var(--gray-light);
}
ion-tab-bar {
- --background: #626262;
- --color: #ffffff;
+ --background: var(--custom-bottom-tabs-background, var(--gray-darker));
+ --color: var(--custom-bottom-tabs-color, var(--white));
}
ion-toolbar {
- --color: var(--ion-color-primary-contrast);
- --background: var(--ion-color-primary);
+ --color: var(--custom-toolbar-color, var(--ion-color-primary-contrast));
+ --background: var(--custom-toolbar-background, var(--ion-color-primary));
}
- --core-login-background: var(--custom-login-background, white);
- --core-login-text-color: var(--custom-login-text-color, #3a3a3a);
+ core-tabs {
+ --background: var(--custom-tabs-background, var(--white));
+ ion-slide {
+ --background: var(--custom-tab-background, var(--white));
+ --color: var(--custom-tab-background, var(--gray-dark));
+ --border-color: var(--custom-tab-border-color, var(--gray));
+ --color-active: var(--custom-tab-color-active, var(--core-color));
+ --border-color-active: var(--custom-tab-border-color-active, var(--color-active));
+ }
+ }
+
+ --drop-shadow: var(--custom-drop-shadow, 0, 0, 0, 0.2);
+
+ --core-login-background: var(--custom-login-background, var(--white));
+ --core-login-text-color: var(--custom-login-text-color, var(--black));
}
/*
@@ -114,7 +127,7 @@
* -------------------------------------------
*/
:root body.dark {
- --ion-background-color: #3a3a3a;
+ --ion-background-color: #1e1e1e;
--ion-background-color-rgb: 18,18,18;
--ion-text-color: #ffffff;
@@ -144,7 +157,6 @@
--ion-tab-bar-background: #1f1f1f;
-
--ion-item-background: #1e1e1e;
--ion-card-background: #1c1c1d;
@@ -153,6 +165,15 @@
--background: var(--ion-background-color);
}
+ core-tabs {
+ --background: var(--custom-tabs-background, #3a3a3a);
+ ion-slide {
+ --background: var(--custom-tab-background, #3a3a3a);
+ --color: var(--custom-tab-background, var(--white));
+ --border-color: var(--custom-tab-border-color, var(--gray-light));
+ }
+ }
+
--core-login-background: var(--custom-login-background, #3a3a3a);
--core-login-text-color: var(--custom-login-text-color, white);
}