diff --git a/package-lock.json b/package-lock.json index 1310a5e2e..14a098f20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -88,6 +88,7 @@ "cordova.plugins.diagnostic": "^5.0.2", "core-js": "^3.9.1", "es6-promise-plugin": "^4.2.2", + "hammerjs": "^2.0.8", "jszip": "^3.5.0", "mathjax": "2.7.7", "moment": "^2.29.0", @@ -15988,6 +15989,14 @@ "node": ">= 0.10" } }, + "node_modules/hammerjs": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", + "integrity": "sha1-BO93hiz/K7edMPdpIJWTAiK/YPE=", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -43687,6 +43696,11 @@ "glogg": "^1.0.0" } }, + "hammerjs": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", + "integrity": "sha1-BO93hiz/K7edMPdpIJWTAiK/YPE=" + }, "handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", diff --git a/package.json b/package.json index bff141856..5c790b408 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "cordova.plugins.diagnostic": "^5.0.2", "core-js": "^3.9.1", "es6-promise-plugin": "^4.2.2", + "hammerjs": "^2.0.8", "jszip": "^3.5.0", "mathjax": "2.7.7", "moment": "^2.29.0", @@ -245,4 +246,4 @@ "optionalDependencies": { "keytar": "^7.2.0" } -} \ No newline at end of file +} diff --git a/src/core/classes/hammer-gesture-config.ts b/src/core/classes/hammer-gesture-config.ts new file mode 100644 index 000000000..de832aa37 --- /dev/null +++ b/src/core/classes/hammer-gesture-config.ts @@ -0,0 +1,27 @@ +// (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 { HammerGestureConfig } from '@angular/platform-browser'; +import { DIRECTION_ALL } from 'hammerjs'; + +/** + * Application HammerJS config. + */ +export class CoreHammerGestureConfig extends HammerGestureConfig { + + overrides = { + swipe: { direction: DIRECTION_ALL }, + }; + +} diff --git a/src/core/classes/items-management/items-manager-source.ts b/src/core/classes/items-management/items-manager-source.ts new file mode 100644 index 000000000..141cc6f4c --- /dev/null +++ b/src/core/classes/items-management/items-manager-source.ts @@ -0,0 +1,169 @@ +// (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. + +/** + * Updates listener. + */ +export interface CoreItemsListSourceListener { + onItemsUpdated(items: Item[], hasMoreItems: boolean): void; + onReset(): void; +} + +/** + * Items collection source data. + */ +export abstract class CoreItemsManagerSource { + + /** + * Get a string to identify instances constructed with the given arguments as being reusable. + * + * @param args Constructor arguments. + * @returns Id. + */ + static getSourceId(...args: unknown[]): string { + return args.map(argument => String(argument)).join('-'); + } + + private items: Item[] | null = null; + private hasMoreItems = true; + private listeners: CoreItemsListSourceListener[] = []; + + /** + * Check whether any page has been loaded. + * + * @returns Whether any page has been loaded. + */ + isLoaded(): boolean { + return this.items !== null; + } + + /** + * Check whether there are more pages to be loaded. + * + * @return Whether there are more pages to be loaded. + */ + isCompleted(): boolean { + return !this.hasMoreItems; + } + + /** + * Get collection items. + * + * @returns Items. + */ + getItems(): Item[] | null { + return this.items; + } + + /** + * Get the count of pages that have been loaded. + * + * @returns Pages loaded. + */ + getPagesLoaded(): number { + if (this.items === null) { + return 0; + } + + return Math.ceil(this.items.length / this.getPageLength()); + } + + /** + * Reset collection data. + */ + reset(): void { + this.items = null; + this.hasMoreItems = true; + + this.listeners.forEach(listener => listener.onReset()); + } + + /** + * Register a listener. + * + * @param listener Listener. + * @returns Unsubscribe function. + */ + addListener(listener: CoreItemsListSourceListener): () => void { + this.listeners.push(listener); + + return () => this.removeListener(listener); + } + + /** + * Remove a listener. + * + * @param listener Listener. + */ + removeListener(listener: CoreItemsListSourceListener): void { + const index = this.listeners.indexOf(listener); + + if (index === -1) { + return; + } + + this.listeners.splice(index, 1); + } + + /** + * Reload the collection, this resets the data to the first page. + */ + async reload(): Promise { + const { items, hasMoreItems } = await this.loadPageItems(0); + + this.setItems(items, hasMoreItems); + } + + /** + * Load items for the next page, if any. + */ + async loadNextPage(): Promise { + if (!this.hasMoreItems) { + return; + } + + const { items, hasMoreItems } = await this.loadPageItems(this.getPagesLoaded()); + + this.setItems((this.items ?? []).concat(items), hasMoreItems); + } + + /** + * Load page items. + * + * @param page Page number (starting at 0). + * @return Page items data. + */ + protected abstract loadPageItems(page: number): Promise<{ items: Item[]; hasMoreItems: boolean }>; + + /** + * Get the length of each page in the collection. + * + * @return Page length. + */ + protected abstract getPageLength(): number; + + /** + * Update the collection items. + * + * @param items Items. + * @param hasMoreItems Whether there are more pages to be loaded. + */ + protected setItems(items: Item[], hasMoreItems: boolean): void { + this.items = items; + this.hasMoreItems = hasMoreItems; + + this.listeners.forEach(listener => listener.onItemsUpdated(items, hasMoreItems)); + } + +} diff --git a/src/core/classes/items-management/items-manager-sources-tracker.ts b/src/core/classes/items-management/items-manager-sources-tracker.ts new file mode 100644 index 000000000..997f5c556 --- /dev/null +++ b/src/core/classes/items-management/items-manager-sources-tracker.ts @@ -0,0 +1,141 @@ +// (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 { CoreItemsManagerSource } from './items-manager-source'; + +type SourceConstructor = { + getSourceId(...args: unknown[]): string; + new (...args: unknown[]): T; +}; +type InstanceTracking = { instance: CoreItemsManagerSource; references: unknown[] }; +type Instances = Record; + +/** + * Tracks CoreItemsManagerSource instances to reuse between pages. + */ +export class CoreItemsManagerSourcesTracker { + + private static instances: WeakMap = new WeakMap(); + private static instanceIds: WeakMap = new WeakMap(); + + /** + * Create an instance of the given source or retrieve one if it's already in use. + * + * @param constructor Source constructor. + * @param constructorArguments Arguments to create a new instance, used to find out if an instance already exists. + * @returns Source. + */ + static getOrCreateSource( + constructor: SourceConstructor, + constructorArguments: ConstructorParameters>, + ): T { + const id = constructor.getSourceId(...constructorArguments); + const constructorInstances = this.getConstructorInstances(constructor); + + return constructorInstances[id]?.instance as T + ?? this.createInstance(id, constructor, constructorArguments); + } + + /** + * Track an object referencing a source. + * + * @param source Source. + * @param reference Object referncing this source. + */ + static addReference(source: CoreItemsManagerSource, reference: unknown): void { + const constructorInstances = this.getConstructorInstances(source.constructor as SourceConstructor); + const instanceId = this.instanceIds.get(source); + + if (!instanceId) { + return; + } + + if (!(instanceId in constructorInstances)) { + constructorInstances[instanceId] = { + instance: source, + references: [], + }; + } + + constructorInstances[instanceId].references.push(reference); + } + + /** + * Remove a reference to an existing source, freeing it from memory if it's not referenced elsewhere. + * + * @param source Source. + * @param reference Object that was referncing this source. + */ + static removeReference(source: CoreItemsManagerSource, reference: unknown): void { + const constructorInstances = this.instances.get(source.constructor as SourceConstructor); + const instanceId = this.instanceIds.get(source); + const index = constructorInstances?.[instanceId ?? '']?.references.indexOf(reference) ?? -1; + + if (!constructorInstances || !instanceId || index === -1) { + return; + } + + constructorInstances[instanceId].references.splice(index, 1); + + if (constructorInstances[instanceId].references.length === 0) { + delete constructorInstances[instanceId]; + } + } + + /** + * Get instances for a given constructor. + * + * @param constructor Source constructor. + * @returns Constructor instances. + */ + private static getConstructorInstances(constructor: SourceConstructor): Instances { + return this.instances.get(constructor) + ?? this.initialiseConstructorInstances(constructor); + } + + /** + * Initialise instances for a given constructor. + * + * @param constructor Source constructor. + * @returns Constructor instances. + */ + private static initialiseConstructorInstances(constructor: SourceConstructor): Instances { + const constructorInstances = {}; + + this.instances.set(constructor, constructorInstances); + + return constructorInstances; + } + + /** + * Create a new source instance. + * + * @param id Source id. + * @param constructor Source constructor. + * @param constructorArguments Source constructor arguments. + * @returns Source instance. + */ + private static createInstance( + id: string, + constructor: SourceConstructor, + constructorArguments: ConstructorParameters>, + ): T { + const instance = new constructor(...constructorArguments); + + this.instanceIds.set(instance, id); + + return instance; + } + +} diff --git a/src/core/classes/items-management/items-manager.ts b/src/core/classes/items-management/items-manager.ts new file mode 100644 index 000000000..8ec06d964 --- /dev/null +++ b/src/core/classes/items-management/items-manager.ts @@ -0,0 +1,192 @@ +// (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 { ActivatedRoute, ActivatedRouteSnapshot, Params } from '@angular/router'; +import { CoreNavigationOptions, CoreNavigator } from '@services/navigator'; + +import { CoreItemsManagerSource } from './items-manager-source'; +import { CoreItemsManagerSourcesTracker } from './items-manager-sources-tracker'; + +/** + * Helper to manage a collection of items in a page. + */ +export abstract class CoreItemsManager { + + protected source?: { instance: CoreItemsManagerSource; unsubscribe: () => void }; + protected itemsMap: Record | null = null; + protected selectedItem: Item | null = null; + + constructor(source: CoreItemsManagerSource) { + this.setSource(source); + } + + /** + * Get source. + * + * @returns Source. + */ + getSource(): CoreItemsManagerSource { + if (!this.source) { + throw new Error('Source is missing from items manager'); + } + + return this.source.instance; + } + + /** + * Set source. + * + * @param newSource New source. + */ + setSource(newSource: CoreItemsManagerSource | null): void { + if (this.source) { + CoreItemsManagerSourcesTracker.removeReference(this.source.instance, this); + + this.source.unsubscribe(); + delete this.source; + + this.onSourceReset(); + } + + if (newSource) { + CoreItemsManagerSourcesTracker.addReference(newSource, this); + + this.source = { + instance: newSource, + unsubscribe: newSource.addListener({ + onItemsUpdated: items => this.onSourceItemsUpdated(items), + onReset: () => this.onSourceReset(), + }), + }; + + const items = newSource.getItems(); + + if (items) { + this.onSourceItemsUpdated(items); + } + } + } + + /** + * Process page destroyed operations. + */ + destroy(): void { + this.setSource(null); + } + + /** + * Get page route. + * + * @returns Current page route, if any. + */ + protected abstract getCurrentPageRoute(): ActivatedRoute | null; + + /** + * Get the path to use when navigating to an item page. + * + * @param item Item. + * @return Path to use when navigating to the item page. + */ + protected abstract getItemPath(item: Item): string; + + /** + * Get the path of the selected item given the current route. + * + * @param route Page route. + * @return Path of the selected item in the given route. + */ + protected abstract getSelectedItemPath(route?: ActivatedRouteSnapshot | null): string | null; + + /** + * Get the query parameters to use when navigating to an item page. + * + * @param item Item. + * @return Query parameters to use when navigating to the item page. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected getItemQueryParams(item: Item): Params { + return {}; + } + + /** + * Update the selected item given the current route. + * + * @param route Current route. + */ + protected updateSelectedItem(route: ActivatedRouteSnapshot | null = null): void { + route = route ?? this.getCurrentPageRoute()?.snapshot ?? null; + + const selectedItemPath = this.getSelectedItemPath(route); + + this.selectedItem = selectedItemPath + ? this.itemsMap?.[selectedItemPath] ?? null + : null; + } + + /** + * Navigate to an item in the collection. + * + * @param item Item. + * @param options Navigation options. + */ + protected async navigateToItem( + item: Item, + options: Pick = {}, + ): Promise { + // Get current route in the page. + const route = this.getCurrentPageRoute(); + + if (route === null) { + return; + } + + // If this item is already selected, do nothing. + const itemPath = this.getItemPath(item); + const selectedItemPath = this.getSelectedItemPath(route.snapshot); + + if (selectedItemPath === itemPath) { + return; + } + + // Navigate to item. + const params = this.getItemQueryParams(item); + const pathPrefix = selectedItemPath ? selectedItemPath.split('/').fill('../').join('') : ''; + + await CoreNavigator.navigate(pathPrefix + itemPath, { params, ...options }); + } + + /** + * Called when source items have been updated. + * + * @param items New items. + */ + protected onSourceItemsUpdated(items: Item[]): void { + this.itemsMap = items.reduce((map, item) => { + map[this.getItemPath(item)] = item; + + return map; + }, {}); + + this.updateSelectedItem(); + } + + /** + * Called when source has been updated. + */ + protected onSourceReset(): void { + this.itemsMap = null; + this.selectedItem = null; + } + +} diff --git a/src/core/classes/items-management/list-items-manager.ts b/src/core/classes/items-management/list-items-manager.ts new file mode 100644 index 000000000..ff1b87ec8 --- /dev/null +++ b/src/core/classes/items-management/list-items-manager.ts @@ -0,0 +1,237 @@ +// (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 { ActivatedRoute, ActivatedRouteSnapshot, UrlSegment } from '@angular/router'; +import { Subscription } from 'rxjs'; + +import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreNavigator } from '@services/navigator'; +import { CoreScreen } from '@services/screen'; +import { CoreUtils } from '@services/utils/utils'; + +import { CoreItemsManager } from './items-manager'; +import { CoreItemsManagerSource } from './items-manager-source'; + +/** + * Helper class to manage the state and routing of a list of items in a page. + */ +export abstract class CoreListItemsManager extends CoreItemsManager { + + protected pageRouteLocator?: unknown | ActivatedRoute; + protected splitView?: CoreSplitViewComponent; + protected splitViewOutletSubscription?: Subscription; + + constructor(source: CoreItemsManagerSource, pageRouteLocator: unknown | ActivatedRoute) { + super(source); + + this.pageRouteLocator = pageRouteLocator; + } + + get items(): Item[] { + return this.getSource().getItems() || []; + } + + get loaded(): boolean { + return this.itemsMap !== null; + } + + get completed(): boolean { + return this.getSource().isCompleted(); + } + + get empty(): boolean { + const items = this.getSource().getItems(); + + return items === null || items.length === 0; + } + + /** + * Process page started operations. + * + * @param splitView Split view component. + */ + async start(splitView: CoreSplitViewComponent): Promise { + this.watchSplitViewOutlet(splitView); + + // Calculate current selected item. + this.updateSelectedItem(); + + // Select default item if none is selected on a non-mobile layout. + if (!CoreScreen.isMobile && this.selectedItem === null && !splitView.isNested) { + const defaultItem = this.getDefaultItem(); + + if (defaultItem) { + this.select(defaultItem); + } + } + + // Log activity. + await CoreUtils.ignoreErrors(this.logActivity()); + } + + /** + * Process page destroyed operations. + */ + destroy(): void { + super.destroy(); + this.splitViewOutletSubscription?.unsubscribe(); + } + + /** + * Watch a split view outlet to keep track of the selected item. + * + * @param splitView Split view component. + */ + watchSplitViewOutlet(splitView: CoreSplitViewComponent): void { + this.splitView = splitView; + this.splitViewOutletSubscription = splitView.outletRouteObservable.subscribe( + route => this.updateSelectedItem(this.getPageRouteFromSplitViewOutlet(route)), + ); + + this.updateSelectedItem(this.getPageRouteFromSplitViewOutlet(splitView.outletRoute) ?? null); + } + + /** + * Check whether the given item is selected or not. + * + * @param item Item. + * @return Whether the given item is selected. + */ + isSelected(item: Item): boolean { + return this.selectedItem === item; + } + + /** + * Return the current aria value. + * + * @param item Item. + * @return Will return the current value of the item if selected, false otherwise. + */ + getItemAriaCurrent(item: Item): string { + return this.isSelected(item) ? 'page' : 'false'; + } + + /** + * Select an item. + * + * @param item Item. + */ + async select(item: Item): Promise { + await this.navigateToItem(item, { reset: this.resetNavigation() }); + } + + /** + * Reset the list of items. + */ + reset(): void { + this.getSource().reset(); + } + + /** + * Reload the list of items. + */ + async reload(): Promise { + await this.getSource().reload(); + } + + /** + * Load items for the next page, if any. + */ + async loadNextPage(): Promise { + await this.getSource().loadNextPage(); + } + + /** + * Log activity when the page starts. + */ + protected async logActivity(): Promise { + // Override to log activity. + } + + /** + * Check whether to reset navigation when selecting an item. + * + * @returns boolean Whether navigation should be reset. + */ + protected resetNavigation(): boolean { + if (!CoreScreen.isTablet) { + return false; + } + + return !!this.splitView && !this.splitView?.isNested; + } + + /** + * Get the item that should be selected by default. + */ + protected getDefaultItem(): Item | null { + return this.items[0] || null; + } + + /** + * @inheritdoc + */ + protected getCurrentPageRoute(): ActivatedRoute | null { + if (this.pageRouteLocator instanceof ActivatedRoute) { + return CoreNavigator.isRouteActive(this.pageRouteLocator) ? this.pageRouteLocator : null; + } + + return CoreNavigator.getCurrentRoute({ pageComponent: this.pageRouteLocator }); + } + + /** + * @inheritdoc + */ + protected getSelectedItemPath(route?: ActivatedRouteSnapshot | null): string | null { + const segments: UrlSegment[] = []; + + while ((route = route?.firstChild)) { + segments.push(...route.url); + } + + return segments.map(segment => segment.path).join('/').replace(/\/+/, '/').trim() || null; + } + + /** + * Get the page route given a child route on the splitview outlet. + * + * @param route Child route. + * @return Page route. + */ + private getPageRouteFromSplitViewOutlet(route: ActivatedRouteSnapshot | null): ActivatedRouteSnapshot | null { + const isPageRoute = this.buildRouteMatcher(); + + while (route && !isPageRoute(route)) { + route = route.parent; + } + + return route; + } + + /** + * Build a function to check whether the given snapshot belongs to the page. + * + * @returns Route matcher. + */ + private buildRouteMatcher(): (route: ActivatedRouteSnapshot) => boolean { + if (this.pageRouteLocator instanceof ActivatedRoute) { + const pageRoutePath = CoreNavigator.getRouteFullPath(this.pageRouteLocator.snapshot); + + return route => CoreNavigator.getRouteFullPath(route) === pageRoutePath; + } + + return route => route.component === this.pageRouteLocator; + } + +} diff --git a/src/core/classes/items-management/swipe-items-manager.ts b/src/core/classes/items-management/swipe-items-manager.ts new file mode 100644 index 000000000..18cb6b40c --- /dev/null +++ b/src/core/classes/items-management/swipe-items-manager.ts @@ -0,0 +1,97 @@ +// (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 { ActivatedRoute } from '@angular/router'; + +import { CoreNavigator } from '@services/navigator'; + +import { CoreItemsManager } from './items-manager'; + +/** + * Helper class to manage the state and routing of a swipeable page. + */ +export abstract class CoreSwipeItemsManager extends CoreItemsManager { + + /** + * Process page started operations. + */ + async start(): Promise { + this.updateSelectedItem(); + } + + /** + * Navigate to the next item. + */ + async navigateToNextItem(): Promise { + await this.navigateToItemBy(-1, 'back'); + } + + /** + * Navigate to the previous item. + */ + async navigateToPreviousItem(): Promise { + await this.navigateToItemBy(1, 'forward'); + } + + /** + * @inheritdoc + */ + protected getCurrentPageRoute(): ActivatedRoute | null { + return CoreNavigator.getCurrentRoute(); + } + + /** + * Navigate to an item by an offset. + * + * @param delta Index offset. + * @param animationDirection Animation direction. + */ + protected async navigateToItemBy(delta: number, animationDirection: 'forward' | 'back'): Promise { + const item = await this.getItemBy(delta); + + if (!item) { + return; + } + + await this.navigateToItem(item, { animationDirection, replace: true }); + } + + /** + * Get item by an offset. + * + * @param delta Index offset. + */ + protected async getItemBy(delta: number): Promise { + const items = this.getSource().getItems(); + + // Get current item. + const index = (this.selectedItem && items?.indexOf(this.selectedItem)) ?? -1; + + if (index === -1) { + return null; + } + + // Get item by delta. + const item = items?.[index + delta] ?? null; + + if (!item && !this.getSource().isCompleted()) { + await this.getSource().loadNextPage(); + + return this.getItemBy(delta); + } + + return item; + } + +} diff --git a/src/core/classes/page-items-list-manager.ts b/src/core/classes/page-items-list-manager.ts index 27705ffef..920e7db66 100644 --- a/src/core/classes/page-items-list-manager.ts +++ b/src/core/classes/page-items-list-manager.ts @@ -22,6 +22,8 @@ import { CoreUtils } from '@services/utils/utils'; /** * Helper class to manage the state and routing of a list of items in a page, for example on pages using a split view. + * + * @deprecated use CoreListItemsManager instead. */ export abstract class CorePageItemsListManager { diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index b055bad15..d39a0e057 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -50,6 +50,7 @@ import { CoreShowPasswordComponent } from './show-password/show-password'; import { CoreSitePickerComponent } from './site-picker/site-picker'; import { CoreSplitViewComponent } from './split-view/split-view'; import { CoreStyleComponent } from './style/style'; +import { CoreSwipeNavigationComponent } from './swipe-navigation/swipe-navigation'; import { CoreTabComponent } from './tabs/tab'; import { CoreTabsComponent } from './tabs/tabs'; import { CoreTabsOutletComponent } from './tabs-outlet/tabs-outlet'; @@ -92,6 +93,7 @@ import { CoreButtonWithSpinnerComponent } from './button-with-spinner/button-wit CoreSitePickerComponent, CoreSplitViewComponent, CoreStyleComponent, + CoreSwipeNavigationComponent, CoreTabComponent, CoreTabsComponent, CoreTabsOutletComponent, @@ -140,6 +142,7 @@ import { CoreButtonWithSpinnerComponent } from './button-with-spinner/button-wit CoreSitePickerComponent, CoreSplitViewComponent, CoreStyleComponent, + CoreSwipeNavigationComponent, CoreTabComponent, CoreTabsComponent, CoreTabsOutletComponent, diff --git a/src/core/components/swipe-navigation/swipe-navigation.html b/src/core/components/swipe-navigation/swipe-navigation.html new file mode 100644 index 000000000..1519bb5e4 --- /dev/null +++ b/src/core/components/swipe-navigation/swipe-navigation.html @@ -0,0 +1,5 @@ + + + + + diff --git a/src/core/components/swipe-navigation/swipe-navigation.scss b/src/core/components/swipe-navigation/swipe-navigation.scss new file mode 100644 index 000000000..4baf55d1c --- /dev/null +++ b/src/core/components/swipe-navigation/swipe-navigation.scss @@ -0,0 +1,7 @@ +ion-slides { + min-height: 100%; +} + +ion-slide { + align-items: start; +} diff --git a/src/core/components/swipe-navigation/swipe-navigation.ts b/src/core/components/swipe-navigation/swipe-navigation.ts new file mode 100644 index 000000000..a87c10b7f --- /dev/null +++ b/src/core/components/swipe-navigation/swipe-navigation.ts @@ -0,0 +1,41 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input } from '@angular/core'; +import { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; + +@Component({ + selector: 'core-swipe-navigation', + templateUrl: 'swipe-navigation.html', + styleUrls: ['swipe-navigation.scss'], +}) +export class CoreSwipeNavigationComponent { + + @Input() manager?: CoreSwipeItemsManager; + + /** + * Swipe to previous item. + */ + swipeLeft(): void { + this.manager?.navigateToPreviousItem(); + } + + /** + * Swipe to next item. + */ + swipeRight(): void { + this.manager?.navigateToNextItem(); + } + +} diff --git a/src/core/core.module.ts b/src/core/core.module.ts index 56d5a0ee8..d5c75b618 100644 --- a/src/core/core.module.ts +++ b/src/core/core.module.ts @@ -14,9 +14,11 @@ import { HTTP_INTERCEPTORS } from '@angular/common/http'; import { ApplicationInitStatus, Injector, NgModule, Type } from '@angular/core'; +import { HammerModule, HAMMER_GESTURE_CONFIG } from '@angular/platform-browser'; import { CoreApplicationInitStatus } from './classes/application-init-status'; import { CoreFeaturesModule } from './features/features.module'; +import { CoreHammerGestureConfig } from './classes/hammer-gesture-config'; import { CoreInterceptor } from './classes/interceptor'; import { getDatabaseProviders } from './services/database'; import { getInitializerProviders } from './initializers'; @@ -84,9 +86,11 @@ export const CORE_SERVICES: Type[] = [ @NgModule({ imports: [ CoreFeaturesModule, + HammerModule, ], providers: [ { provide: HTTP_INTERCEPTORS, useClass: CoreInterceptor, multi: true }, + { provide: HAMMER_GESTURE_CONFIG, useClass: CoreHammerGestureConfig }, { provide: ApplicationInitStatus, useClass: CoreApplicationInitStatus, deps: [Injector] }, ...getDatabaseProviders(), ...getInitializerProviders(), diff --git a/src/core/features/user/classes/participants-source.ts b/src/core/features/user/classes/participants-source.ts new file mode 100644 index 000000000..594ff4817 --- /dev/null +++ b/src/core/features/user/classes/participants-source.ts @@ -0,0 +1,81 @@ +// (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 { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; + +import { CoreUser, CoreUserData, CoreUserParticipant, CoreUserProvider } from '../services/user'; + +/** + * Provides a collection of course participants. + */ +export class CoreUserParticipantsSource extends CoreItemsManagerSource { + + /** + * @inheritdoc + */ + static getSourceId(courseId: number, searchQuery: string | null = null): string { + searchQuery = searchQuery ?? '__empty__'; + + return `participants-${courseId}-${searchQuery}`; + } + + readonly COURSE_ID: number; + readonly SEARCH_QUERY: string | null; + + constructor(courseId: number, searchQuery: string | null = null) { + super(); + + this.COURSE_ID = courseId; + this.SEARCH_QUERY = searchQuery; + } + + /** + * @inheritdoc + */ + protected async loadPageItems(page: number): Promise<{ items: (CoreUserParticipant | CoreUserData)[]; hasMoreItems: boolean }> { + if (this.SEARCH_QUERY) { + const { participants, canLoadMore } = await CoreUser.searchParticipants( + this.COURSE_ID, + this.SEARCH_QUERY, + true, + page, + CoreUserProvider.PARTICIPANTS_LIST_LIMIT, + ); + + return { + items: participants, + hasMoreItems: canLoadMore, + }; + } + + const { participants, canLoadMore } = await CoreUser.getParticipants( + this.COURSE_ID, + page * CoreUserProvider.PARTICIPANTS_LIST_LIMIT, + CoreUserProvider.PARTICIPANTS_LIST_LIMIT, + ); + + return { + items: participants, + hasMoreItems: canLoadMore, + }; + } + + /** + * @inheritdoc + */ + protected getPageLength(): number { + return CoreUserProvider.PARTICIPANTS_LIST_LIMIT; + } + +} diff --git a/src/core/features/user/pages/participants/participants.page.ts b/src/core/features/user/pages/participants/participants.page.ts index 82a9f22f0..61d2c80ed 100644 --- a/src/core/features/user/pages/participants/participants.page.ts +++ b/src/core/features/user/pages/participants/participants.page.ts @@ -13,15 +13,18 @@ // limitations under the License. import { AfterViewInit, Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { Params } from '@angular/router'; import { IonRefresher } from '@ionic/angular'; import { CoreApp } from '@services/app'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreNavigator } from '@services/navigator'; -import { CorePageItemsListManager } from '@classes/page-items-list-manager'; +import { CoreListItemsManager } from '@classes/items-management/list-items-manager'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; -import { CoreUser, CoreUserProvider, CoreUserParticipant, CoreUserData } from '@features/user/services/user'; +import { CoreUser, CoreUserParticipant, CoreUserData } from '@features/user/services/user'; import { CoreUtils } from '@services/utils/utils'; +import { CoreUserParticipantsSource } from '@features/user/classes/participants-source'; +import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; /** * Page that displays the list of course participants. @@ -32,6 +35,7 @@ import { CoreUtils } from '@services/utils/utils'; }) export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestroy { + courseId!: number; participants!: CoreUserParticipantsManager; searchQuery: string | null = null; searchInProgress = false; @@ -42,10 +46,12 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; constructor() { - let courseId: number; - try { - courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); + this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); + this.participants = new CoreUserParticipantsManager( + CoreItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId]), + this, + ); } catch (error) { CoreDomUtils.showErrorModal(error); @@ -54,7 +60,6 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro return; } - this.participants = new CoreUserParticipantsManager(CoreUserParticipantsPage, courseId); } /** @@ -103,9 +108,11 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro return; } + const newSource = CoreItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId]); + this.searchQuery = null; this.searchInProgress = false; - this.participants.resetItems(); + this.participants.setSource(newSource); await this.fetchInitialParticipants(); } @@ -118,9 +125,11 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro async search(query: string): Promise { CoreApp.closeKeyboard(); + const newSource = CoreItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId, query]); + this.searchInProgress = true; this.searchQuery = query; - this.participants.resetItems(); + this.participants.setSource(newSource); await this.fetchInitialParticipants(); @@ -133,8 +142,8 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro * @param refresher Refresher. */ async refreshParticipants(refresher: IonRefresher): Promise { - await CoreUtils.ignoreErrors(CoreUser.invalidateParticipantsList(this.participants.courseId)); - await CoreUtils.ignoreErrors(this.fetchParticipants()); + await CoreUtils.ignoreErrors(CoreUser.invalidateParticipantsList(this.courseId)); + await CoreUtils.ignoreErrors(this.fetchParticipants(true)); refresher?.complete(); } @@ -146,7 +155,7 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro */ async fetchMoreParticipants(complete: () => void): Promise { try { - await this.fetchParticipants(this.participants.items); + await this.fetchParticipants(false); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'Error loading more participants'); @@ -161,38 +170,23 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro */ private async fetchInitialParticipants(): Promise { try { - await this.fetchParticipants(); + await this.fetchParticipants(true); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'Error loading participants'); - this.participants.setItems([]); + this.participants.reset(); } } /** * Update the list of participants. * - * @param loadedParticipants Participants list to continue loading from. + * @param reload Whether to reload the list or load the next page. */ - private async fetchParticipants(loadedParticipants: CoreUserParticipant[] | CoreUserData[] = []): Promise { - if (this.searchQuery) { - const { participants, canLoadMore } = await CoreUser.searchParticipants( - this.participants.courseId, - this.searchQuery, - true, - Math.ceil(loadedParticipants.length / CoreUserProvider.PARTICIPANTS_LIST_LIMIT), - CoreUserProvider.PARTICIPANTS_LIST_LIMIT, - ); - - this.participants.setItems((loadedParticipants as CoreUserData[]).concat(participants), canLoadMore); - } else { - const { participants, canLoadMore } = await CoreUser.getParticipants( - this.participants.courseId, - loadedParticipants.length, - ); - - this.participants.setItems((loadedParticipants as CoreUserParticipant[]).concat(participants), canLoadMore); - } + private async fetchParticipants(reload: boolean): Promise { + reload + ? await this.participants.reload() + : await this.participants.loadNextPage(); this.fetchMoreParticipantsFailed = false; } @@ -202,14 +196,14 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro /** * Helper to manage the list of participants. */ -class CoreUserParticipantsManager extends CorePageItemsListManager { +class CoreUserParticipantsManager extends CoreListItemsManager { - courseId: number; + page: CoreUserParticipantsPage; - constructor(pageComponent: unknown, courseId: number) { - super(pageComponent); + constructor(source: CoreUserParticipantsSource, page: CoreUserParticipantsPage) { + super(source, CoreUserParticipantsPage); - this.courseId = courseId; + this.page = page; } /** @@ -219,11 +213,18 @@ class CoreUserParticipantsManager extends CorePageItemsListManager { - await CoreUser.logParticipantsView(this.courseId); + await CoreUser.logParticipantsView(this.page.courseId); } } diff --git a/src/core/features/user/pages/profile/profile.html b/src/core/features/user/pages/profile/profile.html index ce58f5ae1..d4f77cb01 100644 --- a/src/core/features/user/pages/profile/profile.html +++ b/src/core/features/user/pages/profile/profile.html @@ -7,90 +7,87 @@ - - - - - - - -
- + + + + + + + + +
+ + + + + {{ handler.title | translate }} + + + +
+ +
+
+ + + + + + + + + +

{{ handler.title | translate }}

+
+ + + {{ handler.badgeA11yText | translate: {$a : handler.badge } }} + + + +
+ + + {{ handler.title | translate }} + -
- -
-
- - - - - - - - - - - - -

{{ handler.title | translate }}

-
- - - {{ handler.badgeA11yText | translate: {$a : handler.badge } }} - - - -
- - - - - - {{ handler.title | translate }} - - - - -
- - - - - -
+ + + + + + +
diff --git a/src/core/features/user/pages/profile/profile.page.ts b/src/core/features/user/pages/profile/profile.page.ts index d8ffe46e4..054ee18bc 100644 --- a/src/core/features/user/pages/profile/profile.page.ts +++ b/src/core/features/user/pages/profile/profile.page.ts @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { ActivatedRoute, ActivatedRouteSnapshot, Params } from '@angular/router'; import { Component, OnDestroy, OnInit } from '@angular/core'; import { IonRefresher } from '@ionic/angular'; import { Subscription } from 'rxjs'; @@ -20,16 +21,16 @@ import { CoreSite } from '@classes/site'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; -import { - CoreUser, - CoreUserProfile, - CoreUserProvider, -} from '@features/user/services/user'; +import { CoreUser, CoreUserBasicData, CoreUserProfile, CoreUserProvider } from '@features/user/services/user'; import { CoreUserHelper } from '@features/user/services/user-helper'; import { CoreUserDelegate, CoreUserDelegateService, CoreUserProfileHandlerData } from '@features/user/services/user-delegate'; import { CoreUtils } from '@services/utils/utils'; import { CoreNavigator } from '@services/navigator'; import { CoreCourses } from '@features/courses/services/courses'; +import { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; +import { CoreUserParticipantsSource } from '@features/user/classes/participants-source'; +import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; +import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; @Component({ selector: 'page-core-user-profile', @@ -55,7 +56,10 @@ export class CoreUserProfilePage implements OnInit, OnDestroy { newPageHandlers: CoreUserProfileHandlerData[] = []; communicationHandlers: CoreUserProfileHandlerData[] = []; - constructor() { + users?: CoreUserSwipeItemsManager; + usersQueryParams: Params = {}; + + constructor(private route: ActivatedRoute) { this.obsProfileRefreshed = CoreEvents.on(CoreUserProvider.PROFILE_REFRESHED, (data) => { if (!this.user || !data.user) { return; @@ -86,6 +90,15 @@ export class CoreUserProfilePage implements OnInit, OnDestroy { this.courseId = undefined; } + if (this.courseId && this.route.snapshot.data.swipeManagerSource === 'participants') { + const search = CoreNavigator.getRouteParam('search'); + const source = CoreItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId, search]); + this.users = new CoreUserSwipeItemsManager(source, this); + + this.usersQueryParams.search = search; + this.users.start(); + } + try { await this.fetchUser(); @@ -204,8 +217,49 @@ export class CoreUserProfilePage implements OnInit, OnDestroy { * @inheritdoc */ ngOnDestroy(): void { + this.users?.destroy(); this.subscription?.unsubscribe(); this.obsProfileRefreshed.off(); } } + +/** + * Helper to manage swiping within a collection of users. + */ +class CoreUserSwipeItemsManager extends CoreSwipeItemsManager { + + page: CoreUserProfilePage; + + constructor(source: CoreItemsManagerSource, page: CoreUserProfilePage) { + super(source); + + this.page = page; + } + + /** + * @inheritdoc + */ + protected getItemPath(item: CoreUserBasicData): string { + return String(item.id); + } + + /** + * @inheritdoc + */ + protected getItemQueryParams(): Params { + return this.page.usersQueryParams; + } + + /** + * @inheritdoc + */ + protected getSelectedItemPath(route?: ActivatedRouteSnapshot | null): string | null { + if (!route) { + return null; + } + + return route.params.userId; + } + +} diff --git a/src/core/features/user/pages/profile/profile.scss b/src/core/features/user/pages/profile/profile.scss index 41723e265..eac82607b 100644 --- a/src/core/features/user/pages/profile/profile.scss +++ b/src/core/features/user/pages/profile/profile.scss @@ -19,6 +19,9 @@ right: calc(50% - 12px - var(--core-avatar-size) / 2) !important; } } + core-loading .core-loading-content { + width: 100%; + } } } diff --git a/src/core/features/user/user.module.ts b/src/core/features/user/user.module.ts index ff8bb99ad..a8e2398a5 100644 --- a/src/core/features/user/user.module.ts +++ b/src/core/features/user/user.module.ts @@ -60,6 +60,9 @@ const routes: Routes = [ { path: `${COURSE_PAGE_NAME}/${COURSE_INDEX_PATH}/${PARTICIPANTS_PAGE_NAME}/:userId`, loadChildren: () => import('@features/user/pages/profile/profile.module').then(m => m.CoreUserProfilePageModule), + data: { + swipeManagerSource: 'participants', + }, }, ], () => CoreScreen.isMobile), ]; diff --git a/src/core/services/navigator.ts b/src/core/services/navigator.ts index 52455ad12..5cc058cb8 100644 --- a/src/core/services/navigator.ts +++ b/src/core/services/navigator.ts @@ -48,6 +48,7 @@ export type CoreRedirectPayload = { export type CoreNavigationOptions = Pick & { params?: Params; reset?: boolean; + replace?: boolean; preferCurrentTab?: boolean; // Default true. nextNavigation?: { path: string; @@ -137,6 +138,7 @@ export class CoreNavigatorService { animationDirection: options.animationDirection, queryParams: CoreObject.isEmpty(options.params ?? {}) ? null : CoreObject.withoutEmpty(options.params), relativeTo: path.startsWith('/') ? null : this.getCurrentRoute(), + replaceUrl: options.replace, }); // Remove objects from queryParams and replace them with an ID.