diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 52ae40f71..681958412 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -29,6 +29,10 @@ const routes: Routes = [ path: 'settings', loadChildren: () => import('./core/settings/settings.module').then( m => m.CoreAppSettingsPageModule), }, + { + path: 'mainmenu', + loadChildren: () => import('./core/mainmenu/mainmenu.module').then( m => m.CoreMainMenuModule), + }, ]; @NgModule({ diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 7a18e7723..3296ee02d 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -13,6 +13,8 @@ // limitations under the License. import { Component, OnInit } from '@angular/core'; +import { NavController } from '@ionic/angular'; + import { CoreLangProvider } from '@services/lang'; import { CoreEvents } from '@singletons/events'; @@ -24,7 +26,8 @@ import { CoreEvents } from '@singletons/events'; export class AppComponent implements OnInit { constructor( - private langProvider: CoreLangProvider, + protected langProvider: CoreLangProvider, + protected navCtrl: NavController, ) { } @@ -34,16 +37,13 @@ export class AppComponent implements OnInit { ngOnInit(): void { CoreEvents.on(CoreEvents.LOGOUT, () => { // Go to sites page when user is logged out. - // Due to DeepLinker, we need to use the ViewCtrl instead of name. - // Otherwise some pages are re-created when they shouldn't. - // TODO - // CoreApp.instance.getRootNavController().setRoot(CoreLoginSitesPage); + this.navCtrl.navigateRoot('/login/sites'); // Unload lang custom strings. this.langProvider.clearCustomStrings(); // Remove version classes from body. - // TODO + // @todo // this.removeVersionClass(); }); } diff --git a/src/app/core/login/login-routing.module.ts b/src/app/core/login/login-routing.module.ts index 8b1ae6f9a..5c528b09b 100644 --- a/src/app/core/login/login-routing.module.ts +++ b/src/app/core/login/login-routing.module.ts @@ -23,6 +23,11 @@ import { CoreLoginSitesPage } from './pages/sites/sites.page'; const routes: Routes = [ { path: '', + redirectTo: 'init', + pathMatch: 'full', + }, + { + path: 'init', component: CoreLoginInitPage, }, { diff --git a/src/app/core/login/login.module.ts b/src/app/core/login/login.module.ts index 5bfd7fd32..090f44698 100644 --- a/src/app/core/login/login.module.ts +++ b/src/app/core/login/login.module.ts @@ -27,7 +27,6 @@ import { CoreLoginCredentialsPage } from './pages/credentials/credentials.page'; import { CoreLoginInitPage } from './pages/init/init.page'; import { CoreLoginSitePage } from './pages/site/site.page'; import { CoreLoginSitesPage } from './pages/sites/sites.page'; -import { CoreLoginHelperProvider } from './services/helper'; @NgModule({ imports: [ @@ -47,8 +46,5 @@ import { CoreLoginHelperProvider } from './services/helper'; CoreLoginSitePage, CoreLoginSitesPage, ], - providers: [ - CoreLoginHelperProvider, - ], }) export class CoreLoginModule {} diff --git a/src/app/core/login/pages/credentials/credentials.page.ts b/src/app/core/login/pages/credentials/credentials.page.ts index d189c504f..8ff4ad4e2 100644 --- a/src/app/core/login/pages/credentials/credentials.page.ts +++ b/src/app/core/login/pages/credentials/credentials.page.ts @@ -243,7 +243,7 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy { this.siteId = id; - await CoreLoginHelper.instance.goToSiteInitialPage(undefined, undefined, undefined, undefined, this.urlToOpen); + await CoreLoginHelper.instance.goToSiteInitialPage({ urlToOpen: this.urlToOpen }); } catch (error) { CoreLoginHelper.instance.treatUserTokenError(siteUrl, error, username, password); diff --git a/src/app/core/login/pages/init/init.page.ts b/src/app/core/login/pages/init/init.page.ts index 2391f6c98..889a7178f 100644 --- a/src/app/core/login/pages/init/init.page.ts +++ b/src/app/core/login/pages/init/init.page.ts @@ -15,9 +15,13 @@ import { Component, OnInit } from '@angular/core'; import { NavController } from '@ionic/angular'; -import { CoreApp } from '@services/app'; +import { CoreApp, CoreRedirectData } from '@services/app'; import { CoreInit } from '@services/init'; import { SplashScreen } from '@singletons/core.singletons'; +import { CoreConstants } from '@core/constants'; +import { CoreSite } from '@/app/classes/site'; +import { CoreSites } from '@/app/services/sites'; +import { CoreLoginHelper, CoreLoginHelperProvider } from '../../services/helper'; /** * Page that displays a "splash screen" while the app is being initialized. @@ -40,55 +44,75 @@ export class CoreLoginInitPage implements OnInit { // Check if there was a pending redirect. const redirectData = CoreApp.instance.getRedirect(); + if (redirectData.siteId) { - // Unset redirect data. - CoreApp.instance.storeRedirect('', '', {}); - - // Only accept the redirect if it was stored less than 20 seconds ago. - if (redirectData.timemodified && Date.now() - redirectData.timemodified < 20000) { - // if (redirectData.siteId != CoreConstants.NO_SITE_ID) { - // // The redirect is pointing to a site, load it. - // return this.sitesProvider.loadSite(redirectData.siteId, redirectData.page, redirectData.params) - // .then((loggedIn) => { - - // if (loggedIn) { - // return this.loginHelper.goToSiteInitialPage(this.navCtrl, redirectData.page, redirectData.params, - // { animate: false }); - // } - // }).catch(() => { - // // Site doesn't exist. - // return this.loadPage(); - // }); - // } else { - // // No site to load, open the page. - // return this.loginHelper.goToNoSitePage(this.navCtrl, redirectData.page, redirectData.params); - // } - } + await this.handleRedirect(redirectData); + } else { + await this.loadPage(); } - await this.loadPage(); - // If we hide the splash screen now, the init view is still seen for an instant. Wait a bit to make sure it isn't seen. setTimeout(() => { SplashScreen.instance.hide(); }, 100); } + /** + * Treat redirect data. + * + * @param redirectData Redirect data. + */ + protected async handleRedirect(redirectData: CoreRedirectData): Promise { + // Unset redirect data. + CoreApp.instance.storeRedirect('', '', {}); + + // Only accept the redirect if it was stored less than 20 seconds ago. + if (redirectData.timemodified && Date.now() - redirectData.timemodified < 20000) { + if (redirectData.siteId != CoreConstants.NO_SITE_ID) { + // The redirect is pointing to a site, load it. + try { + const loggedIn = await CoreSites.instance.loadSite( + redirectData.siteId!, + redirectData.page, + redirectData.params, + ); + + if (!loggedIn) { + return; + } + + return CoreLoginHelper.instance.goToSiteInitialPage({ + redirectPage: redirectData.page, + redirectParams: redirectData.params, + }); + } catch (error) { + // Site doesn't exist. + return this.loadPage(); + } + } else { + // No site to load, open the page. + return CoreLoginHelper.instance.goToNoSitePage(redirectData.page, redirectData.params); + } + } + + return this.loadPage(); + } + /** * Load the right page. * * @return Promise resolved when done. */ protected async loadPage(): Promise { - // if (this.sitesProvider.isLoggedIn()) { - // if (this.loginHelper.isSiteLoggedOut()) { - // return this.sitesProvider.logout().then(() => { - // return this.loadPage(); - // }); - // } + if (CoreSites.instance.isLoggedIn()) { + if (CoreLoginHelper.instance.isSiteLoggedOut()) { + await CoreSites.instance.logout(); - // return this.loginHelper.goToSiteInitialPage(); - // } + return this.loadPage(); + } + + return CoreLoginHelper.instance.goToSiteInitialPage(); + } await this.navCtrl.navigateRoot('/login/sites'); } diff --git a/src/app/core/login/services/helper.ts b/src/app/core/login/services/helper.ts index e510e487f..5c7d9609b 100644 --- a/src/app/core/login/services/helper.ts +++ b/src/app/core/login/services/helper.ts @@ -34,11 +34,14 @@ import { CoreWSError } from '@classes/errors/wserror'; import { makeSingleton, Translate } from '@singletons/core.singletons'; import { CoreLogger } from '@singletons/logger'; import { CoreUrl } from '@singletons/url'; +import { NavigationOptions } from '@ionic/angular/providers/nav-controller'; /** * Helper provider that provides some common features regarding authentication. */ -@Injectable() +@Injectable({ + providedIn: 'root', +}) export class CoreLoginHelperProvider { static readonly OPEN_COURSE = 'open_course'; @@ -448,7 +451,7 @@ export class CoreLoginHelperProvider { * @return Promise resolved when done. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - goToNoSitePage(navCtrl: NavController, page: string, params?: Params): Promise { + goToNoSitePage(page?: string, params?: Params): Promise { // @todo return Promise.resolve(); } @@ -456,17 +459,11 @@ export class CoreLoginHelperProvider { /** * Go to the initial page of a site depending on 'userhomepage' setting. * - * @param navCtrl NavController to use. Defaults to app root NavController. - * @param page Name of the page to load after loading the main page. - * @param params Params to pass to the page. - * @param options Navigation options. - * @param url URL to open once the main menu is loaded. + * @param options Options. * @return Promise resolved when done. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - goToSiteInitialPage(navCtrl?: NavController, page?: string, params?: Params, options?: any, url?: string): Promise { - // @todo - return Promise.resolve(); + goToSiteInitialPage(options?: OpenMainMenuOptions): Promise { + return this.openMainMenu(options); } /** @@ -664,17 +661,32 @@ export class CoreLoginHelperProvider { /** * Open the main menu, loading a certain page. * - * @param navCtrl NavController. - * @param page Name of the page to load. - * @param params Params to pass to the page. - * @param options Navigation options. - * @param url URL to open once the main menu is loaded. + * @param options Options. * @return Promise resolved when done. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected openMainMenu(navCtrl: NavController, page: string, params: Params, options?: any, url?: string): Promise { - // @todo - return Promise.resolve(); + protected async openMainMenu(options?: OpenMainMenuOptions): Promise { + + // Due to DeepLinker, we need to remove the path from the URL before going to main menu. + // IonTabs checks the URL to determine which path to load for deep linking, so we clear the URL. + // @todo this.location.replaceState(''); + + if (options?.redirectPage == CoreLoginHelperProvider.OPEN_COURSE) { + // Load the main menu first, and then open the course. + try { + await this.navCtrl.navigateRoot('/mainmenu'); + } finally { + // @todo: Open course. + } + } else { + // Open the main menu. + const queryParams: Params = Object.assign({}, options); + delete queryParams.navigationOptions; + + await this.navCtrl.navigateRoot('/mainmenu', { + queryParams, + ...options?.navigationOptions, + }); + } } /** @@ -1375,3 +1387,10 @@ type StoredLoginLaunchData = { pageParams: Params; ssoUrlParams: CoreUrlParams; }; + +type OpenMainMenuOptions = { + redirectPage?: string; // Route of the page to open in main menu. If not defined, default tab will be selected. + redirectParams?: Params; // Params to pass to the selected tab if any. + urlToOpen?: string; // URL to open once the main menu is loaded. + navigationOptions?: NavigationOptions; // Navigation options. +}; diff --git a/src/app/core/mainmenu/lang/en.json b/src/app/core/mainmenu/lang/en.json new file mode 100644 index 000000000..4ff96fbf7 --- /dev/null +++ b/src/app/core/mainmenu/lang/en.json @@ -0,0 +1,6 @@ +{ + "changesite": "Change site", + "help": "Help", + "logout": "Log out", + "website": "Website" +} \ No newline at end of file diff --git a/src/app/core/mainmenu/mainmenu-routing.module.ts b/src/app/core/mainmenu/mainmenu-routing.module.ts new file mode 100644 index 000000000..2ad58ff75 --- /dev/null +++ b/src/app/core/mainmenu/mainmenu-routing.module.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { CoreMainMenuPage } from './pages/menu/menu.page'; +import { CoreMainMenuMorePage } from './pages/more/more.page'; + +const routes: Routes = [ + { + path: '', + component: CoreMainMenuPage, + children: [ + { + path: 'more', + component: CoreMainMenuMorePage, + }, + ], + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class CoreMainMenuRoutingModule {} diff --git a/src/app/core/mainmenu/mainmenu.module.ts b/src/app/core/mainmenu/mainmenu.module.ts new file mode 100644 index 000000000..1cd8f561e --- /dev/null +++ b/src/app/core/mainmenu/mainmenu.module.ts @@ -0,0 +1,42 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreComponentsModule } from '@/app/components/components.module'; +import { CoreDirectivesModule } from '@/app/directives/directives.module'; + +import { CoreMainMenuRoutingModule } from './mainmenu-routing.module'; +import { CoreMainMenuPage } from './pages/menu/menu.page'; +import { CoreMainMenuMorePage } from './pages/more/more.page'; + +@NgModule({ + imports: [ + CommonModule, + IonicModule, + CoreMainMenuRoutingModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + ], + declarations: [ + CoreMainMenuPage, + CoreMainMenuMorePage, + ], +}) +export class CoreMainMenuModule {} diff --git a/src/app/core/mainmenu/pages/menu/menu.html b/src/app/core/mainmenu/pages/menu/menu.html new file mode 100644 index 000000000..8c53535dc --- /dev/null +++ b/src/app/core/mainmenu/pages/menu/menu.html @@ -0,0 +1,23 @@ + + + + + + + {{ tab.title | translate }} + + + + + {{ 'core.more' | translate }} + + + +
+
+ {{ "core.youreonline" | translate }} +
+
+ {{ "core.youreoffline" | translate }} +
+
diff --git a/src/app/core/mainmenu/pages/menu/menu.page.ts b/src/app/core/mainmenu/pages/menu/menu.page.ts new file mode 100644 index 000000000..364853780 --- /dev/null +++ b/src/app/core/mainmenu/pages/menu/menu.page.ts @@ -0,0 +1,225 @@ +// (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, ChangeDetectorRef } from '@angular/core'; +import { ActivatedRoute, Params } from '@angular/router'; +import { NavController } from '@ionic/angular'; +import { Subscription } from 'rxjs'; + +import { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreEvents, CoreEventObserver, CoreEventLoadPageMainMenuData } from '@singletons/events'; +import { CoreMainMenu } from '../../services/mainmenu'; +import { CoreMainMenuDelegate, CoreMainMenuHandlerToDisplay } from '../../services/delegate'; + +/** + * Page that displays the main menu of the app. + */ +@Component({ + selector: 'page-core-mainmenu', + templateUrl: 'menu.html', + styleUrls: ['menu.scss'], +}) +export class CoreMainMenuPage implements OnInit, OnDestroy { + + tabs: CoreMainMenuHandlerToDisplay[] = []; + allHandlers?: CoreMainMenuHandlerToDisplay[]; + loaded = false; + redirectPage?: string; + redirectParams?: Params; + showTabs = false; + tabsPlacement = 'bottom'; + + protected subscription?: Subscription; + protected redirectObs?: CoreEventObserver; + protected pendingRedirect?: CoreEventLoadPageMainMenuData; + protected urlToOpen?: string; + protected mainMenuId: number; + protected keyboardObserver?: CoreEventObserver; + + @ViewChild('mainTabs') mainTabs?: any; // CoreIonTabsComponent; + + constructor( + protected route: ActivatedRoute, + protected navCtrl: NavController, + protected menuDelegate: CoreMainMenuDelegate, + protected changeDetector: ChangeDetectorRef, + ) { + this.mainMenuId = CoreApp.instance.getMainMenuId(); + } + + /** + * Initialize the component. + */ + ngOnInit(): void { + if (!CoreSites.instance.isLoggedIn()) { + this.navCtrl.navigateRoot('/login/init'); + + return; + } + + this.route.queryParams.subscribe(params => { + const redirectPage = params['redirectPage']; + if (redirectPage) { + this.pendingRedirect = { + redirectPage: redirectPage, + redirectParams: params['redirectParams'], + }; + } + + this.urlToOpen = params['urlToOpen']; + }); + + this.showTabs = true; + + this.redirectObs = CoreEvents.on(CoreEvents.LOAD_PAGE_MAIN_MENU, (data: CoreEventLoadPageMainMenuData) => { + if (!this.loaded) { + // View isn't ready yet, wait for it to be ready. + this.pendingRedirect = data; + } else { + delete this.pendingRedirect; + this.handleRedirect(data); + } + }); + + this.subscription = this.menuDelegate.getHandlers().subscribe((handlers) => { + // Remove the handlers that should only appear in the More menu. + this.allHandlers = handlers.filter((handler) => !handler.onlyInMore); + + this.initHandlers(); + + if (this.loaded && this.pendingRedirect) { + // Wait for tabs to be initialized and then handle the redirect. + setTimeout(() => { + if (this.pendingRedirect) { + this.handleRedirect(this.pendingRedirect); + delete this.pendingRedirect; + } + }); + } + }); + + window.addEventListener('resize', this.initHandlers.bind(this)); + + if (CoreApp.instance.isIOS()) { + // In iOS, the resize event is triggered before the keyboard is opened/closed and not triggered again once done. + // Init handlers again once keyboard is closed since the resize event doesn't have the updated height. + this.keyboardObserver = CoreEvents.on(CoreEvents.KEYBOARD_CHANGE, (kbHeight: number) => { + if (kbHeight === 0) { + this.initHandlers(); + + // If the device is slow it can take a bit more to update the window height. Retry in a few ms. + setTimeout(() => { + this.initHandlers(); + }, 250); + } + }); + } + + CoreApp.instance.setMainMenuOpen(this.mainMenuId, true); + } + + /** + * Init handlers on change (size or handlers). + */ + initHandlers(): void { + if (this.allHandlers) { + this.tabsPlacement = CoreMainMenu.instance.getTabPlacement(); + + const handlers = this.allHandlers.slice(0, CoreMainMenu.instance.getNumItems()); // Get main handlers. + + // 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: CoreMainMenuHandlerToDisplay[] = []; + + for (let i = 0; i < handlers.length; i++) { + const handler = handlers[i]; + + // Check if the handler is already in the tabs list. If so, use it. + const tab = this.tabs.find((tab) => tab.title == handler.title && tab.icon == handler.icon); + + tab ? tab.hide = false : null; + handler.hide = false; + + newTabs.push(tab || handler); + } + + // Maintain tab in phantom mode in case is not visible. + const selectedTab = this.mainTabs?.getSelected(); + if (selectedTab) { + const oldTab = this.tabs.find((tab) => tab.page == selectedTab.root && tab.icon == selectedTab.tabIcon); + + if (oldTab) { + // Check if the selected handler is visible. + const isVisible = newTabs.some((newTab) => oldTab.title == newTab.title && oldTab.icon == newTab.icon); + + if (!isVisible) { + oldTab.hide = true; + newTabs.push(oldTab); + } + } + } + + this.tabs = newTabs; + + // Sort them by priority so new handlers are in the right position. + this.tabs.sort((a, b) => (b.priority || 0) - (a.priority || 0)); + + this.loaded = this.menuDelegate.areHandlersLoaded(); + } + + if (this.urlToOpen) { + // There's a content link to open. + // @todo: Treat URL. + } + } + + /** + * Handle a redirect. + * + * @param data Data received. + */ + protected handleRedirect(data: CoreEventLoadPageMainMenuData): void { + // Check if the redirect page is the root page of any of the tabs. + const i = this.tabs.findIndex((tab) => tab.page == data.redirectPage); + + if (i >= 0) { + // Tab found. Set the params. + this.tabs[i].pageParams = Object.assign({}, data.redirectParams); + } else { + // Tab not found, use a phantom tab. + this.redirectPage = data.redirectPage; + this.redirectParams = data.redirectParams; + } + + // Force change detection, otherwise sometimes the tab was selected before the params were applied. + this.changeDetector.detectChanges(); + + setTimeout(() => { + // Let the tab load the params before navigating. + this.mainTabs?.selectTabRootByIndex(i + 1); + }); + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.subscription?.unsubscribe(); + this.redirectObs?.off(); + window.removeEventListener('resize', this.initHandlers.bind(this)); + CoreApp.instance.setMainMenuOpen(this.mainMenuId, false); + this.keyboardObserver?.off(); + } + +} diff --git a/src/app/core/mainmenu/pages/menu/menu.scss b/src/app/core/mainmenu/pages/menu/menu.scss new file mode 100644 index 000000000..608068077 --- /dev/null +++ b/src/app/core/mainmenu/pages/menu/menu.scss @@ -0,0 +1,93 @@ +ion-icon.tab-button-icon { + text-overflow: unset; + overflow: visible; + text-align: center; + transition: margin 500ms ease-in-out, transform 300ms ease-in-out; +} + +.ion-md-fa-graduation-cap, +.ion-ios-fa-graduation-cap, +.ion-ios-fa-graduation-cap-outline, +.ion-fa-graduation-cap { + // @todo @extend .fa-graduation-cap; + // @todo @extend .fa; + font-size: 21px; + height: 21px; + +} + +.ion-ios-fa-graduation-cap-outline { + color: transparent; + -webkit-text-stroke-width: 0.8px; + // @todo -webkit-text-stroke-color: $tabs-tab-color-inactive; + font-size: 23px; + height: 23px; +} + +.ion-md-fa-newspaper-o, +.ion-ios-fa-newspaper-o, +.ion-ios-fa-newspaper-o-outline, +.ion-fa-newspaper-o { + // @todo @extend .fa-newspaper-o; + // @todo @extend .fa; + font-size: 22px; + height: 22px; +} + +.ion-ios-fa-newspaper-o-outline { + font-size: 23px; + height: 23px; +} + +.core-network-message { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding-left: 10px; + padding-right: 10px; + text-align: center; + color: white; + visibility: hidden; + height: 0; + transition: all 500ms ease-in-out; + opacity: .8; +} + +.core-online-message, +.core-offline-message { + display: none; +} + + +/** +.core-online ion-app.app-root page-core-mainmenu, +.core-offline ion-app.app-root page-core-mainmenu { + + core-ion-tabs[tabsplacement="bottom"] ion-icon.tab-button-icon { + margin-bottom: $core-network-message-height / 2; + + &.icon-ios { + margin-bottom: 14px; + } + } + + .core-network-message { + visibility: visible; + height: $core-network-message-height; + pointer-events: none; + } +} + +.core-offline ion-app.app-root page-core-mainmenu .core-offline-message, +.core-online ion-app.app-root page-core-mainmenu .core-online-message { + display: block; +} + +.core-online ion-app.app-root page-core-mainmenu .core-network-message { + background: $green; +} + +.core-offline ion-app.app-root page-core-mainmenu .core-network-message { + background: $red; +}*/ diff --git a/src/app/core/mainmenu/pages/more/more.html b/src/app/core/mainmenu/pages/more/more.html new file mode 100644 index 000000000..c349498a4 --- /dev/null +++ b/src/app/core/mainmenu/pages/more/more.html @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + +

{{siteInfo.fullname}}

+

+

{{ siteUrl }}

+
+
+ + + + + + + +

{{ handler.title | translate}}

+
+ {{handler.badge}} + +
+
+ + + +

{{item.label}}

+
+
+ + + +

{{item.label}}

+
+
+
+ + + +

{{ 'core.scanqr' | translate }}

+
+
+ + + +

{{ 'core.mainmenu.website' | translate }}

+
+
+ + + +

{{ 'core.mainmenu.help' | translate }}

+
+
+ + + +

{{ 'core.settings.preferences' | translate }}

+
+
+ + + +

{{ logoutLabel | translate }}

+
+
+ + + + +

{{ 'core.settings.appsettings' | translate }}

+
+
+
+
diff --git a/src/app/core/mainmenu/pages/more/more.page.ts b/src/app/core/mainmenu/pages/more/more.page.ts new file mode 100644 index 000000000..bd47f45d8 --- /dev/null +++ b/src/app/core/mainmenu/pages/more/more.page.ts @@ -0,0 +1,182 @@ +// (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 } from '@angular/core'; +import { Subscription } from 'rxjs'; + +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreSiteInfo } from '@classes/site'; +import { CoreLoginHelper } from '@core/login/services/helper'; +import { CoreMainMenuDelegate, CoreMainMenuHandlerData } from '../../services/delegate'; +import { CoreMainMenu, CoreMainMenuCustomItem } from '../../services/mainmenu'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; + +/** + * Page that displays the main menu of the app. + */ +@Component({ + selector: 'page-core-mainmenu-more', + templateUrl: 'more.html', + styleUrls: ['more.scss'], +}) +export class CoreMainMenuMorePage implements OnInit, OnDestroy { + + handlers?: CoreMainMenuHandlerData[]; + allHandlers?: CoreMainMenuHandlerData[]; + handlersLoaded = false; + siteInfo?: CoreSiteInfo; + siteName?: string; + logoutLabel?: string; + showScanQR: boolean; + showWeb?: boolean; + showHelp?: boolean; + docsUrl?: string; + customItems?: CoreMainMenuCustomItem[]; + siteUrl?: string; + + protected subscription!: Subscription; + protected langObserver: CoreEventObserver; + protected updateSiteObserver: CoreEventObserver; + + constructor( + protected menuDelegate: CoreMainMenuDelegate, + ) { + + this.langObserver = CoreEvents.on(CoreEvents.LANGUAGE_CHANGED, this.loadSiteInfo.bind(this)); + this.updateSiteObserver = CoreEvents.on( + CoreEvents.SITE_UPDATED, + this.loadSiteInfo.bind(this), + CoreSites.instance.getCurrentSiteId(), + ); + this.loadSiteInfo(); + this.showScanQR = CoreUtils.instance.canScanQR() && + !CoreSites.instance.getCurrentSite()?.isFeatureDisabled('CoreMainMenuDelegate_QrReader'); + } + + /** + * Initialize component. + */ + ngOnInit(): void { + // Load the handlers. + this.subscription = this.menuDelegate.getHandlers().subscribe((handlers) => { + this.allHandlers = handlers; + + this.initHandlers(); + }); + + window.addEventListener('resize', this.initHandlers.bind(this)); + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + window.removeEventListener('resize', this.initHandlers.bind(this)); + this.langObserver?.off(); + this.updateSiteObserver?.off(); + this.subscription?.unsubscribe(); + } + + /** + * Init handlers on change (size or handlers). + */ + initHandlers(): void { + if (!this.allHandlers) { + return; + } + + // Calculate the main handlers not to display them in this view. + const mainHandlers = this.allHandlers + .filter((handler) => !handler.onlyInMore) + .slice(0, CoreMainMenu.instance.getNumItems()); + + // Get only the handlers that don't appear in the main view. + this.handlers = this.allHandlers.filter((handler) => mainHandlers.indexOf(handler) == -1); + + this.handlersLoaded = this.menuDelegate.areHandlersLoaded(); + } + + /** + * Load the site info required by the view. + */ + protected async loadSiteInfo(): Promise { + const currentSite = CoreSites.instance.getCurrentSite(); + + if (!currentSite) { + return; + } + + this.siteInfo = currentSite.getInfo(); + this.siteName = currentSite.getSiteName(); + this.siteUrl = currentSite.getURL(); + this.logoutLabel = CoreLoginHelper.instance.getLogoutLabel(currentSite); + this.showWeb = !currentSite.isFeatureDisabled('CoreMainMenuDelegate_website'); + this.showHelp = !currentSite.isFeatureDisabled('CoreMainMenuDelegate_help'); + + this.docsUrl = await currentSite.getDocsUrl(); + + this.customItems = await CoreMainMenu.instance.getCustomMenuItems(); + } + + /** + * Open a handler. + * + * @param handler Handler to open. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + openHandler(handler: CoreMainMenuHandlerData): void { + // @todo + } + + /** + * Open an embedded custom item. + * + * @param item Item to open. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + openItem(item: CoreMainMenuCustomItem): void { + // @todo + } + + /** + * Open app settings page. + */ + openAppSettings(): void { + // @todo + } + + /** + * Open site settings page. + */ + openSitePreferences(): void { + // @todo + } + + /** + * Scan and treat a QR code. + */ + async scanQR(): Promise { + // Scan for a QR code. + // @todo + } + + /** + * Logout the user. + */ + logout(): void { + CoreSites.instance.logout(); + } + +} diff --git a/src/app/core/mainmenu/pages/more/more.scss b/src/app/core/mainmenu/pages/more/more.scss new file mode 100644 index 000000000..301c56874 --- /dev/null +++ b/src/app/core/mainmenu/pages/more/more.scss @@ -0,0 +1,97 @@ +/* +$core-more-icon: $gray-darker !default; +$core-more-background-ios: $list-ios-background-color !default; +$core-more-background-md: $list-md-background-color !default; +$core-more-activated-background-ios: color-shade($core-more-background-ios) !default; +$core-more-activated-background-md: color-shade($core-more-background-md) !default; +$core-more-divider-ios: $item-ios-divider-background !default; +$core-more-divider-md: $item-md-divider-background !default; +$core-more-border-ios: $list-ios-border-color !default; +$core-more-border-md: $list-md-border-color !default; +$core-more-color-ios: $list-ios-text-color!default; +$core-more-color-md: $list-md-text-color !default; + +.item-block { + &.item-ios { + background-color: $core-more-background-ios; + color: $core-more-color-ios; + p { + color: $core-more-color-ios; + } + + .item-inner { + border-bottom: $hairlines-width solid $core-more-border-ios; + } + } + &.item-md { + background-color: $core-more-background-md; + color: $core-more-color-md; + p { + color: $core-more-color-md; + } + + .item-inner { + border-bottom: 1px solid $core-more-border-md; + } + } + + &.activated { + &.item-ios { + background-color: $core-more-activated-background-ios; + } + &.item-md { + background-color: $core-more-activated-background-md; + } + } +} + +ion-icon { + color: $core-more-icon; +} + +.item-divider { + &.item-ios { + background-color: $core-more-divider-ios; + } + + &.item-md { + background-color: $core-more-divider-md; + border-bottom: $core-more-border-md; + } +} + +@include darkmode() { + ion-icon { + color: $core-dark-text-color; + } + + .item-divider { + &.item-ios, + &.item-md { + color: $core-dark-text-color; + background-color: $core-dark-item-divider-bg-color; + } + } + + .item-block { + &.item-ios, + &.item-md { + color: $core-dark-text-color; + background-color: $core-dark-item-bg-color; + p { + color: $core-dark-text-color; + } + + } + + &.activated { + &.item-ios { + background-color: $core-more-activated-background-ios; + } + &.item-md { + background-color: $core-more-activated-background-md; + } + } + } +} +*/ \ No newline at end of file diff --git a/src/app/core/mainmenu/services/delegate.ts b/src/app/core/mainmenu/services/delegate.ts new file mode 100644 index 000000000..41de92992 --- /dev/null +++ b/src/app/core/mainmenu/services/delegate.ts @@ -0,0 +1,175 @@ +// (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 CoreMainMenuHandler extends CoreDelegateHandler { + /** + * The highest priority is displayed first. + */ + priority?: number; + + /** + * Returns the data needed to render the handler. + * + * @return Data. + */ + getDisplayData(): CoreMainMenuHandlerData; +} + +/** + * Data needed to render a main menu handler. It's returned by the handler. + */ +export interface CoreMainMenuHandlerData { + /** + * Name of the page to load for the handler. + */ + page: string; + + /** + * Title to display for the handler. + */ + title: string; + + /** + * Name of the icon to display for the handler. + */ + icon: string; // Name of the icon to display in the tab. + + /** + * Class to add to the displayed handler. + */ + class?: string; + + /** + * 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; + + /** + * If true, the badge number is being loaded. Only used if showBadge is true. + */ + loading?: boolean; + + /** + * Params to pass to the page. + */ + pageParams?: Params; + + /** + * Whether the handler should only appear in More menu. + */ + onlyInMore?: boolean; +} + +/** + * Data returned by the delegate for each handler. + */ +export interface CoreMainMenuHandlerToDisplay extends CoreMainMenuHandlerData { + /** + * Name of the handler. + */ + name?: string; + + /** + * Priority of the handler. + */ + priority?: number; + + /** + * Hide tab. Used then resizing. + */ + hide?: boolean; +} + +/** + * 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 CoreMainMenuDelegate extends CoreDelegate { + + protected loaded = false; + protected siteHandlers: Subject = new BehaviorSubject([]); + protected featurePrefix = 'CoreMainMenuDelegate_'; + + constructor() { + super('CoreMainMenuDelegate', 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: CoreMainMenuHandlerToDisplay[] = []; + + for (const name in this.enabledHandlers) { + const handler = this.enabledHandlers[name]; + const data = handler.getDisplayData(); + + data.name = name; + data.priority = handler.priority; + } + + // 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/core/mainmenu/services/mainmenu.ts b/src/app/core/mainmenu/services/mainmenu.ts new file mode 100644 index 000000000..2385272d1 --- /dev/null +++ b/src/app/core/mainmenu/services/mainmenu.ts @@ -0,0 +1,275 @@ +// (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 { CoreApp } from '@services/app'; +import { CoreLang } from '@services/lang'; +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreConstants } from '@core/constants'; +import { CoreMainMenuDelegate, CoreMainMenuHandlerToDisplay } from './delegate'; +import { makeSingleton } from '@singletons/core.singletons'; + +/** + * Service that provides some features regarding Main Menu. + */ +@Injectable({ + providedIn: 'root', +}) +export class CoreMainMenuProvider { + + static readonly NUM_MAIN_HANDLERS = 4; + static readonly ITEM_MIN_WIDTH = 72; // Min with of every item, based on 5 items on a 360 pixel wide screen. + + protected tablet = false; + + constructor(protected menuDelegate: CoreMainMenuDelegate) { + this.tablet = !!(window?.innerWidth && window.innerWidth >= 576 && window.innerHeight >= 576); + } + + /** + * Get the current main menu handlers. + * + * @return Promise resolved with the current main menu handlers. + */ + getCurrentMainMenuHandlers(): Promise { + const deferred = CoreUtils.instance.promiseDefer(); + + const subscription = this.menuDelegate.getHandlers().subscribe((handlers) => { + subscription?.unsubscribe(); + + // Remove the handlers that should only appear in the More menu. + handlers = handlers.filter(handler => !handler.onlyInMore); + + // Return main handlers. + deferred.resolve(handlers.slice(0, this.getNumItems())); + }); + + return deferred.promise; + } + + /** + * Get a list of custom menu items for a certain site. + * + * @param siteId Site ID. If not defined, current site. + * @return List of custom menu items. + */ + async getCustomMenuItems(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const itemsString = site.getStoredConfig('tool_mobile_custommenuitems'); + const map: CustomMenuItemsMap = {}; + const result: CoreMainMenuCustomItem[] = []; + + let position = 0; // Position of each item, to keep the same order as it's configured. + + if (!itemsString || typeof itemsString != 'string') { + // Setting not valid. + return result; + } + + // Add items to the map. + const items = itemsString.split(/(?:\r\n|\r|\n)/); + items.forEach((item) => { + const values = item.split('|'); + const label = values[0] ? values[0].trim() : values[0]; + const url = values[1] ? values[1].trim() : values[1]; + const type = values[2] ? values[2].trim() : values[2]; + const lang = (values[3] ? values[3].trim() : values[3]) || 'none'; + let icon = values[4] ? values[4].trim() : values[4]; + + if (!label || !url || !type) { + // Invalid item, ignore it. + return; + } + + const id = url + '#' + type; + if (!icon) { + // Icon not defined, use default one. + icon = type == 'embedded' ? 'fa-square-o' : 'fa-link'; // @todo: Find a better icon for embedded. + } + + if (!map[id]) { + // New entry, add it to the map. + map[id] = { + url: url, + type: type, + position: position, + labels: {}, + }; + position++; + } + + map[id].labels[lang.toLowerCase()] = { + label: label, + icon: icon, + }; + }); + + if (!position) { + // No valid items found, stop. + return result; + } + + const currentLang = await CoreLang.instance.getCurrentLanguage(); + + const fallbackLang = CoreConstants.CONFIG.default_lang || 'en'; + + // Get the right label for each entry and add it to the result. + for (const id in map) { + const entry = map[id]; + let data = entry.labels[currentLang] || entry.labels[currentLang + '_only'] || + entry.labels.none || entry.labels[fallbackLang]; + + if (!data) { + // No valid label found, get the first one that is not "_only". + for (const lang in entry.labels) { + if (lang.indexOf('_only') == -1) { + data = entry.labels[lang]; + break; + } + } + + if (!data) { + // No valid label, ignore this entry. + continue; + } + } + + result[entry.position] = { + url: entry.url, + type: entry.type, + label: data.label, + icon: data.icon, + }; + } + + // Remove undefined values. + return result.filter((entry) => typeof entry != 'undefined'); + } + + /** + * Get the number of items to be shown on the main menu bar. + * + * @return Number of items depending on the device width. + */ + getNumItems(): number { + if (!this.isResponsiveMainMenuItemsDisabledInCurrentSite() && window && window.innerWidth) { + let numElements: number; + + if (this.tablet) { + // Tablet, menu will be displayed vertically. + numElements = Math.floor(window.innerHeight / CoreMainMenuProvider.ITEM_MIN_WIDTH); + } else { + numElements = Math.floor(window.innerWidth / CoreMainMenuProvider.ITEM_MIN_WIDTH); + + // Set a maximum elements to show and skip more button. + numElements = numElements >= 5 ? 5 : numElements; + } + + // Set a mínimum elements to show and skip more button. + return numElements > 1 ? numElements - 1 : 1; + } + + return CoreMainMenuProvider.NUM_MAIN_HANDLERS; + } + + /** + * Get tabs placement depending on the device size. + * + * @return Tabs placement including side value. + */ + getTabPlacement(): string { + const tablet = !!(window.innerWidth && window.innerWidth >= 576 && (window.innerHeight >= 576 || + ((CoreApp.instance.isKeyboardVisible() || CoreApp.instance.isKeyboardOpening()) && window.innerHeight >= 200))); + + if (tablet != this.tablet) { + this.tablet = tablet; + + // @todo Resize so content margins can be updated. + } + + return tablet ? 'side' : 'bottom'; + } + + /** + * Check if a certain page is the root of a main menu handler currently displayed. + * + * @param page Name of the page. + * @param pageParams Page params. + * @return Promise resolved with boolean: whether it's the root of a main menu handler. + */ + async isCurrentMainMenuHandler(pageName: string): Promise { + const handlers = await this.getCurrentMainMenuHandlers(); + + const handler = handlers.find((handler) => handler.page == pageName); + + return !!handler; + } + + /** + * Check if responsive main menu items is disabled in the current site. + * + * @return Whether it's disabled. + */ + protected isResponsiveMainMenuItemsDisabledInCurrentSite(): boolean { + const site = CoreSites.instance.getCurrentSite(); + + return !!site?.isFeatureDisabled('NoDelegate_ResponsiveMainMenuItems'); + } + +} + +export class CoreMainMenu extends makeSingleton(CoreMainMenuProvider) {} + +/** + * Custom main menu item. + */ +export interface CoreMainMenuCustomItem { + /** + * Type of the item: app, inappbrowser, browser or embedded. + */ + type: string; + + /** + * Url of the item. + */ + url: string; + + /** + * Label to display for the item. + */ + label: string; + + /** + * Name of the icon to display for the item. + */ + icon: string; +} + +/** + * Map of custom menu items. + */ +type CustomMenuItemsMap = Record; diff --git a/src/app/services/sites.ts b/src/app/services/sites.ts index ed32a9dbc..9292eacf4 100644 --- a/src/app/services/sites.ts +++ b/src/app/services/sites.ts @@ -1312,7 +1312,7 @@ export class CoreSitesProvider { async logout(): Promise { await this.dbReady; - let siteId; + let siteId: string | undefined; const promises: Promise[] = []; if (this.currentSite) { diff --git a/src/app/singletons/events.ts b/src/app/singletons/events.ts index 51152d929..4bc192fa3 100644 --- a/src/app/singletons/events.ts +++ b/src/app/singletons/events.ts @@ -208,3 +208,11 @@ export type CoreEventLoadingChangedData = { loaded: boolean; uniqueId: string; }; + +/** + * Data passed to LOAD_PAGE_MAIN_MENU event. + */ +export type CoreEventLoadPageMainMenuData = { + redirectPage: string; + redirectParams?: Params; +}; diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index 2c01ee081..29e9ac335 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -304,6 +304,10 @@ "core.browser": "Browser", "core.copiedtoclipboard": "Text copied to clipboard", "core.login.yourenteredsite": "Connect to your site", + "core.mainmenu.changesite": "Change site", + "core.mainmenu.help": "Help", + "core.mainmenu.logout": "Log out", + "core.mainmenu.website": "Website", "core.no": "No", "core.offline": "Offline", "core.ok": "OK",