diff --git a/package-lock.json b/package-lock.json index b01f00f9cbc913a6ce8629f378985f9a4b4e16bc..5a57c2d81c68e7d9e8ae614d228f6aa5eb7f0ed7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8453,6 +8453,14 @@ "tslib": "^1.9.0" } }, + "ngx-webstorage-service": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/ngx-webstorage-service/-/ngx-webstorage-service-4.0.1.tgz", + "integrity": "sha512-hSWmwnj+hHBdwMZDeFbJjzXKUrJMw86B3l74cb6LePM97Vu8D62MTOPj2+ABB5NMIkUhxgO1E27ih/zhrEd9Fg==", + "requires": { + "tslib": "^1.9.0" + } + }, "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", diff --git a/package.json b/package.json index 08ab1a3538f905f9fed2b3ce1febdeafcb42ce62..61e8b612011a98860dd30c7189f1a6ece6ead078 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "mathjax": "^2.7.5", "ngx-material-file-input": "^1.1.1", "ngx-md": "^7.0.0", + "ngx-webstorage-service": "^4.0.1", "rxjs": "^6.3.3", "rxjs-compat": "^6.3.3", "tslib": "^1.9.0", diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 9f8a8f0693dfed18b2cce9cd550b2f42b43dd1a9..80b386e6632e1e0850e6a04c4529299746006030 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -102,16 +102,12 @@ export class AppComponent implements OnInit, OnDestroy, Observer { ); } + /** + * Triggered at app startup. + * Preferences are loaded by app setup service + * @see ApplicationSetupService.construct() + */ ngOnInit() { - // try to detect the browser's language - const navLang = navigator.language; - try { - this.intlService.setLocale(navLang); - } catch (e) { - console.error(e.toString()); - this.intlService.setLocale(this.appSetupService.language); - } - this.formulaireService.addObserver(this); this.subscribeErrorService(); this._innerWidth = window.innerWidth; diff --git a/src/app/app.module.ts b/src/app/app.module.ts index eb3a4446c091b73920cf88c59b46b19ad2cd47ee..2759ef9f56aad5027654e2911e2c776c4e34f6e5 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -18,6 +18,7 @@ import { MatListModule, MatCardModule, MatTableModule, + MatSnackBarModule, ErrorStateMatcher, MatButtonToggleModule } from "@angular/material"; @@ -36,6 +37,7 @@ import { FormsModule, ReactiveFormsModule } from "@angular/forms"; // <-- NgMode import { ChartModule } from "angular2-chartjs"; import { RouterModule, Routes } from "@angular/router"; import { NgxMdModule } from "ngx-md"; +import { StorageServiceModule } from "ngx-webstorage-service"; import { FormulaireService } from "./services/formulaire/formulaire.service"; import { I18nService } from "./services/internationalisation/internationalisation.service"; @@ -115,6 +117,7 @@ const appRoutes: Routes = [ MatMenuModule, MatSelectModule, MatSidenavModule, + MatSnackBarModule, MatTableModule, MatTabsModule, MatToolbarModule, @@ -126,7 +129,8 @@ const appRoutes: Routes = [ useHash: true, // prevents reloading whole app when typing url in browser's navigation bar enableTracing: false // debugging purposes only } - ) + ), + StorageServiceModule ], declarations: [ // composants, pipes et directives AppComponent, diff --git a/src/app/components/app-setup/app-setup.component.html b/src/app/components/app-setup/app-setup.component.html index bd04a62caf6f8cb32150ac8f2472d7f5848b24c3..955aba34a1371e3f020a7b33c1954818aa6b4a61 100644 --- a/src/app/components/app-setup/app-setup.component.html +++ b/src/app/components/app-setup/app-setup.component.html @@ -6,6 +6,21 @@ <mat-card-title> <h1>{{ uitextTitle }}</h1> </mat-card-title> + + <button mat-icon-button [matMenuTriggerFor]="menu"> + <mat-icon>more_vert</mat-icon> + </button> + + <mat-menu #menu="matMenu"> + <button mat-menu-item (click)="storePreferences()"> + <mat-icon>save_alt</mat-icon> + <span>{{ uitextRememberValues }}</span> + </button> + <button mat-menu-item (click)="restoreDefaultValues()"> + <mat-icon>settings_backup_restore</mat-icon> + <span>{{ uitextRestoreDefault }}</span> + </button> + </mat-menu> </mat-card-header> <mat-card-content> diff --git a/src/app/components/app-setup/app-setup.component.ts b/src/app/components/app-setup/app-setup.component.ts index fc05bd484e5a30caefb9863824b51d4046f4c3fb..a2ada31467568d4fa331bc1f28879d3c0a393f11 100644 --- a/src/app/components/app-setup/app-setup.component.ts +++ b/src/app/components/app-setup/app-setup.component.ts @@ -6,7 +6,7 @@ import { ApplicationSetupService } from "../../services/app-setup/app-setup.serv import { I18nService, LanguageCode } from "../../services/internationalisation/internationalisation.service"; import { NgBaseParam } from "../base-param-input/base-param-input.component"; import { BaseComponent } from "../base/base.component"; -import { ErrorStateMatcher } from "@angular/material"; +import { ErrorStateMatcher, MatSnackBar } from "@angular/material"; @Component({ @@ -29,7 +29,8 @@ export class ApplicationSetupComponent extends BaseComponent implements Observer constructor( private appSetupService: ApplicationSetupService, - private intlService: I18nService + private intlService: I18nService, + private snackBar: MatSnackBar ) { super(); this.appSetupService.addObserver(this); @@ -40,7 +41,9 @@ export class ApplicationSetupComponent extends BaseComponent implements Observer } public get currentLanguageCode() { - return this.intlService.currentLanguage.code; + if (this.intlService.currentLanguage) { + return this.intlService.currentLanguage.code; + } } public set currentLanguageCode(lc: LanguageCode) { @@ -65,10 +68,34 @@ export class ApplicationSetupComponent extends BaseComponent implements Observer return this.intlService.localizeText("INFO_SETUP_NEWTON_MAX_ITER"); } + public get uitextRememberValues(): string { + return this.intlService.localizeText("INFO_MENU_SAVE_SETTINGS"); + } + + public get uitextRestoreDefault(): string { + return this.intlService.localizeText("INFO_MENU_RESTORE_DEFAULT_SETTINGS"); + } + public get uitextMustBeANumber(): string { return this.intlService.localizeText("ERROR_PARAM_MUST_BE_A_NUMBER"); } + public storePreferences() { + this.appSetupService.saveValuesIntoLocalStorage(); + this.snackBar.open(this.intlService.localizeText("INFO_SNACKBAR_SETTINGS_SAVED"), "OK", { + duration: 1200 + }); + } + + public restoreDefaultValues() { + const text = this.intlService.localizeText("INFO_SNACKBAR_DEFAULT_SETTINGS_RESTORED"); + this.appSetupService.restoreDefaultValues().then(() => { + this.snackBar.open(text, "OK", { + duration: 1200 + }); + }); + } + private init() { // modèle du composant BaseParamInputComponent de précision d'affichage this.displayPrec = new NgBaseParam("dp", ParamDomainValue.POS, this.appSetupService.displayPrecision); diff --git a/src/app/components/calculator-list/calculator-list.component.ts b/src/app/components/calculator-list/calculator-list.component.ts index 4a084aa81a7fe2c3b0b96d48a04cb393cd6e75a6..b2c4cdb661c1cf884cda3d1c25631f322424d065 100644 --- a/src/app/components/calculator-list/calculator-list.component.ts +++ b/src/app/components/calculator-list/calculator-list.component.ts @@ -116,6 +116,7 @@ export class CalculatorListComponent implements OnInit { update(sender: any, data: any): void { if (sender instanceof I18nService) { + // reload themes if language changed this.loadCalculatorsThemes(); } } diff --git a/src/app/components/generic-calculator/calculator.component.ts b/src/app/components/generic-calculator/calculator.component.ts index cd527ea3d6a99d9be7056b42315c01a09df91b49..4d9f0fd92fd4077410b378c448a7a5c11843b732 100644 --- a/src/app/components/generic-calculator/calculator.component.ts +++ b/src/app/components/generic-calculator/calculator.component.ts @@ -270,6 +270,7 @@ export class GenericCalculatorComponent extends BaseComponent implements OnInit, update(sender: any, data: any): void { if (sender instanceof I18nService) { + // update display if language changed this.formulaireService.updateLocalisation(); } else if (sender instanceof FormulaireService) { switch (data["action"]) { diff --git a/src/app/services/app-setup/app-setup.service.ts b/src/app/services/app-setup/app-setup.service.ts index 6a6069c637bfdc6986892a5e09ac8833bbb10a21..afde5826c0758b79e5a9cf1f729eeb04b53302e4 100644 --- a/src/app/services/app-setup/app-setup.service.ts +++ b/src/app/services/app-setup/app-setup.service.ts @@ -1,6 +1,7 @@ import { HttpService } from "../http/http.service"; -import { Injectable } from "@angular/core"; +import { Injectable, Inject } from "@angular/core"; import { Observable } from "jalhyd"; +import { StorageService, LOCAL_STORAGE } from "ngx-webstorage-service"; /** * Stores app preferences @@ -9,48 +10,122 @@ import { Observable } from "jalhyd"; export class ApplicationSetupService extends Observable { private CONFIG_FILE_PATH = "app/config.json"; + private LOCAL_STORAGE_PREFIX = "nghyd_"; // default builtin values public displayPrecision = 0.001; public computePrecision = 0.0001; public newtonMaxIterations = 50; - private _language = "fr"; + /** + * just stores the current language preference, does not transmit it to I18nService, that is + * not available here. + * + * @see ApplicationSetupComponent.currentLanguageCode() setter + * @see I18nService.update() observer + */ + public language = "fr"; /** themes to group calculators, for displaying on the front page */ public themes: any[]; public constructor( - private httpService: HttpService + private httpService: HttpService, + @Inject(LOCAL_STORAGE) private storage: StorageService ) { - super(); - this.readValuesFromConfig(); + // precedence: builtin values >> JSON config >> browser's language >> local storage + const builtinLanguage = this.language; + + // load JSON config + this.readValuesFromConfig().then((data) => { + const configLanguage = this.language; + + // guess browser's language + this.language = navigator.language.substring(0, 2); // @TODO clodo trick, check validity + const browserLanguage = this.language; + + // load saved preferences + const loadedPrefKeys = this.readValuesFromLocalStorage(); + let storageLanguage: string; + if (loadedPrefKeys.includes("language")) { + storageLanguage = this.language; + } + + // notify I18nService + this.notifyObservers({ + action: "languagePreferenceChanged", + languages: [ storageLanguage, browserLanguage, configLanguage, builtinLanguage ] + }); + }); } public get displayDigits() { return -Math.log10(this.displayPrecision); } - public set language(lang: string) { - this._language = lang; - this.notifyObservers(null); + /** + * Save configuration values into local storage + */ + public saveValuesIntoLocalStorage() { + this.storage.set(this.LOCAL_STORAGE_PREFIX + "displayPrecision", this.displayPrecision); + this.storage.set(this.LOCAL_STORAGE_PREFIX + "computePrecision", this.computePrecision); + this.storage.set(this.LOCAL_STORAGE_PREFIX + "newtonMaxIterations", this.newtonMaxIterations); + this.storage.set(this.LOCAL_STORAGE_PREFIX + "language", this.language); } - public get language(): string { - return this._language; + /** + * Restore configuration values + */ + public restoreDefaultValues(): Promise<any> { + return this.readValuesFromConfig().then(() => { + // notify I18nService + this.notifyObservers({ + action: "languagePreferenceChanged", + languages: [ this.language ] + }); + }); } - // @TODO save preferences in cookie / localStorage ? + /** + * Read configuration values from local storage + */ + private readValuesFromLocalStorage(): string[] { + const loadedKeys = []; + // get all config values (volontarily non-generic to prevent side-effects) + const displayPrecision = this.storage.get(this.LOCAL_STORAGE_PREFIX + "displayPrecision"); + if (displayPrecision !== undefined) { + this.displayPrecision = displayPrecision; + loadedKeys.push("displayPrecision"); + } + const computePrecision = this.storage.get(this.LOCAL_STORAGE_PREFIX + "computePrecision"); + if (computePrecision !== undefined) { + this.computePrecision = computePrecision; + loadedKeys.push("computePrecision"); + } + const newtonMaxIterations = this.storage.get(this.LOCAL_STORAGE_PREFIX + "newtonMaxIterations"); + if (newtonMaxIterations !== undefined) { + this.newtonMaxIterations = newtonMaxIterations; + loadedKeys.push("newtonMaxIterations"); + } + const language = this.storage.get(this.LOCAL_STORAGE_PREFIX + "language"); + if (language !== undefined) { + this.language = language; + loadedKeys.push("language"); + } + return loadedKeys; + } - // read default values from config and notify observers - private readValuesFromConfig() { - this.httpService.httpGetRequestPromise(this.CONFIG_FILE_PATH).then((data: any) => { + /** + * Read configuration values from config (async) + */ + private readValuesFromConfig(): Promise<any> { + return this.httpService.httpGetRequestPromise(this.CONFIG_FILE_PATH).then((data: any) => { + // get all config values (volontarily non-generic to prevent side-effects) this.displayPrecision = data.params.displayPrecision; this.computePrecision = data.params.computePrecision; this.newtonMaxIterations = data.params.newtonMaxIterations; this.language = data.params.language; + // load themes for calculators list page this.themes = data.themes; - - this.notifyObservers(null); }); } } diff --git a/src/app/services/internationalisation/internationalisation.service.ts b/src/app/services/internationalisation/internationalisation.service.ts index 5eb90bcc6c84752e416a77a2d2184de4e5e33a93..bde70b1443709027e013ba4151fd51c42a79c724 100644 --- a/src/app/services/internationalisation/internationalisation.service.ts +++ b/src/app/services/internationalisation/internationalisation.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@angular/core"; -import { Message, MessageCode, Observable } from "jalhyd"; +import { Message, MessageCode, Observable, Observer } from "jalhyd"; import { StringMap } from "../../stringmap"; import { ApplicationSetupService } from "../app-setup/app-setup.service"; @@ -43,7 +43,7 @@ export class Language { } @Injectable() -export class I18nService extends Observable { +export class I18nService extends Observable implements Observer { private _currLang: Language; private _Messages: StringMap; @@ -57,6 +57,8 @@ export class I18nService extends Observable { this._languages = []; this._languages.push(new Language(LanguageCode.FRENCH, "fr", "Français")); this._languages.push(new Language(LanguageCode.ENGLISH, "en", "English")); + // add language preferences observer + this.applicationSetupService.addObserver(this); } public get languages() { @@ -110,6 +112,7 @@ export class I18nService extends Observable { const is: I18nService = this; prom.then((res) => { + // propagate language change to all application is.notifyObservers(undefined); }); } @@ -231,4 +234,34 @@ export class I18nService extends Observable { return value.toFixed(nDigits); } + // interface Observer + + /** + * Should only be triggered once at app startup, when setup service tries loading language + * @param sender should always be ApplicationSetupService + * @param data object { + * action: should always be "languagePreferenceChanged" + * languages: languages codes to try until one works + * } + */ + public update(sender: any, data: any): void { + if (sender instanceof ApplicationSetupService) { + if (data.action === "languagePreferenceChanged") { + let languageEventuallyUsed: string; + for (let i = 0; i < data.languages.length && languageEventuallyUsed === undefined; i++) { + const l = data.languages[i]; + if (l !== undefined) { + try { + this.setLocale(l); + languageEventuallyUsed = l; + } catch (e) { + console.error(e.toString()); + } + } + } + // confirm to setup service which language was eventually used at app startup (triggers nothing more) + this.applicationSetupService.language = languageEventuallyUsed; + } + } + } } diff --git a/src/locale/messages.en.json b/src/locale/messages.en.json index 4c96c5aa529c5c762ae469348b291a11780edf5b..249f391b9f53741f0f7ad534ef11d34f2cf0d862 100644 --- a/src/locale/messages.en.json +++ b/src/locale/messages.en.json @@ -133,6 +133,8 @@ "INFO_MENU_SAVE_SESSION_TITLE": "Save session", "INFO_MENU_SELECT_CALC": "Select calculator module", "INFO_MENU_EMPTY_SESSION_TITLE": "New session", + "INFO_MENU_SAVE_SETTINGS": "Save settings", + "INFO_MENU_RESTORE_DEFAULT_SETTINGS": "Default settings", "INFO_OPTION_NO": "No", "INFO_OPTION_YES": "Yes", "INFO_OPTION_CANCEL": "Cancel", @@ -197,6 +199,8 @@ "INFO_SETUP_PRECISION_AFFICHAGE": "Display accuracy", "INFO_SETUP_PRECISION_CALCUL": "Computation accuracy", "INFO_SETUP_TITLE": "Application setup", + "INFO_SNACKBAR_SETTINGS_SAVED": "Settings saved on this device", + "INFO_SNACKBAR_DEFAULT_SETTINGS_RESTORED": "Default settings restored", "INFO_THEME_CREDITS": "Credit", "INFO_THEME_MODULES_INUTILISES_TITRE": "Other calculation modules", "INFO_THEME_MODULES_INUTILISES_DESCRIPTION": "Various calculation modules", diff --git a/src/locale/messages.fr.json b/src/locale/messages.fr.json index 69ceb18ec82ee75e9a9173fb7c36863143559722..f3fbdb6bc1f1f0c0ec3667f3400285ad63dda1f5 100644 --- a/src/locale/messages.fr.json +++ b/src/locale/messages.fr.json @@ -133,6 +133,8 @@ "INFO_MENU_NOUVELLE_CALC": "Nouveau module de calcul", "INFO_MACRORUGO_TITRE": "Passe à macro-rugosités", "INFO_MACRORUGO_TITRE_COURT": "Macro-rugo.", + "INFO_MENU_SAVE_SETTINGS": "Enregistrer les paramètres", + "INFO_MENU_RESTORE_DEFAULT_SETTINGS": "Paramètres par défaut", "INFO_OPTION_NO": "Non", "INFO_OPTION_YES": "Oui", "INFO_OPTION_CANCEL": "Annuler", @@ -197,6 +199,8 @@ "INFO_SETUP_PRECISION_AFFICHAGE": "Précision d'affichage", "INFO_SETUP_PRECISION_CALCUL": "Précision de calcul", "INFO_SETUP_TITLE": "Paramètres de l'application", + "INFO_SNACKBAR_SETTINGS_SAVED": "Paramètres enregistrés sur cet appareil", + "INFO_SNACKBAR_DEFAULT_SETTINGS_RESTORED": "Paramètres par défaut restaurés", "INFO_THEME_CREDITS": "Crédit", "INFO_THEME_MODULES_INUTILISES_TITRE": "Autres modules de calcul", "INFO_THEME_MODULES_INUTILISES_DESCRIPTION": "Modules de calculs divers",