From 7ef503fab6c66bde12d1a3fe6f315afa8637c84a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Tue, 3 Nov 2020 08:37:59 +0100 Subject: [PATCH 1/7] MOBILE-3565 core-tabs: First version of the component --- src/app/components/components.module.ts | 4 + src/app/components/tabs/core-tabs.html | 31 + src/app/components/tabs/tabs.scss | 72 ++ src/app/components/tabs/tabs.ts | 629 ++++++++++++++++++ src/app/core/courses/courses.module.ts | 22 +- src/app/core/courses/handlers/dashboard.ts | 60 ++ .../courses/pages/dashboard/dashboard.html | 6 + .../pages/dashboard/dashboard.page.module.ts | 47 ++ .../courses/pages/dashboard/dashboard.page.ts | 36 + .../courses/pages/dashboard/dashboard.scss | 0 src/app/core/mainmenu/pages/home/home.html | 11 +- .../mainmenu/pages/home/home.page.module.ts | 7 + src/app/core/mainmenu/pages/home/home.page.ts | 59 +- .../core/mainmenu/services/home.delegate.ts | 172 +++++ src/app/services/utils/dom.ts | 4 +- src/theme/variables.scss | 41 +- 16 files changed, 1182 insertions(+), 19 deletions(-) create mode 100644 src/app/components/tabs/core-tabs.html create mode 100644 src/app/components/tabs/tabs.scss create mode 100644 src/app/components/tabs/tabs.ts create mode 100644 src/app/core/courses/handlers/dashboard.ts create mode 100644 src/app/core/courses/pages/dashboard/dashboard.html create mode 100644 src/app/core/courses/pages/dashboard/dashboard.page.module.ts create mode 100644 src/app/core/courses/pages/dashboard/dashboard.page.ts create mode 100644 src/app/core/courses/pages/dashboard/dashboard.scss create mode 100644 src/app/core/mainmenu/services/home.delegate.ts 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
-
+ + + + +
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); } From 3be2f204f41af83d107e830872d5c055cf9ac4f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Wed, 4 Nov 2020 16:16:57 +0100 Subject: [PATCH 2/7] MOBILE-3565 login: Fix some styles --- src/app/core/login/login.scss | 16 ++++++++++- .../login/pages/credentials/credentials.html | 5 ++-- .../core/login/pages/reconnect/reconnect.html | 22 +++++++-------- src/theme/app.scss | 28 +++++++++++++++++++ 4 files changed, 56 insertions(+), 15 deletions(-) diff --git a/src/app/core/login/login.scss b/src/app/core/login/login.scss index 6e67854fa..f3eb1f348 100644 --- a/src/app/core/login/login.scss +++ b/src/app/core/login/login.scss @@ -4,7 +4,16 @@ --color: var(--core-login-text-color); } - img { + form .item.item-input, + form .core-username.item { + margin-bottom: 16px; + } + + form .core-username.item p { + font-size: 16px; + } + + .core-login-site-logo img { max-width: 100%; } @@ -14,6 +23,7 @@ .core-sitename { font-size: 1.2rem; + margin-bottom: 0; } .core-login-site-logo { @@ -25,4 +35,8 @@ .core-login-forgotten-password { text-decoration: underline; } + + .core-login-reconnect-warning { + font-size: 0.9em; + } } diff --git a/src/app/core/login/pages/credentials/credentials.html b/src/app/core/login/pages/credentials/credentials.html index bf8dfa698..e9a66b194 100644 --- a/src/app/core/login/pages/credentials/credentials.html +++ b/src/app/core/login/pages/credentials/credentials.html @@ -14,7 +14,7 @@ - +