mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-10 03:32:52 +00:00
refactor: initialise settings from config module and persist only the values
Closes #30, #59
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
import {HTTP_INTERCEPTORS, HttpClient,
|
||||
HttpEvent, HttpHandler, HttpInterceptor, HttpRequest, HttpResponse} from '@angular/common/http';
|
||||
import {Injectable} from '@angular/core';
|
||||
import {SCIndexResponse, SCThingType} from '@openstapps/core';
|
||||
import {SCIndexResponse, SCThingOriginType, SCThingType} from '@openstapps/core';
|
||||
import {Observable, of} from 'rxjs';
|
||||
import {map, delay} from 'rxjs/operators';
|
||||
import {SampleThings} from './data/sample-things';
|
||||
@@ -55,7 +55,102 @@ const sampleIndexResponse: SCIndexResponse = {
|
||||
menus: [],
|
||||
name: 'StApps - Technische Universität Berlin',
|
||||
privacyPolicyUrl: 'https://stappsbe01.innocampus.tu-berlin.de/_static/privacy.md',
|
||||
settings: [],
|
||||
settings: [
|
||||
{
|
||||
categories: ['profile'],
|
||||
description: '',
|
||||
input: {
|
||||
defaultValue: 'student',
|
||||
inputType: 'singleChoice',
|
||||
values: ['student', 'employee', 'guest'],
|
||||
},
|
||||
name: 'group',
|
||||
order: 1,
|
||||
origin: {
|
||||
indexed: '2018-09-11T12:30:00Z',
|
||||
name: 'Dummy',
|
||||
type: SCThingOriginType.Remote,
|
||||
},
|
||||
translations: {
|
||||
de: {
|
||||
categories: ['Benutzer'],
|
||||
description: 'Mit welcher Benutzergruppe soll die App verwendet werden?'
|
||||
+ ' Die Einstellung wird beispielsweise für die Vorauswahl der Preiskategorie der Mensa verwendet.',
|
||||
name: 'Gruppe',
|
||||
},
|
||||
en: {
|
||||
categories: ['User'],
|
||||
description: 'The user group the app is going to be used.'
|
||||
+ 'This settings for example is getting used for the predefined price category of mensa meals.',
|
||||
name: 'Group',
|
||||
},
|
||||
},
|
||||
type: SCThingType.Setting,
|
||||
uid: '',
|
||||
},
|
||||
{
|
||||
categories: ['profile'],
|
||||
description: '',
|
||||
input: {
|
||||
defaultValue: 'en',
|
||||
inputType: 'singleChoice',
|
||||
values: ['en', 'de'],
|
||||
},
|
||||
name: 'language',
|
||||
order: 0,
|
||||
origin: {
|
||||
indexed: '2018-09-11T12:30:00Z',
|
||||
name: 'Dummy',
|
||||
type: SCThingOriginType.Remote,
|
||||
},
|
||||
translations: {
|
||||
de: {
|
||||
categories: ['Benutzer'],
|
||||
description: 'Die Sprache in der die App angezeigt werden soll',
|
||||
name: 'Sprache',
|
||||
},
|
||||
en: {
|
||||
categories: ['User'],
|
||||
description: 'The language this app is going to use',
|
||||
name: 'Language',
|
||||
},
|
||||
},
|
||||
type: SCThingType.Setting,
|
||||
uid: '',
|
||||
},
|
||||
{
|
||||
categories: ['privacy'],
|
||||
description: '',
|
||||
input: {
|
||||
defaultValue: false,
|
||||
inputType: 'singleChoice',
|
||||
values: [true, false],
|
||||
},
|
||||
name: 'geoLocation',
|
||||
order: 0,
|
||||
origin: {
|
||||
indexed: '2018-09-11T12:30:00Z',
|
||||
name: 'Dummy',
|
||||
type: SCThingOriginType.Remote,
|
||||
},
|
||||
translations: {
|
||||
de: {
|
||||
categories: ['Privatsphäre'],
|
||||
description: 'Berechtigung für die Verwendung des Ortungsdienstes, für die Anzeige der aktuellen ' +
|
||||
'Position \'\n auf der Karte und zur Berechnung der Entfernung zu Gebäuden und Orten des Campus',
|
||||
name: 'Position',
|
||||
},
|
||||
en: {
|
||||
categories: ['Privacy'],
|
||||
description: 'Allow the App to use the device location to provide additional informationsbased ' +
|
||||
'on your actual location',
|
||||
name: 'Position',
|
||||
},
|
||||
},
|
||||
type: SCThingType.Setting,
|
||||
uid: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
backend: {
|
||||
SCVersion: '1.0.0',
|
||||
|
||||
@@ -72,7 +72,7 @@ export class AppComponent {
|
||||
|
||||
try {
|
||||
// set language from settings
|
||||
const languageCode = await this.settingsProvider.getSettingValue('profile', 'language');
|
||||
const languageCode = await this.settingsProvider.getValue('profile', 'language');
|
||||
this.translateService.use(languageCode);
|
||||
} catch (error) {
|
||||
this.logger.warn(error);
|
||||
|
||||
@@ -13,14 +13,14 @@
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import {Component, Input} from '@angular/core';
|
||||
import {Geolocation} from '@ionic-native/geolocation/ngx';
|
||||
import {AlertController} from '@ionic/angular';
|
||||
import {LangChangeEvent, TranslateService} from '@ngx-translate/core';
|
||||
import {
|
||||
SCSetting,
|
||||
SCSettingMeta,
|
||||
SCThingTranslator,
|
||||
SCTranslations,
|
||||
} from '@openstapps/core';
|
||||
import {Logger} from '@openstapps/logger';
|
||||
import {SettingsProvider} from '../settings.provider';
|
||||
|
||||
@Component({
|
||||
@@ -31,22 +31,21 @@ export class SettingsItemComponent {
|
||||
isVisible = true;
|
||||
// limit to languages that are available in StApps Core
|
||||
language: keyof SCTranslations<any>;
|
||||
meta = SCSettingMeta;
|
||||
|
||||
logger = new Logger();
|
||||
@Input() setting: SCSetting;
|
||||
translator: SCThingTranslator;
|
||||
|
||||
constructor(private alertCtrl: AlertController,
|
||||
private translateService: TranslateService,
|
||||
private settingsProvider: SettingsProvider,
|
||||
private geoLocation: Geolocation) {
|
||||
this.meta = SCSettingMeta;
|
||||
|
||||
private settingsProvider: SettingsProvider) {
|
||||
this.language = translateService.currentLang as keyof SCTranslations<any>;
|
||||
this.translator = new SCThingTranslator(this.language, 'de');
|
||||
|
||||
translateService.onLangChange.subscribe((event: LangChangeEvent) => {
|
||||
this.isVisible = false;
|
||||
this.language = event.lang as keyof SCTranslations<any>;
|
||||
// workaround for selected 'select option' not updating translation
|
||||
this.translator = new SCThingTranslator(this.language, 'de');
|
||||
// TODO: Issue #53 check workaround for selected 'select option' not updating translation
|
||||
setTimeout(() => this.isVisible = true);
|
||||
});
|
||||
}
|
||||
@@ -56,24 +55,11 @@ export class SettingsItemComponent {
|
||||
* if no permission is granted, setting is set to false and an alert is presented to the user
|
||||
*/
|
||||
private async checkGeoLocationPermission() {
|
||||
// request geoLocation to test the user permission
|
||||
try {
|
||||
// set enableHighAccuracy, otherwise android platform does not respond
|
||||
const options = {
|
||||
enableHighAccuracy: true,
|
||||
};
|
||||
await this.geoLocation.getCurrentPosition(options);
|
||||
} catch (error) {
|
||||
// if error code is 1 the user denied permission,
|
||||
// other errors like 'timeout' or 'no location' will be ignored here
|
||||
if (error.code === 1) {
|
||||
// ios has special error message for disabled location services, for the setting we ignore it
|
||||
if (error.message.toLowerCase() !== 'location services are disabled.') {
|
||||
// revert setting value
|
||||
this.setting.input.value = false;
|
||||
await this.presentGeoLocationAlert();
|
||||
}
|
||||
}
|
||||
const permissionGranted = await this.settingsProvider.checkGeoLocationPermission();
|
||||
if (!permissionGranted) {
|
||||
// revert setting value
|
||||
this.setting.input.value = false;
|
||||
await this.presentGeoLocationAlert();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,8 +67,8 @@ export class SettingsItemComponent {
|
||||
* Shows alert with error message on denied user permission or disabled location services
|
||||
*/
|
||||
private async presentGeoLocationAlert() {
|
||||
const title = this.translateService.instant('settings.geoLocation.permission_denied_title');
|
||||
const message = this.translateService.instant('settings.geoLocation.permission_denied_message');
|
||||
const title = await this.translateService.get('settings.geoLocation.permission_denied_title').toPromise();
|
||||
const message = await this.translateService.get('settings.geoLocation.permission_denied_message').toPromise();
|
||||
await this.presentAlert(title, message);
|
||||
}
|
||||
|
||||
@@ -124,7 +110,7 @@ export class SettingsItemComponent {
|
||||
} else {
|
||||
// reset setting
|
||||
this.setting.input.value =
|
||||
await this.settingsProvider.getSettingValue(this.setting.categories[0], this.setting.name);
|
||||
await this.settingsProvider.getValue(this.setting.categories[0], this.setting.name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
<ion-card>
|
||||
<!-- <ion-card-header>
|
||||
<span id="settingTitle" >{{ meta.getFieldValueTranslation(language, 'name', setting) }}</span>
|
||||
</ion-card-header> -->
|
||||
<ion-card-header>
|
||||
<span>{{ translator.translate(setting).name() }}</span>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<!-- <ion-note >{{ meta.getFieldValueTranslation(language, 'description', setting) }}</ion-note> -->
|
||||
<ion-note>{{ translator.translate(setting).description() }}</ion-note>
|
||||
|
||||
<div [ngSwitch]="setting.input.inputType" *ngIf="isVisible" >
|
||||
<ion-item *ngSwitchCase="'toggle'">
|
||||
<ion-label></ion-label>
|
||||
<ion-toggle start [(ngModel)]="setting.input.value" (ionChange)="settingChanged()"></ion-toggle>
|
||||
</ion-item>
|
||||
<ion-item *ngSwitchCase="'number'">
|
||||
<ion-label></ion-label>
|
||||
<ion-input type='number' [(ngModel)]="setting.input.value" value={{setting.input.value}} (ionChange)="settingChanged()"></ion-input>
|
||||
@@ -45,11 +41,6 @@
|
||||
</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
|
||||
<span *ngSwitchDefault>
|
||||
<ion-note>no template for {{ setting.name }}</ion-note>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import {Component} from '@angular/core';
|
||||
import {AlertController, ToastController} from '@ionic/angular';
|
||||
import {LangChangeEvent, TranslateService} from '@ngx-translate/core';
|
||||
import {SCSettingMeta, SCTranslations} from '@openstapps/core';
|
||||
import {SCSettingMeta, SCThingTranslator, SCTranslations} from '@openstapps/core';
|
||||
import {SettingsCache, SettingsProvider} from '../settings.provider';
|
||||
|
||||
@Component({
|
||||
@@ -28,22 +29,81 @@ export class SettingsPageComponent {
|
||||
meta = SCSettingMeta;
|
||||
objectKeys = Object.keys;
|
||||
settingsCache: SettingsCache;
|
||||
translator: SCThingTranslator;
|
||||
|
||||
constructor(public settingsProvider: SettingsProvider,
|
||||
translateService: TranslateService) {
|
||||
constructor(private alertController: AlertController,
|
||||
private settingsProvider: SettingsProvider,
|
||||
private toastController: ToastController,
|
||||
private translateService: TranslateService) {
|
||||
this.language = translateService.currentLang as keyof SCTranslations<any>;
|
||||
this.translator = new SCThingTranslator(this.language, 'de');
|
||||
|
||||
translateService.onLangChange.subscribe((event: LangChangeEvent) => {
|
||||
this.language = event.lang as keyof SCTranslations<any>;
|
||||
this.translator = new SCThingTranslator(this.language, 'de');
|
||||
});
|
||||
this.settingsCache = {};
|
||||
this.categoriesOrder = settingsProvider.getCategoriesOrder();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Presents a Toast with message for settings successful reset
|
||||
*/
|
||||
private async presentSettingsResetToast() {
|
||||
const toast = await this.toastController.create({
|
||||
cssClass: 'text-center',
|
||||
duration: 2000,
|
||||
message: this.translateService.instant('settings.resetToast.message'),
|
||||
});
|
||||
toast.present();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads cache of settings from SettingProvider
|
||||
*/
|
||||
async loadSettings(): Promise<void> {
|
||||
this.settingsCache = await this.settingsProvider.getSettingsCache();
|
||||
this.settingsCache = await this.settingsProvider.getCache();
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.loadSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Presents an alert to the user to reset settings to default values
|
||||
*/
|
||||
async presentResetAlert() {
|
||||
const cancelText = await this.translateService.get('settings.resetAlert.buttonCancel').toPromise();
|
||||
const yesText = await this.translateService.get('settings.resetAlert.buttonYes').toPromise();
|
||||
const title = await this.translateService.get('settings.resetAlert.title').toPromise();
|
||||
const message = await this.translateService.get('settings.resetAlert.message').toPromise();
|
||||
|
||||
const alert = await this.alertController.create({
|
||||
buttons: [
|
||||
{
|
||||
role: 'cancel',
|
||||
text: cancelText,
|
||||
},
|
||||
{
|
||||
handler: async () => {
|
||||
await this.resetSettings();
|
||||
},
|
||||
text: yesText,
|
||||
},
|
||||
],
|
||||
header: title,
|
||||
message: message,
|
||||
});
|
||||
alert.present();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets all settings to default values
|
||||
*/
|
||||
async resetSettings() {
|
||||
await this.settingsProvider.resetDefault();
|
||||
await this.loadSettings();
|
||||
await this.presentSettingsResetToast();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button></ion-back-button>
|
||||
<ion-menu-button></ion-menu-button>
|
||||
</ion-buttons>
|
||||
<ion-title ><div id="title" > {{'settings.title' | translate}}</div></ion-title>
|
||||
<ion-title>{{'settings.title' | translate}}</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content >
|
||||
<ion-content>
|
||||
<ion-list *ngFor="let categoryKey of categoriesOrder ">
|
||||
<div *ngIf="objectKeys(settingsCache).includes(categoryKey)">
|
||||
<ion-item-divider><h5>{{ meta.getFieldValueTranslation(language, 'categories',
|
||||
settingsCache[categoryKey].settings[objectKeys(settingsCache[categoryKey].settings)[0]]) }}</h5></ion-item-divider>
|
||||
<ion-item-divider>
|
||||
<h5>{{translator.translate(settingsCache[categoryKey].settings[objectKeys(settingsCache[categoryKey].settings)[0]]).categories()[0]}}
|
||||
</h5>
|
||||
</ion-item-divider>
|
||||
<stapps-settings-item *ngFor="let settingKeys of objectKeys(settingsCache[categoryKey].settings)" [setting]="settingsCache[categoryKey].settings[settingKeys]"></stapps-settings-item>
|
||||
</div>
|
||||
</ion-list>
|
||||
<ion-button color="medium" expand="block" fill="outline" (click)="presentResetAlert()">
|
||||
{{'settings.resetSettings' | translate}}
|
||||
<ion-icon slot="start" name="undo"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-content>
|
||||
|
||||
@@ -20,12 +20,13 @@ import {Geolocation} from '@ionic-native/geolocation/ngx';
|
||||
import {IonicModule} from '@ionic/angular';
|
||||
import {TranslateModule} from '@ngx-translate/core';
|
||||
|
||||
import {ConfigProvider} from '../config/config.provider';
|
||||
import {SettingsItemComponent} from './item/settings-item.component';
|
||||
import {SettingsPageComponent} from './page/settings-page.component';
|
||||
import {SettingsProvider} from './settings.provider';
|
||||
|
||||
const settingsRoutes: Routes = [
|
||||
{ path: 'settings', component: SettingsPageComponent },
|
||||
{path: 'settings', component: SettingsPageComponent},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
@@ -41,6 +42,7 @@ const settingsRoutes: Routes = [
|
||||
RouterModule.forChild(settingsRoutes),
|
||||
],
|
||||
providers: [
|
||||
ConfigProvider,
|
||||
Geolocation,
|
||||
SettingsProvider,
|
||||
],
|
||||
|
||||
@@ -14,123 +14,165 @@
|
||||
*/
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {SCSetting, SCThingOriginType, SCThingType} from '@openstapps/core';
|
||||
import {StorageModule} from '../storage/storage.module';
|
||||
import {ConfigProvider} from '../config/config.provider';
|
||||
import {StorageProvider} from '../storage/storage.provider';
|
||||
import {SettingsProvider} from './settings.provider';
|
||||
import {SettingsProvider, SettingValuesContainer, STORAGE_KEY_SETTING_VALUES} from './settings.provider';
|
||||
import {Geolocation} from '@ionic-native/geolocation/ngx';
|
||||
|
||||
describe('SettingsProvider', () => {
|
||||
let configProviderSpy: jasmine.SpyObj<ConfigProvider>;
|
||||
let settingsProvider: SettingsProvider;
|
||||
let storageModule: StorageProvider;
|
||||
let storageProviderSpy: jasmine.SpyObj<StorageProvider>;
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [StorageModule],
|
||||
providers: [SettingsProvider, StorageProvider],
|
||||
});
|
||||
settingsProvider = TestBed.get(SettingsProvider);
|
||||
storageModule = TestBed.get(StorageProvider);
|
||||
const storageProviderMethodSpy = jasmine.createSpyObj('StorageProvider', ['init', 'get', 'has', 'put']);
|
||||
const configProviderMethodSpy = jasmine.createSpyObj('ConfigProvider', ['getValue']);
|
||||
|
||||
settingsProvider.clear();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [],
|
||||
providers: [
|
||||
SettingsProvider,
|
||||
{
|
||||
provide: StorageProvider, useValue: storageProviderMethodSpy,
|
||||
},
|
||||
{
|
||||
provide: ConfigProvider, useValue: configProviderMethodSpy,
|
||||
},
|
||||
Geolocation,
|
||||
],
|
||||
});
|
||||
configProviderSpy = TestBed.get(ConfigProvider);
|
||||
// set settings returned from config
|
||||
configProviderSpy.getValue.and.returnValue(Promise.resolve(CONFIG_SETTINGS_MOCK));
|
||||
settingsProvider = TestBed.get(SettingsProvider);
|
||||
storageProviderSpy = TestBed.get(StorageProvider);
|
||||
storageProviderMethodSpy.has.and.returnValue(false);
|
||||
});
|
||||
|
||||
it('should provide and get setting', async () => {
|
||||
await settingsProvider.provideSetting(JSON.parse(JSON.stringify(SETTING_MOCKS[0])));
|
||||
const setting = await settingsProvider.getSetting(SETTING_MOCKS[0].categories[0], SETTING_MOCKS[0].name);
|
||||
await expect(setting).toBeDefined();
|
||||
await settingsProvider.provideSetting(JSON.parse(JSON.stringify(CONFIG_SETTINGS_MOCK[0])));
|
||||
const setting: SCSetting = await settingsProvider
|
||||
.getSetting(CONFIG_SETTINGS_MOCK[0].categories[0], CONFIG_SETTINGS_MOCK[0].name);
|
||||
await expect(setting.input.value).toBeDefined();
|
||||
});
|
||||
|
||||
it('should provide and get settings value', async () => {
|
||||
await settingsProvider.provideSetting(JSON.parse(JSON.stringify(SETTING_MOCKS[0])));
|
||||
const value = await settingsProvider.getSettingValue(SETTING_MOCKS[0].categories[0], SETTING_MOCKS[0].name);
|
||||
await expect(value).toBeDefined();
|
||||
await settingsProvider.provideSetting(JSON.parse(JSON.stringify(CONFIG_SETTINGS_MOCK[0])));
|
||||
const value = await settingsProvider
|
||||
.getValue(CONFIG_SETTINGS_MOCK[0].categories[0], CONFIG_SETTINGS_MOCK[0].name);
|
||||
await expect(value).toEqual(CONFIG_SETTINGS_MOCK[0].input.defaultValue);
|
||||
});
|
||||
|
||||
it('should get persisted setting value', async () => {
|
||||
// set return values of storage
|
||||
storageProviderSpy.has.and.returnValue(Promise.resolve(true));
|
||||
storageProviderSpy.get.and.returnValue(Promise.resolve(SETTING_VALUES_MOCK));
|
||||
|
||||
const value = await settingsProvider
|
||||
.getValue(CONFIG_SETTINGS_MOCK[3].categories[0], CONFIG_SETTINGS_MOCK[3].name);
|
||||
await expect(value).toEqual(SETTING_VALUES_MOCK.profile.group);
|
||||
});
|
||||
|
||||
it('should set default setting value if no persisted value exist', async () => {
|
||||
// set return values of spy objects
|
||||
storageProviderSpy.has.and.returnValue(Promise.resolve(true));
|
||||
storageProviderSpy.get.and.returnValue(Promise.resolve([]));
|
||||
const value = await settingsProvider
|
||||
.getValue(CONFIG_SETTINGS_MOCK[3].categories[0], CONFIG_SETTINGS_MOCK[3].name);
|
||||
await expect(value).toEqual(CONFIG_SETTINGS_MOCK[3].input.defaultValue);
|
||||
});
|
||||
|
||||
it('should keep persisted setting values from settings that are not contained in loaded config', async () => {
|
||||
const settings = [
|
||||
CONFIG_SETTINGS_MOCK[4],
|
||||
CONFIG_SETTINGS_MOCK[5],
|
||||
];
|
||||
configProviderSpy.getValue.and.returnValue(Promise.resolve(settings));
|
||||
storageProviderSpy.has.and.returnValue(Promise.resolve(true));
|
||||
storageProviderSpy.get.and.returnValue(Promise.resolve(SETTING_VALUES_MOCK));
|
||||
await settingsProvider.init();
|
||||
await expect(storageProviderSpy.put).toHaveBeenCalledWith(STORAGE_KEY_SETTING_VALUES, SETTING_VALUES_MOCK);
|
||||
});
|
||||
|
||||
it('should set value of a provided setting', async () => {
|
||||
await settingsProvider.provideSetting(JSON.parse(JSON.stringify(SETTING_MOCKS[0])));
|
||||
await settingsProvider.setSettingValue(SETTING_MOCKS[0].categories[0], SETTING_MOCKS[0].name, 'updated');
|
||||
const value = await settingsProvider.getSettingValue(SETTING_MOCKS[0].categories[0], SETTING_MOCKS[0].name);
|
||||
await settingsProvider.provideSetting(JSON.parse(JSON.stringify(CONFIG_SETTINGS_MOCK[1])));
|
||||
await settingsProvider
|
||||
.setSettingValue(CONFIG_SETTINGS_MOCK[1].categories[0], CONFIG_SETTINGS_MOCK[1].name, 'updated');
|
||||
const value = await settingsProvider
|
||||
.getValue(CONFIG_SETTINGS_MOCK[1].categories[0], CONFIG_SETTINGS_MOCK[1].name);
|
||||
await expect(value).toEqual('updated');
|
||||
});
|
||||
|
||||
it('should return copy of settingsCache', async () => {
|
||||
const category = SETTING_MOCKS[0].categories[0];
|
||||
const name = SETTING_MOCKS[0].name;
|
||||
await settingsProvider.provideSetting(JSON.parse(JSON.stringify(SETTING_MOCKS[0])));
|
||||
const settings = await settingsProvider.getSettingsCache();
|
||||
const category = CONFIG_SETTINGS_MOCK[0].categories[0];
|
||||
const name = CONFIG_SETTINGS_MOCK[0].name;
|
||||
await settingsProvider.provideSetting(JSON.parse(JSON.stringify(CONFIG_SETTINGS_MOCK[0])));
|
||||
const settings = await settingsProvider.getCache();
|
||||
settings[category].settings[name].input.value = 'testValue';
|
||||
// cached setting value should still be defaultValue
|
||||
await expect((await settingsProvider.getSettingValue(category, name)))
|
||||
.toEqual(SETTING_MOCKS[0].input.defaultValue);
|
||||
});
|
||||
|
||||
it('should call storage put on provideSetting', async () => {
|
||||
spyOn(storageModule, 'put');
|
||||
await settingsProvider.provideSetting(JSON.parse(JSON.stringify(SETTING_MOCKS[0])));
|
||||
await expect(storageModule.put).toHaveBeenCalled();
|
||||
await expect((await settingsProvider.getValue(category, name)))
|
||||
.toEqual(CONFIG_SETTINGS_MOCK[0].input.defaultValue);
|
||||
});
|
||||
|
||||
it('should call storage put on setSettingValue', async () => {
|
||||
await settingsProvider.provideSetting(JSON.parse(JSON.stringify(SETTING_MOCKS[0])));
|
||||
spyOn(storageModule, 'put');
|
||||
await settingsProvider.setSettingValue(SETTING_MOCKS[0].categories[0], SETTING_MOCKS[0].name, '');
|
||||
await expect(storageModule.put).toHaveBeenCalled();
|
||||
await settingsProvider.provideSetting(JSON.parse(JSON.stringify(CONFIG_SETTINGS_MOCK[0])));
|
||||
await settingsProvider
|
||||
.setSettingValue(CONFIG_SETTINGS_MOCK[0].categories[0], CONFIG_SETTINGS_MOCK[0].name, '');
|
||||
await expect(storageProviderSpy.put).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear settings', async () => {
|
||||
const category = SETTING_MOCKS[0].categories[0];
|
||||
const name = SETTING_MOCKS[0].name;
|
||||
await settingsProvider.provideSetting(JSON.parse(JSON.stringify(SETTING_MOCKS[0])));
|
||||
await settingsProvider.clear();
|
||||
const exists = await settingsProvider.settingExists(category, name);
|
||||
await expect(exists).toEqual(false);
|
||||
await settingsProvider.reset();
|
||||
await expect(storageProviderSpy.put).toHaveBeenCalledWith(STORAGE_KEY_SETTING_VALUES, {});
|
||||
});
|
||||
|
||||
it('should reset settings', async () => {
|
||||
const category = SETTING_MOCKS[0].categories[0];
|
||||
const name = SETTING_MOCKS[0].name;
|
||||
await settingsProvider.provideSetting(JSON.parse(JSON.stringify(SETTING_MOCKS[0])));
|
||||
const category = CONFIG_SETTINGS_MOCK[0].categories[0];
|
||||
const name = CONFIG_SETTINGS_MOCK[0].name;
|
||||
await settingsProvider.provideSetting(JSON.parse(JSON.stringify(CONFIG_SETTINGS_MOCK[0])));
|
||||
await settingsProvider.setSettingValue(category, name, 'guest');
|
||||
await settingsProvider.resetDefault();
|
||||
const value = await settingsProvider.getSettingValue(SETTING_MOCKS[0].categories[0], SETTING_MOCKS[0].name);
|
||||
await expect(value).toEqual(SETTING_MOCKS[0].input.defaultValue);
|
||||
const value = await settingsProvider
|
||||
.getValue(CONFIG_SETTINGS_MOCK[0].categories[0], CONFIG_SETTINGS_MOCK[0].name);
|
||||
await expect(value).toEqual(CONFIG_SETTINGS_MOCK[0].input.defaultValue);
|
||||
});
|
||||
|
||||
it('should validate wrong values for inputType text', async () => {
|
||||
await testValue(SETTING_MOCKS[0], 123456789);
|
||||
await testValue(SETTING_MOCKS[0], false);
|
||||
await testValue(SETTING_MOCKS[0], []);
|
||||
await testValue(CONFIG_SETTINGS_MOCK[0], 123456789);
|
||||
await testValue(CONFIG_SETTINGS_MOCK[0], false);
|
||||
await testValue(CONFIG_SETTINGS_MOCK[0], []);
|
||||
});
|
||||
|
||||
it('should validate wrong values for inputType password', async () => {
|
||||
await testValue(SETTING_MOCKS[0], 123456789);
|
||||
await testValue(SETTING_MOCKS[0], false);
|
||||
await testValue(SETTING_MOCKS[0], []);
|
||||
await testValue(CONFIG_SETTINGS_MOCK[0], 123456789);
|
||||
await testValue(CONFIG_SETTINGS_MOCK[0], false);
|
||||
await testValue(CONFIG_SETTINGS_MOCK[0], []);
|
||||
});
|
||||
|
||||
it('should validate wrong values for inputType number', async () => {
|
||||
await testValue(SETTING_MOCKS[2], '');
|
||||
await testValue(SETTING_MOCKS[2], false);
|
||||
await testValue(SETTING_MOCKS[2], []);
|
||||
await testValue(CONFIG_SETTINGS_MOCK[2], '');
|
||||
await testValue(CONFIG_SETTINGS_MOCK[2], false);
|
||||
await testValue(CONFIG_SETTINGS_MOCK[2], []);
|
||||
});
|
||||
|
||||
it('should validate wrong values for inputType singleChoice text', async () => {
|
||||
await testValue(SETTING_MOCKS[3], '');
|
||||
await testValue(SETTING_MOCKS[3], 123456);
|
||||
await testValue(SETTING_MOCKS[3], false);
|
||||
await testValue(SETTING_MOCKS[3], []);
|
||||
await testValue(CONFIG_SETTINGS_MOCK[3], '');
|
||||
await testValue(CONFIG_SETTINGS_MOCK[3], 123456);
|
||||
await testValue(CONFIG_SETTINGS_MOCK[3], false);
|
||||
await testValue(CONFIG_SETTINGS_MOCK[3], []);
|
||||
});
|
||||
|
||||
it('should validate wrong values for inputType singleChoice boolean', async () => {
|
||||
await testValue(SETTING_MOCKS[5], '');
|
||||
await testValue(SETTING_MOCKS[5], 123456);
|
||||
await testValue(SETTING_MOCKS[5], []);
|
||||
await testValue(CONFIG_SETTINGS_MOCK[5], '');
|
||||
await testValue(CONFIG_SETTINGS_MOCK[5], 123456);
|
||||
await testValue(CONFIG_SETTINGS_MOCK[5], []);
|
||||
});
|
||||
|
||||
it('should validate wrong values for inputType multipleChoice', async () => {
|
||||
await testValue(SETTING_MOCKS[6], '');
|
||||
await testValue(SETTING_MOCKS[6], 123456);
|
||||
await testValue(SETTING_MOCKS[6], false);
|
||||
await testValue(SETTING_MOCKS[6], [1, 2, 3, 4]);
|
||||
await testValue(CONFIG_SETTINGS_MOCK[6], '');
|
||||
await testValue(CONFIG_SETTINGS_MOCK[6], 123456);
|
||||
await testValue(CONFIG_SETTINGS_MOCK[6], false);
|
||||
await testValue(CONFIG_SETTINGS_MOCK[6], [1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
async function testValue(setting: SCSetting, value: any) {
|
||||
@@ -144,10 +186,10 @@ describe('SettingsProvider', () => {
|
||||
// @ts-ignore
|
||||
await expect(error).toBeDefined();
|
||||
// @ts-ignore
|
||||
await expect(error.message).toMatch(/Value.*not valid/);
|
||||
await expect(error.message).toMatch(/is not valid/);
|
||||
}
|
||||
|
||||
const SETTING_MOCKS: SCSetting[] = [
|
||||
const CONFIG_SETTINGS_MOCK: SCSetting[] = [
|
||||
{
|
||||
categories: ['credentials'],
|
||||
input: {
|
||||
@@ -354,3 +396,16 @@ describe('SettingsProvider', () => {
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const SETTING_VALUES_MOCK: SettingValuesContainer = {
|
||||
foo: {
|
||||
bar: 'foo-bar',
|
||||
},
|
||||
privacy: {
|
||||
geoLocation: 'true',
|
||||
},
|
||||
profile: {
|
||||
group: 'employee',
|
||||
language: 'de',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (C) 2018 StApps
|
||||
* Copyright (C) 2018, 2019 StApps
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, version 3.
|
||||
@@ -13,7 +13,16 @@
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import {Injectable} from '@angular/core';
|
||||
import {SCSetting, SCSettingMultipleChoice, SCSettingSingleChoice, SCSettingValue} from '@openstapps/core';
|
||||
import {Geolocation} from '@ionic-native/geolocation/ngx';
|
||||
import {
|
||||
SCSetting,
|
||||
SCSettingMultipleChoice,
|
||||
SCSettingSingleChoice,
|
||||
SCSettingValue,
|
||||
} from '@openstapps/core';
|
||||
import {Logger} from '@openstapps/logger';
|
||||
import * as deepMerge from 'deepmerge';
|
||||
import {ConfigProvider} from '../config/config.provider';
|
||||
import {StorageProvider} from '../storage/storage.provider';
|
||||
|
||||
export const STORAGE_KEY_SETTINGS = 'settings';
|
||||
@@ -35,14 +44,29 @@ export interface SettingsCache {
|
||||
[key: string]: CategoryWithSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structure with categories and its setting valueContainers for persistence
|
||||
*/
|
||||
export interface SettingValuesContainer {
|
||||
[key: string]: SettingValueContainer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structure of a setting and its value
|
||||
*/
|
||||
export interface SettingValueContainer {
|
||||
[key: string]: SCSettingValue | SCSettingValue[] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider for app settings
|
||||
*/
|
||||
@Injectable()
|
||||
export class SettingsProvider {
|
||||
private categoriesOrder: string[];
|
||||
private initialized = false;
|
||||
private settingsCache: SettingsCache;
|
||||
categoriesOrder: string[];
|
||||
initialized = false;
|
||||
logger = new Logger();
|
||||
settingsCache: SettingsCache;
|
||||
|
||||
/**
|
||||
* Return true if all given values are valid to possible values in given settingInput
|
||||
@@ -102,17 +126,56 @@ export class SettingsProvider {
|
||||
return isValueValid;
|
||||
}
|
||||
|
||||
constructor(public storage: StorageProvider) {
|
||||
constructor(private storage: StorageProvider,
|
||||
private configProvider: ConfigProvider,
|
||||
private geoLocation: Geolocation) {
|
||||
this.categoriesOrder = [];
|
||||
this.settingsCache = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes settings cache from storage
|
||||
* Add an Setting to the Cache if not exist and set undefined value to defaultValue
|
||||
* @param setting
|
||||
*/
|
||||
private async addSetting(setting: SCSetting): Promise<void> {
|
||||
if (!this.categoryExists(setting.categories[0])) {
|
||||
await this.provideCategory(setting.categories[0]);
|
||||
}
|
||||
if (!this.settingExists(setting.categories[0], setting.name)) {
|
||||
if (setting.input.value === undefined) {
|
||||
setting.input.value = setting.input.defaultValue;
|
||||
}
|
||||
this.settingsCache[setting.categories[0]].settings[setting.name] = setting;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all setting values from settingsCache in a SettingsValueContainer
|
||||
*/
|
||||
private getSettingValuesFromCache(): SettingValuesContainer {
|
||||
const settingValuesContainer: SettingValuesContainer = {};
|
||||
// iterate through keys of categories
|
||||
for (const categoryKey of Object.keys(this.settingsCache)) {
|
||||
// iterate through keys of settingValueContainer
|
||||
for (const settingKey of Object.keys(this.settingsCache[categoryKey].settings)) {
|
||||
if (typeof settingValuesContainer[categoryKey] === 'undefined') {
|
||||
settingValuesContainer[categoryKey] = {};
|
||||
}
|
||||
settingValuesContainer[categoryKey][settingKey] =
|
||||
this.settingsCache[categoryKey].settings[settingKey].input.value;
|
||||
}
|
||||
}
|
||||
return settingValuesContainer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes settings from config and stored values if exist
|
||||
*/
|
||||
private async initSettings(): Promise<void> {
|
||||
try {
|
||||
this.settingsCache = await this.storage.get<SettingsCache>(STORAGE_KEY_SETTING_VALUES);
|
||||
const settings: SCSetting[] = (await this.configProvider.getValue('settings')) as SCSetting[];
|
||||
settings.forEach((setting) => this.addSetting(setting));
|
||||
|
||||
for (const category of Object.keys(this.settingsCache)) {
|
||||
if (!this.categoriesOrder.includes(category)) {
|
||||
this.categoriesOrder.push(category);
|
||||
@@ -121,7 +184,29 @@ export class SettingsProvider {
|
||||
} catch (error) {
|
||||
this.settingsCache = {};
|
||||
}
|
||||
await this.saveSettings();
|
||||
|
||||
if (await this.storage.has(STORAGE_KEY_SETTING_VALUES)) {
|
||||
// get setting values from StorageProvider into settingsCache
|
||||
const valuesContainer: SettingValuesContainer =
|
||||
await this.storage.get<SettingValuesContainer>(STORAGE_KEY_SETTING_VALUES);
|
||||
// iterate through keys of categories
|
||||
for (const categoryKey of Object.keys(this.settingsCache)) {
|
||||
// iterate through setting keys of category
|
||||
for (const settingKey of Object.keys(this.settingsCache[categoryKey].settings)) {
|
||||
// if saved setting value exists set it, otherwise set to default value
|
||||
if (typeof valuesContainer[categoryKey] !== 'undefined'
|
||||
&& typeof valuesContainer[categoryKey][settingKey] !== 'undefined') {
|
||||
this.settingsCache[categoryKey].settings[settingKey].input.value =
|
||||
valuesContainer[categoryKey][settingKey];
|
||||
} else {
|
||||
this.settingsCache[categoryKey].settings[settingKey].input.value =
|
||||
this.settingsCache[categoryKey].settings[settingKey].input.defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.saveSettingValues();
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
@@ -150,16 +235,40 @@ export class SettingsProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all provided settings
|
||||
* Checks for user permission to use location
|
||||
*/
|
||||
public async clear(): Promise<void> {
|
||||
await this.init();
|
||||
this.settingsCache = {};
|
||||
await this.saveSettings();
|
||||
public async checkGeoLocationPermission(): Promise<boolean> {
|
||||
// request geoLocation to test the user permission
|
||||
try {
|
||||
// set enableHighAccuracy, otherwise android platform does not respond
|
||||
const options = {
|
||||
enableHighAccuracy: true,
|
||||
};
|
||||
await this.geoLocation.getCurrentPosition(options);
|
||||
} catch (error) {
|
||||
// if error code is 1 the user denied permission,
|
||||
// other errors like 'timeout' or 'no location' will be ignored here
|
||||
if (error.code === 1) {
|
||||
// ios has special error message for disabled location services, for the setting we ignore it
|
||||
if (error.message.toLowerCase() !== 'location services are disabled.') {
|
||||
// revert setting value
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* returns an array with the order of categories
|
||||
* Returns copy of cached settings
|
||||
*/
|
||||
public async getCache(): Promise<SettingsCache> {
|
||||
await this.init();
|
||||
return JSON.parse(JSON.stringify(this.settingsCache));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array with the order of categories
|
||||
*/
|
||||
public getCategoriesOrder(): string[] {
|
||||
return this.categoriesOrder;
|
||||
@@ -175,33 +284,27 @@ export class SettingsProvider {
|
||||
public async getSetting(category: string, name: string): Promise<any> {
|
||||
await this.init();
|
||||
if (this.settingExists(category, name)) {
|
||||
// return a copy of the settings
|
||||
return JSON.parse(JSON.stringify(this.settingsCache[category].settings[name]));
|
||||
} else {
|
||||
throw new Error('Setting "' + name + '" not provided');
|
||||
throw new Error(`Setting "${name}" not provided`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns copy of cached settings
|
||||
*/
|
||||
public async getSettingsCache(): Promise<SettingsCache> {
|
||||
await this.init();
|
||||
return JSON.parse(JSON.stringify(this.settingsCache));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns copy of a setting if exist
|
||||
* Returns copy of a settings value if exist
|
||||
* @param category the category of requested setting
|
||||
* @param name the name of requested setting
|
||||
*
|
||||
* @throws Exception if setting is not provided
|
||||
*/
|
||||
public async getSettingValue(category: string, name: string): Promise<any> {
|
||||
public async getValue(category: string, name: string): Promise<any> {
|
||||
await this.init();
|
||||
if (this.settingExists(category, name)) {
|
||||
// return a copy of the settings value
|
||||
return JSON.parse(JSON.stringify(this.settingsCache[category].settings[name].input.value));
|
||||
} else {
|
||||
throw new Error('Setting "' + name + '" not provided');
|
||||
throw new Error(`Setting "${name}" not provided`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,37 +323,44 @@ export class SettingsProvider {
|
||||
*/
|
||||
public async provideSetting(setting: SCSetting): Promise<void> {
|
||||
await this.init();
|
||||
if (!this.categoryExists(setting.categories[0])) {
|
||||
await this.provideCategory(setting.categories[0]);
|
||||
}
|
||||
if (!this.settingExists(setting.categories[0], setting.name)) {
|
||||
// set value to default
|
||||
if (setting.input.value === undefined) {
|
||||
setting.input.value = setting.input.defaultValue;
|
||||
}
|
||||
this.settingsCache[setting.categories[0]].settings[setting.name] = setting;
|
||||
await this.saveSettings();
|
||||
}
|
||||
await this.addSetting(setting);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes saved values and reinitialising the settings
|
||||
*/
|
||||
public async reset(): Promise<void> {
|
||||
await this.storage.put(STORAGE_KEY_SETTING_VALUES, {});
|
||||
await this.initSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets values of all settings to defaultValue
|
||||
*/
|
||||
public async resetDefault(): Promise<void> {
|
||||
await this.init();
|
||||
async resetDefault(): Promise<void> {
|
||||
for (const catKey of Object.keys(this.settingsCache)) {
|
||||
for (const settingKey of Object.keys(this.settingsCache[catKey].settings)) {
|
||||
const settingInput = this.settingsCache[catKey].settings[settingKey].input;
|
||||
settingInput.value = settingInput.defaultValue;
|
||||
}
|
||||
}
|
||||
await this.saveSettingValues();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves cached settings in app storage
|
||||
*/
|
||||
public async saveSettings(): Promise<void> {
|
||||
await this.storage.put(STORAGE_KEY_SETTING_VALUES, this.settingsCache);
|
||||
public async saveSettingValues(): Promise<void> {
|
||||
if (await this.storage.has(STORAGE_KEY_SETTING_VALUES)) {
|
||||
const savedSettingsValues: SettingValuesContainer =
|
||||
await this.storage.get<SettingValuesContainer>(STORAGE_KEY_SETTING_VALUES);
|
||||
const cacheSettingsValues = this.getSettingValuesFromCache();
|
||||
const mergedSettingValues = deepMerge(savedSettingsValues, cacheSettingsValues);
|
||||
await this.storage
|
||||
.put<SettingValuesContainer>(STORAGE_KEY_SETTING_VALUES, mergedSettingValues);
|
||||
} else {
|
||||
await this.storage.put<SettingValuesContainer>(STORAGE_KEY_SETTING_VALUES, this.getSettingValuesFromCache());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -269,20 +379,21 @@ export class SettingsProvider {
|
||||
*
|
||||
* @throws Exception if setting is not provided or value not valid to the settings inputType
|
||||
*/
|
||||
public async setSettingValue(category: string, name: string, value: any): Promise<void> {
|
||||
public async setSettingValue(category: string, name: string,
|
||||
value: any): Promise<void> {
|
||||
await this.init();
|
||||
if (this.settingExists(category, name)) {
|
||||
const setting: SCSetting = this.settingsCache[category].settings[name];
|
||||
const isValueValid = SettingsProvider.validateValue(setting, value);
|
||||
if (isValueValid) {
|
||||
this.settingsCache[category].settings[name].input.value = value;
|
||||
await this.saveSettings();
|
||||
await this.saveSettingValues();
|
||||
} else {
|
||||
throw new Error('Value "' + value + '" of type ' +
|
||||
typeof value + ' is not valid for ' + setting.input.inputType);
|
||||
}
|
||||
throw new Error(`Value "${value}" of type
|
||||
${typeof value} is not valid for ${setting.input.inputType}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error('setting ' + name + ' is not provided');
|
||||
throw new Error(`setting ${name} is not provided`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,10 +16,16 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"resetAlert.title": "Alle Einstellungen zurücksetzen?",
|
||||
"resetAlert.message": "Sind Sie sich sicher, alle Einstellungen auf ihre Anfangswerte zurückzusetzen?",
|
||||
"resetAlert.buttonYes": "Ja",
|
||||
"resetAlert.buttonCancel": "Abbrechen",
|
||||
"resetToast.message": "Einstellungen wurden zurückgesetzt",
|
||||
"title": "Einstellungen",
|
||||
"geoLocation": {
|
||||
"permission_denied_title": "Erlaubnis für Ortungsdienst nicht gegeben",
|
||||
"permission_denied_message": "Erlaube der App die Nutzung des Ortungsdienstes, um diese Funktion zu aktivieren."
|
||||
}
|
||||
},
|
||||
"resetSettings": "Einstellungen zurücksetzen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,16 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"resetAlert.title": "Reset all settings?",
|
||||
"resetAlert.message": "Are you shure to reset all settings to defaults values?",
|
||||
"resetAlert.buttonYes": "Yes",
|
||||
"resetAlert.buttonCancel": "Cancel",
|
||||
"resetToast.message": "Settings reset",
|
||||
"title": "Settings",
|
||||
"geoLocation": {
|
||||
"permission_denied_title": "Location permission not granted",
|
||||
"permission_denied_message": "Allow this app to use location services to activate this feature."
|
||||
}
|
||||
},
|
||||
"resetSettings": "Reset Settings"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user