MOBILE-3609 settings: Implement split view

main
Noel De Martin 2021-01-19 16:28:37 +01:00
parent 159613bdb5
commit 7f7f282e69
20 changed files with 671 additions and 163 deletions

View File

@ -13,17 +13,105 @@
// limitations under the License.
import { InjectionToken, Injector, ModuleWithProviders, NgModule } from '@angular/core';
import { PreloadAllModules, RouterModule, ROUTES, Routes } from '@angular/router';
import {
PreloadAllModules,
RouterModule,
Route,
Routes,
ROUTES,
UrlMatcher,
UrlMatchResult,
UrlSegment,
UrlSegmentGroup,
} from '@angular/router';
import { CoreArray } from '@singletons/array';
/**
* Build app routes.
*
* @param injector Module injector.
* @return App routes.
*/
function buildAppRoutes(injector: Injector): Routes {
return CoreArray.flatten(injector.get<Routes[]>(APP_ROUTES, []));
}
/**
* Create a url matcher that will only match when a given condition is met.
*
* @param condition Condition.
* @return Conditional url matcher.
*/
function buildConditionalUrlMatcher(condition: () => boolean): UrlMatcher {
// Create a matcher based on Angular's default matcher.
// see https://github.com/angular/angular/blob/10.0.x/packages/router/src/shared.ts#L127
return (segments: UrlSegment[], segmentGroup: UrlSegmentGroup, route: Route): UrlMatchResult | null => {
// If the condition isn't met, the route will never match.
if (!condition()) {
return null;
}
const { path, pathMatch } = route as { path: string; pathMatch?: 'full' };
const posParams: Record<string, UrlSegment> = {};
const isFullMatch = pathMatch === 'full';
const parts = path.split('/');
// The actual URL is shorter than the config, no match.
if (parts.length > segments.length) {
return null;
}
// The config is longer than the actual URL but we are looking for a full match, return null.
if (isFullMatch && (segmentGroup.hasChildren() || parts.length < segments.length)) {
return null;
}
// Check each config part against the actual URL.
for (let index = 0; index < parts.length; index++) {
const part = parts[index];
const segment = segments[index];
const isParameter = part.startsWith(':');
if (isParameter) {
posParams[part.substring(1)] = segment;
} else if (part !== segment.path) {
// The actual URL part does not match the config, no match.
return null;
}
}
// Return consumed segments with params.
return { consumed: segments.slice(0, parts.length), posParams };
};
}
export type ModuleRoutes = { children: Routes; siblings: Routes };
export type ModuleRoutesConfig = Routes | Partial<ModuleRoutes>;
/**
* Configure routes so that they'll only match when a given condition is met.
*
* @param routes Routes.
* @param condition Condition to determine if routes should be activated or not.
* @return Conditional routes.
*/
export function conditionalRoutes(routes: Routes, condition: () => boolean): Routes {
const conditionalMatcher = buildConditionalUrlMatcher(condition);
return routes.map(route => ({
...route,
matcher: conditionalMatcher,
}));
}
/**
* Resolve module routes.
*
* @param injector Module injector.
* @param token Routes injection token.
* @return Routes.
*/
export function resolveModuleRoutes(injector: Injector, token: InjectionToken<ModuleRoutesConfig[]>): ModuleRoutes {
const configs = injector.get(token, []);
const routes = configs.map(config => {

View File

@ -28,6 +28,7 @@ import { CoreMarkRequiredComponent } from './mark-required/mark-required';
import { CoreRecaptchaComponent } from './recaptcha/recaptcha';
import { CoreRecaptchaModalComponent } from './recaptcha/recaptcha-modal';
import { CoreShowPasswordComponent } from './show-password/show-password';
import { CoreSplitViewComponent } from './split-view/split-view';
import { CoreEmptyBoxComponent } from './empty-box/empty-box';
import { CoreTabsComponent } from './tabs/tabs';
import { CoreInfiniteLoadingComponent } from './infinite-loading/infinite-loading';
@ -55,6 +56,7 @@ import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons';
CoreRecaptchaComponent,
CoreRecaptchaModalComponent,
CoreShowPasswordComponent,
CoreSplitViewComponent,
CoreEmptyBoxComponent,
CoreTabsComponent,
CoreInfiniteLoadingComponent,
@ -85,6 +87,7 @@ import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons';
CoreRecaptchaComponent,
CoreRecaptchaModalComponent,
CoreShowPasswordComponent,
CoreSplitViewComponent,
CoreEmptyBoxComponent,
CoreTabsComponent,
CoreInfiniteLoadingComponent,

View File

@ -0,0 +1,5 @@
<ion-content>
<ng-content></ng-content>
</ion-content>
<ion-router-outlet></ion-router-outlet>
<!-- @todo placeholder -->

View File

@ -0,0 +1,86 @@
@import "~theme/breakpoints";
// @todo RTL layout
:host {
--side-width: 100%;
--side-min-width: 270px;
--side-max-width: 28%;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
position: absolute;
inset: 0;
flex-direction: row;
flex-wrap: nowrap;
contain: strict;
}
:host-context(ion-app.md) {
--border: 1px solid var(--ion-item-border-color, var(--ion-border-color, var(--ion-color-step-150, rgba(0, 0, 0, .13))));
}
:host-context(ion-app.ios) {
--border: .55px solid var(--ion-item-border-color, var(--ion-border-color, var(--ion-color-step-250, #c8c7cc)));
}
ion-content,
ion-router-outlet {
top: 0;
right: 0;
bottom: 0;
left: 0;
position: relative;
box-shadow: none !important;
z-index: 0;
}
ion-content {
display: flex;
flex-shrink: 0;
order: -1;
border-left: unset;
border-right: unset;
border-inline-start: 0;
border-inline-end: 0;
width: 100%;
}
ion-router-outlet {
flex: 1;
display: none;
::ng-deep ion-header {
display: none;
}
}
:host(.outlet-activated) {
ion-router-outlet {
display: block;
}
ion-content {
display: none;
}
}
@media (min-width: $breakpoint-tablet) {
ion-content {
border-inline-end: var(--border);
min-width: var(--side-min-width);
max-width: var(--side-max-width);
}
:host(.outlet-activated) ion-content {
display: flex;
}
}

View File

@ -0,0 +1,49 @@
// (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 { AfterViewInit, Component, HostBinding, OnDestroy, ViewChild } from '@angular/core';
import { IonRouterOutlet } from '@ionic/angular';
import { Subscription } from 'rxjs';
@Component({
selector: 'core-split-view',
templateUrl: 'split-view.html',
styleUrls: ['split-view.scss'],
})
export class CoreSplitViewComponent implements AfterViewInit, OnDestroy {
@ViewChild(IonRouterOutlet) outlet!: IonRouterOutlet;
@HostBinding('class.outlet-activated') outletActivated = false;
private subscriptions?: Subscription[];
/**
* @inheritdoc
*/
ngAfterViewInit(): void {
this.outletActivated = this.outlet.isActivated;
this.subscriptions = [
this.outlet.activateEvents.subscribe(() => this.outletActivated = true),
this.outlet.deactivateEvents.subscribe(() => this.outletActivated = false),
];
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.subscriptions?.forEach(subscription => subscription.unsubscribe());
}
}

View File

@ -0,0 +1,53 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* Settings section.
*/
export type CoreSettingsSection = {
name: string;
path: string;
icon: string;
};
/**
* Settings constants.
*/
export class CoreSettingsConstants {
static readonly SECTIONS: CoreSettingsSection[] = [
{
name: 'general',
path: 'general',
icon: 'fas-wrench',
},
{
name: 'spaceusage',
path: 'spaceusage',
icon: 'fas-tasks',
},
{
name: 'synchronization',
path: 'sync',
icon: 'fas-sync-alt',
},
// @TODO sharedfiles
{
name: 'about',
path: 'about',
icon: 'fas-id-card',
},
];
}

View File

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

View File

@ -1,44 +0,0 @@
// (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 { CoreSharedModule } from '@/core/shared.module';
import { CoreSettingsAppPage } from './app';
const routes: Routes = [
{
path: '',
component: CoreSettingsAppPage,
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
CommonModule,
IonicModule,
TranslateModule.forChild(),
CoreSharedModule,
],
declarations: [
CoreSettingsAppPage,
],
exports: [RouterModule],
})
export class CoreSettingsAppPageModule {}

View File

@ -1,68 +0,0 @@
// (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 { CoreApp } from '@services/app';
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Params, Router } from '@angular/router';
/**
* App settings menu page.
*/
@Component({
selector: 'page-core-app-settings',
templateUrl: 'app.html',
})
export class CoreSettingsAppPage implements OnInit {
// @ViewChild(CoreSplitViewComponent) splitviewCtrl?: CoreSplitViewComponent;
isIOS: boolean;
selectedPage?: string;
constructor(
protected route: ActivatedRoute,
protected router: Router, // Will be removed when splitview is implemented
) {
this.isIOS = CoreApp.instance.isIOS();
this.selectedPage = route.snapshot.paramMap.get('page') || undefined;
if (this.selectedPage) {
this.openSettings(this.selectedPage);
}
}
/**
* View loaded.
*/
ngOnInit(): void {
if (this.selectedPage) {
this.openSettings(this.selectedPage);
} /* else if (this.splitviewCtrl!.isOn()) {
this.openSettings('general');
}*/
}
/**
* Open a settings page.
*
* @param page Page to open.
* @param params Params of the page to open.
*/
openSettings(page: string, params?: Params): void {
this.selectedPage = page;
// this.splitviewCtrl!.push(page, params);
this.router.navigate([page], { relativeTo: this.route, queryParams: params });
}
}

View File

@ -0,0 +1,25 @@
<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.settings.appsettings' | translate }}</ion-title>
<ion-buttons slot="end"></ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<core-split-view>
<ion-list>
<ion-item
*ngFor="let section of sections"
[class.core-selected-item]="section.name === activeSection"
button
detail
(click)="openSection(section)"
>
<ion-icon [name]="section.icon" slot="start"></ion-icon>
<ion-label>{{ 'core.settings.' + section.name | translate }}</ion-label>
</ion-item>
</ion-list>
</core-split-view>
</ion-content>

View File

@ -0,0 +1,94 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Subscription } from 'rxjs';
import { CoreNavigator } from '@services/navigator';
import { CoreScreen } from '@services/screen';
import { CoreSettingsConstants, CoreSettingsSection } from '@features/settings/constants';
@Component({
selector: 'page-core-settings-index',
templateUrl: 'index.html',
})
export class CoreSettingsIndexPage implements OnInit, OnDestroy {
sections = CoreSettingsConstants.SECTIONS;
activeSection?: string;
layoutSubscription?: Subscription;
/**
* @inheritdoc
*/
ngOnInit(): void {
this.layoutSubscription = CoreScreen.instance.layoutObservable.subscribe(() => this.updateActiveSection());
}
/**
* @inheritdoc
*/
ionViewWillEnter(): void {
this.updateActiveSection();
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.layoutSubscription?.unsubscribe();
}
/**
* Open a section page.
*
* @param section Section to open.
*/
openSection(section: CoreSettingsSection): void {
const path = this.activeSection ? `../${section.path}` : section.path;
CoreNavigator.instance.navigate(path);
this.updateActiveSection(section.name);
}
/**
* Update active section.
*
* @param activeSection Active section.
*/
private updateActiveSection(activeSection?: string): void {
if (CoreScreen.instance.isMobile) {
delete this.activeSection;
return;
}
this.activeSection = activeSection ?? this.guessActiveSection();
}
/**
* Guess active section looking at the current route.
*
* @return Active section.
*/
private guessActiveSection(): string | undefined {
const activeSection = this.sections.find(
section => CoreNavigator.instance.isCurrent(`**/settings/${section.path}`),
);
return activeSection?.name;
}
}

View File

@ -35,8 +35,8 @@
<h2>
<core-format-text [text]="site.siteName" clean="true" [siteId]="site.id"></core-format-text>
</h2>
<p>{{ site.fullName }}</p>
<p>{{ site.siteUrl }}</p>
<p>{{ site.fullName }}</p>
<p>{{ site.siteUrl }}</p>
</ion-label>
<ion-button fill="clear" slot="end" *ngIf="!isSynchronizing(site.id)" (click)="synchronize(site.id)"
[title]="site.siteName" [attr.aria-label]="'core.settings.synchronizenow' | translate">

View File

@ -12,14 +12,19 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { CommonModule } from '@angular/common';
import { IonicModule } from '@ionic/angular';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
const routes: Routes = [
{
path: 'about',
loadChildren: () => import('./pages/about/about.module').then(m => m.CoreSettingsAboutPageModule),
},
import { conditionalRoutes } from '@/app/app-routing.module';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreScreen } from '@services/screen';
import { CoreSettingsIndexPage } from './pages/index';
const sectionRoutes: Routes = [
{
path: 'general',
loadChildren: () => import('./pages/general/general.module').then(m => m.CoreSettingsGeneralPageModule),
@ -34,13 +39,43 @@ const routes: Routes = [
import('./pages/synchronization/synchronization.module')
.then(m => m.CoreSettingsSynchronizationPageModule),
},
// @todo sharedfiles
{
path: '',
loadChildren: () => import('./pages/app/app.module').then(m => m.CoreSettingsAppPageModule),
path: 'about',
loadChildren: () => import('./pages/about/about.module').then(m => m.CoreSettingsAboutPageModule),
},
];
const routes: Routes = [
{
matcher: segments => {
const matches = CoreScreen.instance.isMobile ? segments.length === 0 : true;
return matches ? { consumed: [] } : null;
},
component: CoreSettingsIndexPage,
children: conditionalRoutes([
{
path: '',
pathMatch: 'full',
redirectTo: 'general',
},
...sectionRoutes,
], () => !CoreScreen.instance.isMobile),
},
...conditionalRoutes(sectionRoutes, () => CoreScreen.instance.isMobile),
];
@NgModule({
imports: [RouterModule.forChild(routes)],
imports: [
RouterModule.forChild(routes),
CommonModule,
IonicModule,
TranslateModule,
CoreSharedModule,
],
declarations: [
CoreSettingsIndexPage,
],
})
export class CoreSettingsLazyModule {}

View File

@ -0,0 +1,19 @@
// (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 { CoreScreen } from '@services/screen';
export default function(): void {
CoreScreen.instance.watchViewport();
}

View File

@ -25,6 +25,7 @@ import { CoreObject } from '@singletons/object';
import { CoreSites } from '@services/sites';
import { CoreUtils } from '@services/utils/utils';
import { CoreUrlUtils } from '@services/utils/url';
import { CoreTextUtils } from '@services/utils/text';
import { makeSingleton, NavController, Router } from '@singletons';
const DEFAULT_MAIN_MENU_TAB = CoreMainMenuHomeHandlerService.PAGE_NAME;
@ -55,11 +56,11 @@ export class CoreNavigatorService {
/**
* Check whether the active route is using the given path.
*
* @param path Path.
* @param path Path, can be a glob pattern.
* @return Whether the active route is using the given path.
*/
isCurrent(path: string): boolean {
return this.getCurrentPath() === path;
return CoreTextUtils.instance.matchesGlob(this.getCurrentPath(), path);
}
/**
@ -195,7 +196,7 @@ export class CoreNavigatorService {
protected getCurrentRoute(route?: ActivatedRoute): ActivatedRoute {
route = route ?? Router.instance.routerState.root;
return route.children.length === 0 ? route : this.getCurrentRoute(route.children[0]);
return route.firstChild ? this.getCurrentRoute(route.firstChild) : route;
}
/**

View File

@ -0,0 +1,143 @@
// (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 { BehaviorSubject, Observable } from 'rxjs';
import { distinctUntilChanged, map } from 'rxjs/operators';
import { makeSingleton } from '@singletons';
/**
* Screen breakpoints.
*
* @see https://ionicframework.com/docs/layout/grid#default-breakpoints
*/
enum Breakpoint {
ExtraSmall = 'xs',
Small = 'sm',
Medium = 'md',
Large = 'lg',
ExtraLarge = 'xl',
}
const BREAKPOINT_NAMES = Object.values(Breakpoint);
const BREAKPOINT_WIDTHS: Record<Breakpoint, number> = {
[Breakpoint.ExtraSmall]: 0,
[Breakpoint.Small]: 576,
[Breakpoint.Medium]: 768,
[Breakpoint.Large]: 992,
[Breakpoint.ExtraLarge]: 1200,
};
/**
* Screen layouts.
*/
export enum CoreScreenLayout {
Mobile = 'mobile',
Tablet = 'tablet',
}
/**
* Manage application screen.
*/
@Injectable({ providedIn: 'root' })
export class CoreScreenService {
protected breakpointsSubject: BehaviorSubject<Record<Breakpoint, boolean>>;
private _layoutObservable: Observable<CoreScreenLayout>;
constructor() {
this.breakpointsSubject = new BehaviorSubject(BREAKPOINT_NAMES.reduce((breakpoints, breakpoint) => ({
...breakpoints,
[breakpoint]: false,
}), {} as Record<Breakpoint, boolean>));
this._layoutObservable = this.breakpointsObservable.pipe(
map(this.calculateLayout.bind(this)),
distinctUntilChanged<CoreScreenLayout>(),
);
}
get breakpoints(): Record<Breakpoint, boolean> {
return this.breakpointsSubject.value;
}
get breakpointsObservable(): Observable<Record<Breakpoint, boolean>> {
return this.breakpointsSubject.asObservable();
}
get layout(): CoreScreenLayout {
return this.calculateLayout(this.breakpointsSubject.value);
}
get layoutObservable(): Observable<CoreScreenLayout> {
return this._layoutObservable;
}
get isMobile(): boolean {
return this.layout === CoreScreenLayout.Mobile;
}
get isTablet(): boolean {
return this.layout === CoreScreenLayout.Tablet;
}
/**
* Watch viewport changes.
*/
watchViewport(): void {
for (const breakpoint of BREAKPOINT_NAMES) {
const width = BREAKPOINT_WIDTHS[breakpoint];
const mediaQuery = window.matchMedia(`(min-width: ${width}px)`);
this.updateBreakpointVisibility(breakpoint, mediaQuery.matches);
mediaQuery.onchange = (({ matches }) => this.updateBreakpointVisibility(breakpoint, matches));
}
}
/**
* Update breakpoint visibility.
*
* @param breakpoint Breakpoint.
* @param visible Visible.
*/
protected updateBreakpointVisibility(breakpoint: Breakpoint, visible: boolean): void {
if (this.breakpoints[breakpoint] === visible) {
return;
}
this.breakpointsSubject.next({
...this.breakpoints,
[breakpoint]: visible,
});
}
/**
* Calculate the layout given the current breakpoints.
*
* @param breakpoints Breakpoints visibility.
* @return Active layout.
*/
protected calculateLayout(breakpoints: Record<Breakpoint, boolean>): CoreScreenLayout {
if (breakpoints[Breakpoint.Large]) {
return CoreScreenLayout.Tablet;
}
return CoreScreenLayout.Mobile;
}
}
export class CoreScreen extends makeSingleton(CoreScreenService) {}

View File

@ -19,6 +19,7 @@ import { mock, mockSingleton } from '@/testing/utils';
import { CoreNavigatorService } from '@services/navigator';
import { CoreUtils, CoreUtilsProvider } from '@services/utils/utils';
import { CoreUrlUtils, CoreUrlUtilsProvider } from '@services/utils/url';
import { CoreTextUtils, CoreTextUtilsProvider } from '@services/utils/text';
import { NavController, Router } from '@singletons';
import { ActivatedRoute, RouterState } from '@angular/router';
import { CoreSites } from '@services/sites';
@ -43,6 +44,7 @@ describe('CoreNavigator', () => {
mockSingleton(Router, router);
mockSingleton(CoreUtils, new CoreUtilsProvider(mock()));
mockSingleton(CoreUrlUtils, new CoreUrlUtilsProvider());
mockSingleton(CoreTextUtils, new CoreTextUtilsProvider(mock()));
mockSingleton(CoreSites, { getCurrentSiteId: () => 42, isLoggedIn: () => true });
mockSingleton(CoreMainMenu, { isMainMenuTab: path => Promise.resolve(currentMainMenuHandlers.includes(path)) });
});
@ -73,9 +75,9 @@ describe('CoreNavigator', () => {
it('navigates to relative paths', async () => {
// Arrange.
const mainOutletRoute = { routeConfig: { path: 'foo' }, children: [] };
const primaryOutletRoute = { routeConfig: { path: 'main' }, children: [mainOutletRoute] };
const rootRoute = { children: [primaryOutletRoute] };
const mainOutletRoute = { routeConfig: { path: 'foo' } };
const primaryOutletRoute = { routeConfig: { path: 'main' }, firstChild: mainOutletRoute };
const rootRoute = { firstChild: primaryOutletRoute };
router.routerState = { root: rootRoute as unknown as ActivatedRoute };

View File

@ -78,4 +78,19 @@ describe('CoreTextUtilsProvider', () => {
expect(CoreApp.instance.isAndroid).toHaveBeenCalled();
});
it('matches glob patterns', () => {
expect(textUtils.matchesGlob('/foo/bar', '/foo/bar')).toBe(true);
expect(textUtils.matchesGlob('/foo/bar', '/foo/bar/')).toBe(false);
expect(textUtils.matchesGlob('/foo', '/foo/*')).toBe(false);
expect(textUtils.matchesGlob('/foo/', '/foo/*')).toBe(true);
expect(textUtils.matchesGlob('/foo/bar', '/foo/*')).toBe(true);
expect(textUtils.matchesGlob('/foo/bar/', '/foo/*')).toBe(false);
expect(textUtils.matchesGlob('/foo/bar/baz', '/foo/*')).toBe(false);
expect(textUtils.matchesGlob('/foo/bar/baz', '/foo/**')).toBe(true);
expect(textUtils.matchesGlob('/foo/bar/baz/', '/foo/**')).toBe(true);
expect(textUtils.matchesGlob('/foo/bar/baz', '**/baz')).toBe(true);
expect(textUtils.matchesGlob('/foo/bar/baz', '**/bar')).toBe(false);
expect(textUtils.matchesGlob('/foo/bar/baz', '/foo/ba?/ba?')).toBe(true);
});
});

View File

@ -644,6 +644,29 @@ export class CoreTextUtilsProvider {
return false;
}
/**
* Check whether the given text matches a glob pattern.
*
* @param text Text to match against.
* @param pattern Glob pattern.
* @return Whether the pattern matches.
*/
matchesGlob(text: string, pattern: string): boolean {
pattern = pattern
.replace(/\*\*/g, '%RECURSIVE_MATCH%')
.replace(/\*/g, '%LOCAL_MATCH%')
.replace(/\?/g, '%CHARACTER_MATCH%');
pattern = this.escapeForRegex(pattern);
pattern = pattern
.replace(/%RECURSIVE_MATCH%/g, '.*')
.replace(/%LOCAL_MATCH%/g, '[^/]*')
.replace(/%CHARACTER_MATCH%/g, '[^/]');
return new RegExp(`^${pattern}$`).test(text);
}
/**
* Same as Javascript's JSON.parse, but it will handle errors.
*

View File

@ -0,0 +1,13 @@
/*
* Layout Breakpoints
*
* https://ionicframework.com/docs/layout/grid#default-breakpoints
*/
$breakpoint-xs: 0px;
$breakpoint-sm: 576px;
$breakpoint-md: 768px;
$breakpoint-lg: 992px;
$breakpoint-xl: 1200px;
$breakpoint-tablet: $breakpoint-lg;