From 5fb9e97d624d18b4ffb2cfc60c5536776cf819ae Mon Sep 17 00:00:00 2001
From: "mathias.chouet" <mathias.chouet@irstea.fr>
Date: Wed, 6 Mar 2019 14:43:10 +0100
Subject: [PATCH] Fix #59

---
 package-lock.json                             |   8 ++
 package.json                                  |   1 +
 src/app/app.component.ts                      |  14 +--
 src/app/app.module.ts                         |   6 +-
 .../app-setup/app-setup.component.html        |  15 +++
 .../app-setup/app-setup.component.ts          |  33 +++++-
 .../calculator-list.component.ts              |   1 +
 .../calculator.component.ts                   |   1 +
 .../services/app-setup/app-setup.service.ts   | 107 +++++++++++++++---
 .../internationalisation.service.ts           |  37 +++++-
 src/locale/messages.en.json                   |   4 +
 src/locale/messages.fr.json                   |   4 +
 12 files changed, 200 insertions(+), 31 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index b01f00f9c..5a57c2d81 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 08ab1a353..61e8b6120 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 9f8a8f069..80b386e66 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 eb3a4446c..2759ef9f5 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 bd04a62ca..955aba34a 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 fc05bd484..a2ada3146 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 4a084aa81..b2c4cdb66 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 cd527ea3d..4d9f0fd92 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 6a6069c63..afde5826c 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 5eb90bcc6..bde70b144 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 4c96c5aa5..249f391b9 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 69ceb18ec..f3fbdb6bc 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",
-- 
GitLab