diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 256c558f4..957f6e580 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -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(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 = {}; + 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; +/** + * 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): ModuleRoutes { const configs = injector.get(token, []); const routes = configs.map(config => { diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index 416cd3999..7a8169648 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -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, diff --git a/src/core/components/split-view/split-view.html b/src/core/components/split-view/split-view.html new file mode 100644 index 000000000..ee5f74817 --- /dev/null +++ b/src/core/components/split-view/split-view.html @@ -0,0 +1,5 @@ + + + + + diff --git a/src/core/components/split-view/split-view.scss b/src/core/components/split-view/split-view.scss new file mode 100644 index 000000000..13cf43e23 --- /dev/null +++ b/src/core/components/split-view/split-view.scss @@ -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; + } + +} diff --git a/src/core/components/split-view/split-view.ts b/src/core/components/split-view/split-view.ts new file mode 100644 index 000000000..6f2488445 --- /dev/null +++ b/src/core/components/split-view/split-view.ts @@ -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()); + } + +} diff --git a/src/core/features/settings/constants.ts b/src/core/features/settings/constants.ts new file mode 100644 index 000000000..ce3ba0763 --- /dev/null +++ b/src/core/features/settings/constants.ts @@ -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', + }, + ]; + +} diff --git a/src/core/features/settings/pages/app/app.html b/src/core/features/settings/pages/app/app.html deleted file mode 100644 index f97a0250f..000000000 --- a/src/core/features/settings/pages/app/app.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - {{ 'core.settings.appsettings' | translate}} - - - - - - - {{ 'core.settings.general' | translate }} - - - - {{ 'core.settings.spaceusage' | translate }} - - - - {{ 'core.settings.synchronization' | translate }} - - - - {{ 'core.sharedfiles.sharedfiles' | translate }} - - - - {{ 'core.settings.about' | translate }} - - diff --git a/src/core/features/settings/pages/app/app.module.ts b/src/core/features/settings/pages/app/app.module.ts deleted file mode 100644 index 90a8ef602..000000000 --- a/src/core/features/settings/pages/app/app.module.ts +++ /dev/null @@ -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 {} diff --git a/src/core/features/settings/pages/app/app.ts b/src/core/features/settings/pages/app/app.ts deleted file mode 100644 index 37b34ef63..000000000 --- a/src/core/features/settings/pages/app/app.ts +++ /dev/null @@ -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 }); - } - -} diff --git a/src/core/features/settings/pages/index/index.html b/src/core/features/settings/pages/index/index.html new file mode 100644 index 000000000..dc2d95829 --- /dev/null +++ b/src/core/features/settings/pages/index/index.html @@ -0,0 +1,25 @@ + + + + + + {{ 'core.settings.appsettings' | translate }} + + + + + + + + + {{ 'core.settings.' + section.name | translate }} + + + + diff --git a/src/core/features/settings/pages/index/index.ts b/src/core/features/settings/pages/index/index.ts new file mode 100644 index 000000000..0d40b0c11 --- /dev/null +++ b/src/core/features/settings/pages/index/index.ts @@ -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; + } + +} diff --git a/src/core/features/settings/pages/synchronization/synchronization.html b/src/core/features/settings/pages/synchronization/synchronization.html index 3ac2cd268..948d9a874 100644 --- a/src/core/features/settings/pages/synchronization/synchronization.html +++ b/src/core/features/settings/pages/synchronization/synchronization.html @@ -35,8 +35,8 @@

-

{{ site.fullName }}

-

{{ site.siteUrl }}

+

{{ site.fullName }}

+

{{ site.siteUrl }}

diff --git a/src/core/features/settings/settings-lazy.module.ts b/src/core/features/settings/settings-lazy.module.ts index 5177d0c9f..990d4751c 100644 --- a/src/core/features/settings/settings-lazy.module.ts +++ b/src/core/features/settings/settings-lazy.module.ts @@ -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 {} diff --git a/src/core/initializers/watch-screen-viewport.ts b/src/core/initializers/watch-screen-viewport.ts new file mode 100644 index 000000000..e11d2d78b --- /dev/null +++ b/src/core/initializers/watch-screen-viewport.ts @@ -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(); +} diff --git a/src/core/services/navigator.ts b/src/core/services/navigator.ts index a74d4f9e1..af34f481d 100644 --- a/src/core/services/navigator.ts +++ b/src/core/services/navigator.ts @@ -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; } /** diff --git a/src/core/services/screen.ts b/src/core/services/screen.ts new file mode 100644 index 000000000..9ee50ff9a --- /dev/null +++ b/src/core/services/screen.ts @@ -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.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>; + private _layoutObservable: Observable; + + constructor() { + this.breakpointsSubject = new BehaviorSubject(BREAKPOINT_NAMES.reduce((breakpoints, breakpoint) => ({ + ...breakpoints, + [breakpoint]: false, + }), {} as Record)); + + this._layoutObservable = this.breakpointsObservable.pipe( + map(this.calculateLayout.bind(this)), + distinctUntilChanged(), + ); + } + + get breakpoints(): Record { + return this.breakpointsSubject.value; + } + + get breakpointsObservable(): Observable> { + return this.breakpointsSubject.asObservable(); + } + + get layout(): CoreScreenLayout { + return this.calculateLayout(this.breakpointsSubject.value); + } + + get layoutObservable(): Observable { + 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): CoreScreenLayout { + if (breakpoints[Breakpoint.Large]) { + return CoreScreenLayout.Tablet; + } + + return CoreScreenLayout.Mobile; + } + +} + +export class CoreScreen extends makeSingleton(CoreScreenService) {} diff --git a/src/core/services/tests/navigator.test.ts b/src/core/services/tests/navigator.test.ts index 6a68f7055..cc55954f9 100644 --- a/src/core/services/tests/navigator.test.ts +++ b/src/core/services/tests/navigator.test.ts @@ -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 }; diff --git a/src/core/services/tests/utils/text.test.ts b/src/core/services/tests/utils/text.test.ts index 2b82aa7a8..5d4ab6da3 100644 --- a/src/core/services/tests/utils/text.test.ts +++ b/src/core/services/tests/utils/text.test.ts @@ -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); + }); + }); diff --git a/src/core/services/utils/text.ts b/src/core/services/utils/text.ts index 919a313bb..247749821 100644 --- a/src/core/services/utils/text.ts +++ b/src/core/services/utils/text.ts @@ -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. * diff --git a/src/theme/breakpoints.scss b/src/theme/breakpoints.scss new file mode 100644 index 000000000..8cc9f2207 --- /dev/null +++ b/src/theme/breakpoints.scss @@ -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;