diff --git a/dev/user-frontend-ionic/projects/auth/src/lib/login/login.page.ts b/dev/user-frontend-ionic/projects/auth/src/lib/login/login.page.ts index 63c4f1bb..ea43fec1 100644 --- a/dev/user-frontend-ionic/projects/auth/src/lib/login/login.page.ts +++ b/dev/user-frontend-ionic/projects/auth/src/lib/login/login.page.ts @@ -41,7 +41,7 @@ import { Component, OnInit } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { IonInput, ToastController } from '@ionic/angular'; -import { AuthenticatedUser, NavigationService } from '@multi/shared'; +import { AuthenticatedUser, FeaturesService, NavigationService } from '@multi/shared'; import { Observable } from 'rxjs'; import { finalize, take, tap } from 'rxjs/operators'; import { AuthService } from '../common/auth.service'; @@ -78,7 +78,8 @@ export class LoginPage implements OnInit { private loginRepository: LoginRepository, private route: ActivatedRoute, private router: Router, - private navigationService: NavigationService + private navigationService: NavigationService, + private featuresService: FeaturesService ) { this.translatedPageContent$ = this.loginRepository.translatedPageContent$; this.hideBackButton$ = this.navigationService.isExternalNavigation$; @@ -149,12 +150,13 @@ export class LoginPage implements OnInit { } this.authService.dispatchLoginAction(); - if (this.returnUrl) { - this.router.navigateByUrl(this.returnUrl); - return; - } - - this.router.navigate(['/features/widgets']); + this.featuresService.loadAndStoreFeatures().subscribe(() => { + if (this.returnUrl) { + this.router.navigateByUrl(this.returnUrl); + } else { + this.router.navigate(['/features/widgets']); + } + }); }); } diff --git a/dev/user-frontend-ionic/projects/auth/src/lib/preferences/preferences.service.ts b/dev/user-frontend-ionic/projects/auth/src/lib/preferences/preferences.service.ts index dfe5b719..e049aa6d 100644 --- a/dev/user-frontend-ionic/projects/auth/src/lib/preferences/preferences.service.ts +++ b/dev/user-frontend-ionic/projects/auth/src/lib/preferences/preferences.service.ts @@ -64,8 +64,10 @@ export class PreferencesService { return getRefreshAuthToken().pipe( take(1), filter(token => token != null), - concatMap(token => this.keepAuthService.removeSavedCredentials(token)), - finalize(() => deleteRefreshAuthToken()) + concatMap(token => + this.keepAuthService.removeSavedCredentials(token).pipe( + finalize(() => deleteRefreshAuthToken()) + )), ).subscribe(); } } diff --git a/dev/user-frontend-ionic/projects/map/src/lib/map.page.ts b/dev/user-frontend-ionic/projects/map/src/lib/map.page.ts index 2140945d..fcb555ac 100644 --- a/dev/user-frontend-ionic/projects/map/src/lib/map.page.ts +++ b/dev/user-frontend-ionic/projects/map/src/lib/map.page.ts @@ -37,17 +37,17 @@ * termes. */ -import { Component, Inject, OnDestroy, ViewChild } from '@angular/core'; +import { Component, DestroyRef, inject, Inject, ViewChild } from '@angular/core'; import { FormArray, FormBuilder, FormControl, FormGroup } from '@angular/forms'; import { Geolocation } from '@capacitor/geolocation'; import { TranslateService } from '@ngx-translate/core'; import { NetworkService } from '@multi/shared'; import * as Leaflet from 'leaflet'; -import { Subject } from 'rxjs'; -import { finalize, take, takeUntil } from 'rxjs/operators'; +import { finalize, take } from 'rxjs/operators'; import { MapModuleConfig, MAP_CONFIG } from './map.config'; import { Marker, markersList$, setMarkers } from './map.repository'; import { MapService } from './map.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; const CATEGORIES = [ 'presidences_points', @@ -64,7 +64,7 @@ const CATEGORIES = [ templateUrl: './map.page.html', styleUrls: ['../../../../src/theme/app-theme/styles/map/map.page.scss'], }) -export class MapPage implements OnDestroy { +export class MapPage { @ViewChild('popover') popover; @@ -77,7 +77,7 @@ export class MapPage implements OnDestroy { private markersByCategory: Map = new Map(); private layerGroupByCategory: Map = new Map(); private positionLayerGroup: Leaflet.LayerGroup; - private unsubscribe$: Subject = new Subject(); + private destroyRef = inject(DestroyRef) constructor( private mapService: MapService, @@ -89,7 +89,7 @@ export class MapPage implements OnDestroy { this.initCategoriesForm(); this.categoriesForm.valueChanges.pipe( - takeUntil(this.unsubscribe$) + takeUntilDestroyed(this.destroyRef) ).subscribe(formValues => { this.categoriesSelected = CATEGORIES.filter((value, index) => formValues[index]); this.refreshMap(); @@ -116,11 +116,6 @@ export class MapPage implements OnDestroy { this.map.remove(); } - ngOnDestroy() { - this.unsubscribe$.next(true); - this.unsubscribe$.complete(); - } - onLocateUserClick() { this.refreshUserPosition(); } diff --git a/dev/user-frontend-ionic/projects/shared/src/lib/auth/auth.repository.ts b/dev/user-frontend-ionic/projects/shared/src/lib/auth/auth.repository.ts index 0e3fb96d..4735bed7 100644 --- a/dev/user-frontend-ionic/projects/shared/src/lib/auth/auth.repository.ts +++ b/dev/user-frontend-ionic/projects/shared/src/lib/auth/auth.repository.ts @@ -39,7 +39,7 @@ import { SecureStoragePlugin } from 'capacitor-secure-storage-plugin'; import { from, Observable, of } from 'rxjs'; -import { catchError, map } from 'rxjs/operators'; +import { catchError, map, switchMap } from 'rxjs/operators'; const AUTH_TOKEN_KEY = 'auth-token'; @@ -53,14 +53,45 @@ export const updateAuthToken = (token: string): Observable => from( ); export const getAuthToken = (): Observable => from( - SecureStoragePlugin.get({key: AUTH_TOKEN_KEY})) - .pipe( - map(result => result.value), - catchError(() => of(null)), - ); + SecureStoragePlugin.keys()).pipe( + map(result => result.value), + switchMap(keys => { + // On vérifie l'existence de la clé avant suppression pour éviter de tomber dans le catch de l'erreur du plugin + // https://github.com/martinkasa/capacitor-secure-storage-plugin/issues/39 + if (keys.includes(AUTH_TOKEN_KEY)) { + return from(SecureStoragePlugin.get({key: AUTH_TOKEN_KEY})) + .pipe( + map(result => result.value), + catchError(() => { + return of(null) + })); + } else { + return of(null); + } + }), + catchError(() => of(null)) +); export const deleteAuthToken = (): Observable => from( - SecureStoragePlugin.remove({key: AUTH_TOKEN_KEY})) - .pipe( - map(result => result.value) - ); + SecureStoragePlugin.keys()).pipe( + map(result => result.value), + switchMap(keys => { + // On vérifie l'existence de la clé avant suppression pour éviter de tomber dans le catch de l'erreur du plugin + // https://github.com/martinkasa/capacitor-secure-storage-plugin/issues/39 + if (keys.includes(AUTH_TOKEN_KEY)) { + return from( + SecureStoragePlugin.remove({key: AUTH_TOKEN_KEY})) + .pipe( + map(result => result.value), + catchError(() => { + return of(null) + })); + } else { + return of(null); + } + }), + catchError(() => of(null)) +); + + + diff --git a/dev/user-frontend-ionic/projects/shared/src/lib/auth/keep-auth.repository.ts b/dev/user-frontend-ionic/projects/shared/src/lib/auth/keep-auth.repository.ts index 0077b6b9..0caf75d0 100644 --- a/dev/user-frontend-ionic/projects/shared/src/lib/auth/keep-auth.repository.ts +++ b/dev/user-frontend-ionic/projects/shared/src/lib/auth/keep-auth.repository.ts @@ -39,7 +39,7 @@ import { SecureStoragePlugin } from 'capacitor-secure-storage-plugin'; import { from, Observable, of } from 'rxjs'; -import { catchError, map } from 'rxjs/operators'; +import { catchError, map, switchMap } from 'rxjs/operators'; const REFRESH_AUTH_TOKEN_KEY = 'refresh-auth-token'; @@ -53,14 +53,42 @@ export const updateRefreshAuthToken = (token: string): Observable => fr ); export const getRefreshAuthToken = (): Observable => from( - SecureStoragePlugin.get({key: REFRESH_AUTH_TOKEN_KEY})) - .pipe( - map(result => result.value), - catchError(() => of(null)), - ); + SecureStoragePlugin.keys()).pipe( + map(result => result.value), + switchMap(keys => { + // On vérifie l'existence de la clé avant suppression pour éviter de tomber dans le catch de l'erreur du plugin + // https://github.com/martinkasa/capacitor-secure-storage-plugin/issues/39 + if (keys.includes(REFRESH_AUTH_TOKEN_KEY)) { + return from(SecureStoragePlugin.get({key: REFRESH_AUTH_TOKEN_KEY})) + .pipe( + map(result => result.value), + catchError(() => { + return of(null) + }),) + } else { + return of(null); + } + }), + catchError(() => of(null)) +); export const deleteRefreshAuthToken = (): Observable => from( - SecureStoragePlugin.remove({key: REFRESH_AUTH_TOKEN_KEY})) - .pipe( - map(result => result.value) - ); + SecureStoragePlugin.keys()).pipe( + map(result => result.value), + switchMap(keys => { + // On vérifie l'existence de la clé avant suppression pour éviter de tomber dans le catch de l'erreur du plugin + // https://github.com/martinkasa/capacitor-secure-storage-plugin/issues/39 + if (keys.includes(REFRESH_AUTH_TOKEN_KEY)) { + return from( + SecureStoragePlugin.remove({key: REFRESH_AUTH_TOKEN_KEY})) + .pipe( + map(result => result.value), + catchError(() => { + return of(null) + })); + } else { + return of(null); + } + }), + catchError(() => of(null)) +); diff --git a/dev/user-frontend-ionic/projects/shared/src/lib/components/widgets/widget-lifecycle.service.ts b/dev/user-frontend-ionic/projects/shared/src/lib/components/widgets/widget-lifecycle.service.ts index 59f0f34a..e377c117 100644 --- a/dev/user-frontend-ionic/projects/shared/src/lib/components/widgets/widget-lifecycle.service.ts +++ b/dev/user-frontend-ionic/projects/shared/src/lib/components/widgets/widget-lifecycle.service.ts @@ -38,7 +38,7 @@ */ import { Injectable } from '@angular/core'; -import { Observable, Subject } from 'rxjs'; +import { Observable, ReplaySubject, Subject } from 'rxjs'; import { filter, shareReplay } from 'rxjs/operators'; /** @@ -54,9 +54,9 @@ import { filter, shareReplay } from 'rxjs/operators'; }) export class WidgetLifecycleService { private widgetViewWillEnterSubject: Subject = new Subject(); - private widgetViewDidEnterSubject: Subject = new Subject(); + private widgetViewDidEnterSubject: ReplaySubject = new ReplaySubject(1); private widgetViewWillLeaveSubject: Subject = new Subject(); - private widgetViewDidLeaveSubject: Subject = new Subject(); + private widgetViewDidLeaveSubject: ReplaySubject = new ReplaySubject(1); sendWidgetViewWillEnter(widgets) { this.widgetViewWillEnterSubject.next(widgets); diff --git a/dev/user-frontend-ionic/projects/shared/src/lib/components/widgets/widget.component.ts b/dev/user-frontend-ionic/projects/shared/src/lib/components/widgets/widget.component.ts index 9992e39c..64c2c6be 100644 --- a/dev/user-frontend-ionic/projects/shared/src/lib/components/widgets/widget.component.ts +++ b/dev/user-frontend-ionic/projects/shared/src/lib/components/widgets/widget.component.ts @@ -40,15 +40,14 @@ import { AfterViewInit, ChangeDetectorRef, - Component, - EventEmitter, + Component, DestroyRef, + EventEmitter, inject, Input, - OnDestroy, Output, ViewChild, ViewContainerRef } from '@angular/core'; -import { Subscription } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ProjectModuleService } from '../../project-module/project-module.service'; import { WidgetLifecycleService } from './widget-lifecycle.service'; @@ -57,15 +56,13 @@ import { WidgetLifecycleService } from './widget-lifecycle.service'; templateUrl: './widget.component.html', styleUrls: ['../../../../../../src/theme/app-theme/styles/shared/widget.component.scss'], }) -export class WidgetComponent implements AfterViewInit, OnDestroy { +export class WidgetComponent implements AfterViewInit { @Input() widgetId: string; @Input() widgetColor: string; @ViewChild('widget', { read: ViewContainerRef }) widgetContainerRef: ViewContainerRef; @Output() widgetIsEmpty = new EventEmitter(); - private widgetViewWillEnterSubscription: Subscription; - private widgetViewDidEnterSubscription: Subscription; - private widgetViewWillLeaveSubscription: Subscription; - private widgetViewDidLeaveSubscription: Subscription; + + private destroyRef = inject(DestroyRef); constructor( private projectModuleService: ProjectModuleService, @@ -82,9 +79,11 @@ export class WidgetComponent implements AfterViewInit, OnDestroy { widgetInstance.widgetColor = this.widgetColor; if (widgetInstance.isEmpty$) { - widgetInstance.isEmpty$.subscribe((isEmpty: boolean) => { - this.widgetIsEmpty.emit(isEmpty); - }); + widgetInstance.isEmpty$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((isEmpty: boolean) => { + this.widgetIsEmpty.emit(isEmpty); + }); } this.cdr.detectChanges(); @@ -93,36 +92,25 @@ export class WidgetComponent implements AfterViewInit, OnDestroy { } } - ngOnDestroy() { - if(this.widgetViewWillEnterSubscription) { - this.widgetViewWillEnterSubscription.unsubscribe(); - } - if(this.widgetViewDidEnterSubscription) { - this.widgetViewDidEnterSubscription.unsubscribe(); - } - if(this.widgetViewWillLeaveSubscription) { - this.widgetViewWillLeaveSubscription.unsubscribe(); - } - if(this.widgetViewDidLeaveSubscription) { - this.widgetViewDidLeaveSubscription.unsubscribe(); - } - } - private handleWidgetLifecycle(widgetInstance: any) { if (widgetInstance.widgetViewWillEnter && typeof widgetInstance.widgetViewWillEnter === 'function') { - this.widgetViewWillEnterSubscription = this.widgetLifecycleService.widgetViewWillEnter(this.widgetId) + this.widgetLifecycleService.widgetViewWillEnter(this.widgetId) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => widgetInstance.widgetViewWillEnter()); } if (widgetInstance.widgetViewDidEnter && typeof widgetInstance.widgetViewDidEnter === 'function') { - this.widgetViewDidEnterSubscription = this.widgetLifecycleService.widgetViewDidEnter(this.widgetId) + this.widgetLifecycleService.widgetViewDidEnter(this.widgetId) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => widgetInstance.widgetViewDidEnter()); } if (widgetInstance.widgetViewWillLeave && typeof widgetInstance.widgetViewWillLeave === 'function') { - this.widgetViewWillLeaveSubscription = this.widgetLifecycleService.widgetViewWillLeave(this.widgetId) + this.widgetLifecycleService.widgetViewWillLeave(this.widgetId) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => widgetInstance.widgetViewWillLeave()); } if (widgetInstance.widgetViewDidLeave && typeof widgetInstance.widgetViewDidLeave === 'function') { - this.widgetViewDidLeaveSubscription = this.widgetLifecycleService.widgetViewDidLeave(this.widgetId) + this.widgetLifecycleService.widgetViewDidLeave(this.widgetId) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => widgetInstance.widgetViewDidLeave()); } } diff --git a/dev/user-frontend-ionic/src/app/app.component.html b/dev/user-frontend-ionic/src/app/app.component.html index b08827ec..29ed2bd6 100644 --- a/dev/user-frontend-ionic/src/app/app.component.html +++ b/dev/user-frontend-ionic/src/app/app.component.html @@ -37,19 +37,21 @@ ~ termes. --> - + -
-
- - - {{"ERROR.NO_NETWORK.TITLE" | translate}} - - - {{"ERROR.NO_NETWORK.FIRST_LAUNCH" | translate}} - - + +
+
+ + + {{"ERROR.NO_NETWORK.TITLE" | translate}} + + + {{"ERROR.NO_NETWORK.FIRST_LAUNCH" | translate}} + + +
-
+ diff --git a/dev/user-frontend-ionic/src/app/app.component.ts b/dev/user-frontend-ionic/src/app/app.component.ts index 42360d37..d3067046 100644 --- a/dev/user-frontend-ionic/src/app/app.component.ts +++ b/dev/user-frontend-ionic/src/app/app.component.ts @@ -38,7 +38,7 @@ */ import { DOCUMENT } from '@angular/common'; -import { Component, Inject, OnDestroy, OnInit, Renderer2 } from '@angular/core'; +import { Component, DestroyRef, inject, Inject, OnDestroy, OnInit, Renderer2 } from '@angular/core'; import { FirebaseMessaging } from '@capacitor-firebase/messaging'; import { App } from '@capacitor/app'; import { Capacitor, PluginListenerHandle } from '@capacitor/core'; @@ -54,9 +54,10 @@ import { themeRepoInitialized$, userHadSetThemeInApp, userHadSetThemeInApp$ } from '@multi/shared'; import { initializeApp } from 'firebase/app'; -import { combineLatest, Observable, of, Subscription } from 'rxjs'; -import { map, switchMap } from 'rxjs/operators'; +import { combineLatest, Observable, of } from 'rxjs'; +import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; import { Title } from '@angular/platform-browser'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'app-root', @@ -64,15 +65,14 @@ import { Title } from '@angular/platform-browser'; styleUrls: ['../theme/app-theme/styles/app/app.component.scss'], }) export class AppComponent implements OnInit, OnDestroy { - public languages: Array = []; public currentPageLayout$: Observable; public isOnline$: Observable; public isNothingToShow$: Observable; - private featuresIsEmpty$: Observable; - private subscriptions: Subscription[] = []; private backButtonListener: Promise; private appResumeListener: Promise; + private destroyRef = inject(DestroyRef); + private prefersDark: MediaQueryList; constructor( @Inject('environment') @@ -91,149 +91,149 @@ export class AppComponent implements OnInit, OnDestroy { private statisticsService: StatisticsService, private titleService: Title ) { - currentLanguage$.subscribe((language) => { - document.documentElement.lang = language || environment.defaultLanguage; - }); - - this.featuresIsEmpty$ = features$.pipe( - map((val) => val.length === 0) - ); - - this.isNothingToShow$ = isFeatureStoreInitialized$.pipe( - switchMap(isInitialized => { - if (isInitialized) { - return combineLatest([ - this.featuresIsEmpty$, - this.networkService.isOnline$ - ]); - } else { - return of([null, null]); - } - }), - switchMap(([featuresIsEmpty, isOnline]) => { - const nothingToShowButLoadFeatures = featuresIsEmpty && isOnline; - const isNothingToShow = (featuresIsEmpty === true || featuresIsEmpty === null) && (isOnline === false || isOnline === null); - - if (nothingToShowButLoadFeatures) { - return this.featuresService.loadAndStoreFeatures().pipe( - map(() => true) - ); - } else { - return of(isNothingToShow); - } - })); - - this.isNothingToShow$.subscribe(); - - SplashScreen.show({ - showDuration: 1500, - autoHide: true, - fadeInDuration: 500 - }); - - // Define available languages in app - this.languages = this.environment.languages; - this.translateService.addLangs(this.languages); + this.initializeApp(); + } - this.currentPageLayout$ = this.pageLayoutService.currentPageLayout$; + ngOnInit() { + this.titleService.setTitle(this.environment.appTitle); + this.initializeBackButton(); + this.initializeAppResume(); + this.initializeTheme(); + this.handleBadge(); - this.platform.ready().then(() => { - if (!Capacitor.isNativePlatform()) { - return; - } + if (!Capacitor.isNativePlatform()) { + this.initializeFirebase(); + } + } - StatusBar.setStyle({ style: Style.Dark }); - }); + ngOnDestroy(): void { + this.backButtonListener.then((listener) => listener.remove()); + this.appResumeListener.then((listener) => listener.remove()); + this.prefersDark.removeEventListener('change', this.handleColorSchemeChange); + } - this.statisticsService.checkAndGenerateStatsUid(); + private initializeBackButton(): void { + this.backButtonListener = App.addListener('backButton', this.handleBackButton.bind(this)); } - ngOnInit() { - this.titleService.setTitle(this.environment.appTitle); - this.backButtonListener = App.addListener('backButton', () => { - this.popoverController.getTop().then(popover => { - if (popover) { - popover.dismiss(); - return; - } - this.modalController.getTop().then(modal => { - if (modal) { - modal.dismiss(); - return; - } - this.navigationService.navigateBack(); - }); - }); - }); + private async handleBackButton(): Promise { + const popover = await this.popoverController.getTop(); + if (popover) { + await popover.dismiss(); + return; + } + const modal = await this.modalController.getTop(); + if (modal) { + await modal.dismiss(); + return; + } + this.navigationService.navigateBack(); + } + private initializeAppResume(): void { // reload notifications when app is resumed (back to foreground) this.appResumeListener = App.addListener('resume', () => { this.notificationsService.loadNotifications(0, 10).subscribe(); }); + } - // apply language saved in persistent state - this.subscriptions.push(currentLanguage$ - .subscribe(language => this.translateService.use(language || this.environment.defaultLanguage)) - ); - - // apply theme saved in persistent state or if is not in the system setting of the user's device - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); - prefersDark.removeEventListener('change', (mediaQuery) => this.toggleOSDarkTheme(mediaQuery.matches)); - // Listen for changes to the prefers-color-scheme media query (from system setting of the user) - prefersDark.addEventListener('change', (mediaQuery) => this.toggleOSDarkTheme(mediaQuery.matches)); + private initializeTheme(): void { + this.prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); + this.prefersDark.addEventListener('change', this.handleColorSchemeChange); themeRepoInitialized$.pipe( - switchMap(isInitialized => isInitialized ? combineLatest([isDarkTheme$, userHadSetThemeInApp$]) - : of(null)), + filter((isInitialized: boolean) => isInitialized), + switchMap(() => combineLatest([isDarkTheme$, userHadSetThemeInApp$])), + takeUntilDestroyed(this.destroyRef) ).subscribe(([isDarkTheme, userHadSetThemeInApplication]) => { - if (userHadSetThemeInApplication === false && prefersDark.matches) { - isDarkTheme = true; - setIsDarkTheme(true); + if (!userHadSetThemeInApplication) { + isDarkTheme = this.prefersDark.matches; + setIsDarkTheme(isDarkTheme); } - - if (userHadSetThemeInApplication === false && !prefersDark.matches) { - isDarkTheme = false; - setIsDarkTheme(false); - } - this.toggleDarkTheme(isDarkTheme); }); - - this.initializeFirebase(); - this.handleBadge(); - } - - toggleDarkTheme(isDarkTheme: boolean): void { - const body = document.body; - if (isDarkTheme) { - this.renderer.addClass(body, 'dark'); - } else { - this.renderer.removeClass(body, 'dark'); - } } - toggleOSDarkTheme(shouldAdd) { + private handleColorSchemeChange(mediaQuery: MediaQueryListEvent): void { if (!userHadSetThemeInApp()) { + const shouldAdd = mediaQuery.matches; setIsDarkTheme(shouldAdd); this.toggleDarkTheme(shouldAdd); } } - ngOnDestroy(): void { - this.subscriptions.forEach(s => s.unsubscribe()); - this.backButtonListener.then((listener) => { - listener.remove(); + private toggleDarkTheme(isDarkTheme: boolean): void { + this.renderer[isDarkTheme ? 'addClass' : 'removeClass'](document.body, 'dark'); + } + + private initializeApp(): void { + this.initializeLanguage(); + this.initializeEmptyStateDetection(); + this.initializePageLayout(); + this.initializeSplashScreen(); + this.initializeStatusBar(); + this.statisticsService.checkAndGenerateStatsUid(); + } + + private initializeLanguage(): void { + // Charge les langues disponibles à partir du paramètre 'language' dans le fichier d'environnement + this.languages = this.environment.languages; + this.translateService.addLangs(this.languages); + // Observable permettant de mettre à jour le code language dans l'entête html et d'appliquer la langue choisie + currentLanguage$.pipe( + distinctUntilChanged(), + takeUntilDestroyed(this.destroyRef) + ).subscribe(language => { + // Mise à jour du langage dans l'application et dans l'attribut lang de l'élément HTML + this.translateService.use(language || this.environment.defaultLanguage); + this.document.documentElement.lang = language || this.environment.defaultLanguage; }); - this.appResumeListener.then((listener) => { - listener.remove(); + } + + private initializeEmptyStateDetection(): void { + const featuresIsEmpty$ : Observable = features$.pipe(map(val => val.length === 0)); + this.isNothingToShow$ = isFeatureStoreInitialized$.pipe( + filter((isInitialized: boolean) => isInitialized), + switchMap(() => combineLatest([featuresIsEmpty$, this.networkService.isOnline$])), + switchMap(([featuresIsEmpty, isOnline]) => { + if (featuresIsEmpty && isOnline) { + return this.featuresService.loadAndStoreFeatures().pipe(map(() => false)); + } + return of(featuresIsEmpty || !isOnline); + }), + distinctUntilChanged(), + takeUntilDestroyed(this.destroyRef) + ); + this.isNothingToShow$.subscribe(); + } + + private initializePageLayout(): void { + this.currentPageLayout$ = this.pageLayoutService.currentPageLayout$; + } + + private initializeSplashScreen(): void { + SplashScreen.show({ + showDuration: 1500, + autoHide: true, + fadeInDuration: 500 }); } - public async initializeFirebase(): Promise { - if (Capacitor.isNativePlatform()) { + private initializeStatusBar(): void { + if (!Capacitor.isNativePlatform()) { return; } - initializeApp(this.environment.firebase); + this.platform.ready().then(() => { + StatusBar.setStyle({ style: Style.Dark }); + }); + } + + public async initializeFirebase(): Promise { + try { + initializeApp(this.environment.firebase); + } catch (error) { + console.error('Error initializing Firebase:', error); + } } /** @@ -242,27 +242,18 @@ export class AppComponent implements OnInit, OnDestroy { * with the number of notifications in notification center */ private async handleBadge(): Promise { - const isIos = (await Device.getInfo()).platform === 'ios'; - const notSupported = !(await Badge.isSupported()); - if (notSupported || !isIos) { // The badge is already well handled on android, no need to do it manually + const deviceInfo = await Device.getInfo(); + if (deviceInfo.platform !== 'ios' || !(await Badge.isSupported())) { return; } - // Will be called when the user launches the app, then each time the app enters or exits background const fixBadgeCount = async () => { const notificationList = await FirebaseMessaging.getDeliveredNotifications(); - return Badge.set({ - count: notificationList.notifications.length - }); + return Badge.set({ count: notificationList.notifications.length }); }; - this.subscriptions.push(this.platform.pause.subscribe(async () => { - await fixBadgeCount(); - })); - - this.subscriptions.push(this.platform.resume.subscribe(async () => { - await fixBadgeCount(); - })); + this.platform.pause.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(fixBadgeCount); + this.platform.resume.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(fixBadgeCount); await fixBadgeCount(); } diff --git a/dev/user-frontend-ionic/src/app/layout/layout.page.html b/dev/user-frontend-ionic/src/app/layout/layout.page.html index a986a5db..c899e8b2 100644 --- a/dev/user-frontend-ionic/src/app/layout/layout.page.html +++ b/dev/user-frontend-ionic/src/app/layout/layout.page.html @@ -38,18 +38,18 @@ --> - - + - - - + + + - + [iconSourceSvgLightTheme]="menu.iconSourceSvgLightTheme" + [iconSourceSvgDarkTheme]="menu.iconSourceSvgDarkTheme"> + @@ -60,27 +60,24 @@ {{"ERROR.NO_NETWORK.TOP_BAR_LABEL" | translate}} - - - + + [iconSourceSvgLightTheme]="menu.iconSourceSvgLightTheme" + [iconSourceSvgDarkTheme]="menu.iconSourceSvgDarkTheme"> {{(menu.shortTitle || menu.title) | translate}} + diff --git a/dev/user-frontend-ionic/src/app/layout/layout.page.ts b/dev/user-frontend-ionic/src/app/layout/layout.page.ts index e886f6ce..c6c41161 100644 --- a/dev/user-frontend-ionic/src/app/layout/layout.page.ts +++ b/dev/user-frontend-ionic/src/app/layout/layout.page.ts @@ -37,7 +37,15 @@ * termes. */ -import { AfterViewInit, Component, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { + AfterViewInit, + Component, + DestroyRef, + inject, + Input, + OnChanges, + SimpleChanges +} from '@angular/core'; import { NavController } from '@ionic/angular'; import { FeaturesService, @@ -48,19 +56,25 @@ import { MenuOpenerService, MenuService, NetworkService, + Notification, NotificationsRepository, NotificationsService, PageLayout, StatisticsService, NavigationService } from '@multi/shared'; -import { combineLatest, Observable, Subject, withLatestFrom } from 'rxjs'; -import { distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { BehaviorSubject, combineLatestWith, Observable, Subject } from 'rxjs'; +import { distinctUntilChanged, filter, finalize, map } from 'rxjs/operators'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; interface MenuItemWithOptionalRouterLink extends MenuItem { routerLink: string; } +interface MenuItemWithBadge extends MenuItemWithOptionalRouterLink { + hasBadge: boolean; +} + @Component({ selector: 'app-layout', templateUrl: 'layout.page.html', @@ -70,12 +84,12 @@ export class LayoutPage implements AfterViewInit, OnChanges { @Input() currentPageLayout: PageLayout; public isLoading = false; - public topMenuItems$: Observable; public tabsMenuItems$: Observable; + public topMenuItemsWithBadges$: Observable; public isOnline$: Observable; - public menuItemHasBadge$: Observable; - public menuItemHasBadgeSubject$: Subject = new Subject(); + public menuItemHasBadgeState$: BehaviorSubject = new BehaviorSubject([]); public layoutChangeSubject$: Subject = new Subject(); + private destroyRef = inject(DestroyRef); constructor( private navController: NavController, @@ -89,54 +103,8 @@ export class LayoutPage implements AfterViewInit, OnChanges { private notificationsService: NotificationsService, private navigationService: NavigationService ) { - this.isOnline$ = this.networkService.isOnline$; - this.tabsMenuItems$ = this.menuService.tabsMenuItems$.pipe( - map(menuItems => menuItems.map(menuItem => { - if (menuItem.link.type !== MenuItemLinkType.router) { - return { - ...menuItem, - routerLink: null - }; - } - - return { - ...menuItem, - routerLink: (menuItem.link as MenuItemRouterLink).routerLink - }; - })) - ); - - this.topMenuItems$ = this.menuService.topMenuItems$; - this.menuItemHasBadge$ = this.menuItemHasBadgeSubject$; - - combineLatest([ - this.topMenuItems$.pipe( - distinctUntilChanged((prevMenuItems, currentMenuItems) => this.menuService - .areSpecifiedPropertiesEqualsInMenuItemsArrays( - prevMenuItems, - currentMenuItems, - ['link.routerLink'] - )) - ), - this.layoutChangeSubject$ - ]) - .pipe( - filter(([, layout]) => layout === 'tabs'), - map(([menuItems]) => menuItems), - ) - .subscribe(() => { // Triggers when either layout has gone from full to tabs or when the topMenuItems have changed - this.navigationService.setExternalNavigation(false); - this.notificationsService.loadNotifications(0, 10).subscribe(); - }); - - this.notificationsRepository.notifications$.pipe( - withLatestFrom(this.topMenuItems$), - map(([notifications, menuItems]) => menuItems.map(menuItem => menuItem.link.type === MenuItemLinkType.router - && (menuItem.link as MenuItemRouterLink).routerLink === '/notifications' - && notifications.find(notification => notification.state === 'UNREAD') !== undefined)), - ).subscribe(values => { - this.menuItemHasBadgeSubject$.next(values); - }); + this.initializeObservables(); + this.setupSubscriptions(); } ngAfterViewInit() { @@ -144,7 +112,7 @@ export class LayoutPage implements AfterViewInit, OnChanges { } ngOnChanges(changes: SimpleChanges): void { - if(changes.currentPageLayout) { + if (changes.currentPageLayout) { this.layoutChangeSubject$.next(changes.currentPageLayout.currentValue); } if (this.shouldRefreshTabsViewData(changes)) { @@ -152,44 +120,97 @@ export class LayoutPage implements AfterViewInit, OnChanges { } } - public async openExternalOrSsoLinkOnly(menuItem: MenuItem) { - if (menuItem.link.type === MenuItemLinkType.router) { - this.statisticsService.onFunctionalityOpened(menuItem.statisticName); - this.navController.setDirection('forward', false); - return; - } + private initializeObservables(): void { + this.isOnline$ = this.networkService.isOnline$; + this.tabsMenuItems$ = this.menuService.tabsMenuItems$.pipe( + map(this.mapMenuItems) + ); - return this.menuOpenerService.open(menuItem); + this.topMenuItemsWithBadges$ = this.menuService.topMenuItems$.pipe( + combineLatestWith(this.menuItemHasBadgeState$), + map(([menuItems, badges]) => menuItems.map((menuItem, index) => ({ + ...menuItem, + routerLink: menuItem.link.type === MenuItemLinkType.router + ? (menuItem.link as MenuItemRouterLink).routerLink + : undefined, + hasBadge: badges[index] ?? false + }))) + ); + + // this.topMenuItems$ = this.menuService.topMenuItems$; } - public generateMenuItemIdFromRouterLink(menuItem: MenuItemWithOptionalRouterLink) { - if (!menuItem.routerLink) { - return; - } - return menuItem.routerLink.substring(1).replace('/', '-'); + private mapMenuItems(menuItems: MenuItem[]): MenuItemWithOptionalRouterLink[] { + return menuItems.map(menuItem => ({ + ...menuItem, + routerLink: menuItem.link.type === MenuItemLinkType.router + ? (menuItem.link as MenuItemRouterLink).routerLink + : undefined + })); } - public getMenuId(menuItem: MenuItem) { - return this.guidedTourService.generateMenuItemIdFromTitle(menuItem); + private setupSubscriptions(): void { + this.topMenuItemsWithBadges$.pipe( + distinctUntilChanged((prevMenuItems, currentMenuItems) => this.menuService.areSpecifiedPropertiesEqualsInMenuItemsArrays( + prevMenuItems, currentMenuItems, ['link.routerLink'] + )), + combineLatestWith(this.layoutChangeSubject$.pipe(filter(layout => layout === 'tabs'))), + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.navigationService.setExternalNavigation(false); + this.notificationsService.loadNotifications(0, 10).subscribe(); + }); + + this.notificationsRepository.notifications$.pipe( + takeUntilDestroyed(this.destroyRef), + combineLatestWith(this.menuService.topMenuItems$), + map(([notifications, menuItems]) => this.mapNotificationsToMenuItems(notifications, menuItems)), + ).subscribe(values => { + this.menuItemHasBadgeState$.next(values); + }); + } + + private mapNotificationsToMenuItems(notifications: Notification[], menuItems: MenuItem[]): boolean[] { + return menuItems.map(menuItem => + menuItem.link.type === MenuItemLinkType.router && + (menuItem.link as MenuItemRouterLink).routerLink === '/notifications' && + notifications.some(notification => notification.state === 'UNREAD') + ); } private shouldRefreshTabsViewData(changes: SimpleChanges): boolean { const currentPageLayoutChange = changes.currentPageLayout; - - return ( - currentPageLayoutChange && - currentPageLayoutChange.currentValue === 'tabs' && - !currentPageLayoutChange.firstChange - ); + return currentPageLayoutChange?.currentValue === 'tabs' && !currentPageLayoutChange.firstChange; } private async loadFeatures(): Promise { // skip if network is not available if (!(await this.networkService.getConnectionStatus()).connected) { + console.warn('No network connection available, features can not be loaded'); return; } this.isLoading = true; - this.featuresService.loadAndStoreFeatures().subscribe(() => this.isLoading = false); + this.featuresService.loadAndStoreFeatures().pipe( + finalize(() => this.isLoading = false) + ).subscribe(); + } + + public async openExternalOrSsoLinkOnly(menuItem: MenuItem): Promise { + if (menuItem.link.type === MenuItemLinkType.router) { + this.statisticsService.onFunctionalityOpened(menuItem.statisticName); + this.navController.setDirection('forward', false); + return; + } + + return this.menuOpenerService.open(menuItem); + } + + public generateMenuItemIdFromRouterLink(menuItem: MenuItemWithOptionalRouterLink) { + return menuItem.routerLink?.substring(1).replace('/', '-'); + } + + public getMenuId(menuItem: MenuItem) { + return this.guidedTourService.generateMenuItemIdFromTitle(menuItem); } }