MOBILE-3565 mainmenu: Initial implementation of main menu

main
Dani Palou 2020-10-22 13:04:57 +02:00
parent df4dfa6eb5
commit a332faa81e
21 changed files with 1366 additions and 66 deletions

View File

@ -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({

View File

@ -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();
});
}

View File

@ -23,6 +23,11 @@ import { CoreLoginSitesPage } from './pages/sites/sites.page';
const routes: Routes = [
{
path: '',
redirectTo: 'init',
pathMatch: 'full',
},
{
path: 'init',
component: CoreLoginInitPage,
},
{

View File

@ -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 {}

View File

@ -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);

View File

@ -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<void> {
// 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<void> {
// 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');
}

View File

@ -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<any> {
goToNoSitePage(page?: string, params?: Params): Promise<any> {
// @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<any> {
// @todo
return Promise.resolve();
goToSiteInitialPage(options?: OpenMainMenuOptions): Promise<void> {
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<any> {
// @todo
return Promise.resolve();
protected async openMainMenu(options?: OpenMainMenuOptions): Promise<void> {
// 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.
};

View File

@ -0,0 +1,6 @@
{
"changesite": "Change site",
"help": "Help",
"logout": "Log out",
"website": "Website"
}

View File

@ -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 {}

View File

@ -0,0 +1,42 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { 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 {}

View File

@ -0,0 +1,23 @@
<ion-tabs #mainTabs [hidden]="!showTabs"> <!-- [loaded]="loaded" -->
<ion-tab-bar slot="bottom">
<ion-tab-button tab="redirect" [disabled]="true" [hidden]="true"></ion-tab-button> <!-- [show]="false" [root]="redirectPage" [rootParams]="redirectParams" -->
<ion-tab-button *ngFor="let tab of tabs" [tab]="tab.page" [disabled]="tab.hide" layout="label-hide"> <!-- [rootParams]="tab.pageParams" [tabBadge]="tab.badge" class="{{tab.class}}" [enabled]="!tab.hide" [show]="!tab.hide" -->
<core-icon [name]="tab.icon"></core-icon>
<ion-label>{{ tab.title | translate }}</ion-label>
</ion-tab-button>
<ion-tab-button tab="more" layout="label-hide">
<core-icon name="fa-bars"></core-icon>
<ion-label>{{ 'core.more' | translate }}</ion-label>
</ion-tab-button>
</ion-tab-bar>
</ion-tabs>
<div class="core-network-message" [hidden]="!showTabs">
<div class="core-online-message">
{{ "core.youreonline" | translate }}
</div>
<div class="core-offline-message">
{{ "core.youreoffline" | translate }}
</div>
</div>

View File

@ -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();
}
}

View File

@ -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;
}*/

View File

@ -0,0 +1,84 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title><core-format-text [text]="siteName" contextLevel="system" [contextInstanceId]="0"></core-format-text></ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-list>
<ion-item *ngIf="siteInfo" class="ion-text-wrap"> <!-- @todo core-user-link [userId]="siteInfo.userid" -->
<ion-avatar slot="start"></ion-avatar> <!-- @todo core-user-avatar [user]="siteInfo" -->
<ion-label>
<h2>{{siteInfo.fullname}}</h2>
<p><core-format-text [text]="siteName" contextLevel="system" [contextInstanceId]="0" [wsNotFiltered]="true"></core-format-text></p>
<p>{{ siteUrl }}</p>
</ion-label>
</ion-item>
<ion-item-divider></ion-item-divider>
<ion-item class="ion-text-center" *ngIf="(!handlers || !handlers.length) && !handlersLoaded">
<ion-spinner></ion-spinner>
</ion-item>
<ion-item *ngFor="let handler of handlers" [ngClass]="['core-moremenu-handler', handler.class || '']" (click)="openHandler(handler)" title="{{ handler.title | translate }}" detail="true">
<core-icon [name]="handler.icon" slot="start"></core-icon>
<ion-label>
<h2>{{ handler.title | translate}}</h2>
</ion-label>
<ion-badge slot="end" *ngIf="handler.showBadge" [hidden]="handler.loading || !handler.badge">{{handler.badge}}</ion-badge>
<ion-spinner slot="end" *ngIf="handler.showBadge && handler.loading"></ion-spinner>
</ion-item>
<div *ngFor="let item of customItems" class="core-moremenu-customitem">
<ion-item *ngIf="item.type != 'embedded'" [href]="item.url" title="{{item.label}}"> <!-- @todo core-link [capture]="item.type == 'app'" [inApp]="item.type == 'inappbrowser'" -->
<core-icon [name]="item.icon" slot="start"></core-icon>
<ion-label>
<h2>{{item.label}}</h2>
</ion-label>
</ion-item>
<ion-item *ngIf="item.type == 'embedded'" (click)="openItem(item)" title="{{item.label}}">
<core-icon [name]="item.icon" slot="start"></core-icon>
<ion-label>
<h2>{{item.label}}</h2>
</ion-label>
</ion-item>
</div>
<ion-item *ngIf="showScanQR" (click)="scanQR()">
<core-icon name="fa-qrcode" slot="start" aria-hidden="true"></core-icon>
<ion-label>
<h2>{{ 'core.scanqr' | translate }}</h2>
</ion-label>
</ion-item>
<ion-item *ngIf="showWeb" [href]="siteInfo.siteurl" core-link autoLogin="yes" title="{{ 'core.mainmenu.website' | translate }}">
<ion-icon name="globe" slot="start" aria-hidden="true"></ion-icon>
<ion-label>
<h2>{{ 'core.mainmenu.website' | translate }}</h2>
</ion-label>
</ion-item>
<ion-item *ngIf="showHelp" [href]="docsUrl" core-link autoLogin="no" title="{{ 'core.mainmenu.help' | translate }}">
<ion-icon name="help-buoy" slot="start" aria-hidden="true"></ion-icon>
<ion-label>
<h2>{{ 'core.mainmenu.help' | translate }}</h2>
</ion-label>
</ion-item>
<ion-item (click)="openSitePreferences()" title="{{ 'core.settings.preferences' | translate }}">
<core-icon name="fa-wrench" slot="start"></core-icon>
<ion-label>
<h2>{{ 'core.settings.preferences' | translate }}</h2>
</ion-label>
</ion-item>
<ion-item (click)="logout()" title="{{ logoutLabel | translate }}">
<ion-icon name="log-out" slot="start" aria-hidden="true"></ion-icon>
<ion-label>
<h2>{{ logoutLabel | translate }}</h2>
</ion-label>
</ion-item>
<ion-item-divider></ion-item-divider>
<ion-item (click)="openAppSettings()" title="{{ 'core.settings.appsettings' | translate }}">
<core-icon name="fa-cogs" slot="start"></core-icon>
<ion-label>
<h2>{{ 'core.settings.appsettings' | translate }}</h2>
</ion-label>
</ion-item>
</ion-list>
</ion-content>

View File

@ -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<void> {
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<void> {
// Scan for a QR code.
// @todo
}
/**
* Logout the user.
*/
logout(): void {
CoreSites.instance.logout();
}
}

View File

@ -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;
}
}
}
}
*/

View File

@ -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<CoreMainMenuHandlerToDisplay[]> = new BehaviorSubject<CoreMainMenuHandlerToDisplay[]>([]);
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<CoreMainMenuHandlerToDisplay[]> {
return this.siteHandlers;
}
/**
* Update handlers Data.
*/
updateData(): void {
const displayData: CoreMainMenuHandlerToDisplay[] = [];
for (const name in this.enabledHandlers) {
const handler = <CoreMainMenuHandler> this.enabledHandlers[name];
const data = <CoreMainMenuHandlerToDisplay> 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);
}
}

View File

@ -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<CoreMainMenuHandlerToDisplay[]> {
const deferred = CoreUtils.instance.promiseDefer<CoreMainMenuHandlerToDisplay[]>();
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<CoreMainMenuCustomItem[]> {
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<boolean> {
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<string, {
url: string;
type: string;
position: number;
labels: {
[lang: string]: {
label: string;
icon: string;
};
};
}>;

View File

@ -1312,7 +1312,7 @@ export class CoreSitesProvider {
async logout(): Promise<void> {
await this.dbReady;
let siteId;
let siteId: string | undefined;
const promises: Promise<unknown>[] = [];
if (this.currentSite) {

View File

@ -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;
};

View File

@ -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",