feat: splash transition

refactor: require app reload for setting changes

feat: require reload on setting changes

feat: new logo

feat: update to capacitor 5

feat: new logo

feat: update to capacitor 5

refactor: simplify settings provider
This commit is contained in:
2023-09-20 15:50:22 +02:00
committed by Thea Schöbl
parent 63a38e0077
commit 9c30211ba2
77 changed files with 671 additions and 1498 deletions

View File

@@ -0,0 +1,9 @@
---
'@openstapps/app': minor
---
Require full reload for setting & language changes
Setting changes are relatively rare, so it makes little sense
going through the effort of ensuring everything is reactive to
language changes as well as creating all the pipe bindings etc.

View File

@@ -1,4 +1,4 @@
# Open StApps Monorepo
# <img src="logo-bg.svg" height="24"> Open StApps Monorepo
Refer to the [contribution guide](./CONTRIBUTING.md)

View File

@@ -93,6 +93,7 @@
"cordova-plugin-calendar": "5.1.6",
"date-fns": "2.30.0",
"deepmerge": "4.3.1",
"fast-deep-equal": "3.1.3",
"form-data": "4.0.0",
"geojson": "0.5.0",
"ionic-appauth": "0.9.0",

View File

@@ -0,0 +1,80 @@
import {Animation, AnimationController} from '@ionic/angular';
import {iosDuration, iosEasing, mdDuration, mdEasing} from './easings';
/**
* Splash screen animation
*/
export function splashAnimation(animationCtl: AnimationController): Animation {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
return animationCtl
.create()
.fromTo('opacity', 0, 1)
.duration(150)
.beforeClearStyles(['visibility'])
.addElement(document.querySelector('ion-app')!);
}
const isMd = document.querySelector('ion-app.md') !== null;
const navElement = document.querySelector('stapps-navigation-tabs')!;
const navBounds = navElement.getBoundingClientRect();
let horizontal = navBounds.width < navBounds.height;
if (window.getComputedStyle(navElement).display === 'none') {
horizontal = true;
}
const translate = (amount: number, unit = 'px') =>
`translate${horizontal ? 'X' : 'Y'}(${horizontal ? amount * -1 : amount}${unit})`;
const duration = 2 * (isMd ? mdDuration : iosDuration);
const animation = animationCtl
.create()
.duration(duration)
.easing(isMd ? mdEasing : iosEasing)
.addAnimation(
animationCtl.create().beforeClearStyles(['visibility']).addElement(document.querySelector('ion-app')!),
)
.addAnimation(
animationCtl
.create()
.fromTo('transform', translate(horizontal ? 64 : 192), translate(0))
.fromTo('opacity', 0, 1)
.addElement(document.querySelector('stapps-navigation > ion-split-pane')!),
)
.addAnimation(
animationCtl
.create()
.fromTo('transform', translate(64), translate(0))
.addElement(document.querySelectorAll('ion-split-pane > ion-menu > ion-content')),
)
.addAnimation(
animationCtl
.create()
.fromTo('transform', translate(horizontal ? 32 : -72), translate(0))
.addElement(document.querySelectorAll('ion-router-outlet > .ion-page > ion-content')!),
)
.addAnimation(
animationCtl
.create()
.fromTo('transform', translate(100, '%'), translate(0, '%'))
.addElement(document.querySelector('stapps-navigation-tabs')!),
);
if (!horizontal) {
animation.addAnimation(
animationCtl
.create()
.fromTo('background', 'none', 'none')
.addElement(document.querySelector('ion-router-outlet')!),
);
const parallax = document
.querySelector('ion-router-outlet > .ion-page > ion-content')
?.shadowRoot?.querySelector('[part=parallax]');
if (parallax) {
animation.addAnimation(
animationCtl.create().fromTo('translate', '0 256px', '0 0px').addElement(parallax),
);
}
}
return animation;
}

View File

@@ -22,28 +22,16 @@ import {environment} from '../environments/environment';
import {Capacitor} from '@capacitor/core';
import {ScheduleSyncService} from './modules/background/schedule/schedule-sync.service';
import {Keyboard, KeyboardResize} from '@capacitor/keyboard';
import {AppVersionService} from './modules/about/app-version.service';
import {SplashScreen} from '@capacitor/splash-screen';
import {AppVersionService} from './modules/about/app-version.service';
/**
* TODO
*/
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
})
export class AppComponent implements AfterContentInit {
/**
* TODO
*/
pages: Array<{
/**
* TODO
*/
component: unknown;
/**
* TODO
*/
title: string;
}>;
@@ -65,7 +53,7 @@ export class AppComponent implements AfterContentInit {
void this.initializeApp();
}
async ngAfterContentInit() {
ngAfterContentInit() {
this.scheduleSyncService.init();
void this.scheduleSyncService.enable();
this.versionService.getPendingReleaseNotes().then(notes => {
@@ -74,24 +62,11 @@ export class AppComponent implements AfterContentInit {
}
});
if (document.readyState === 'complete') {
this.hideSplash();
} else {
document.addEventListener('readystatechange', () => {
if (document.readyState === 'complete') this.hideSplash();
});
}
}
async hideSplash() {
if (Capacitor.isNativePlatform()) {
void SplashScreen.hide();
}
}
/**
* TODO
*/
async initializeApp() {
App.addListener('appUrlOpen', (event: URLOpenListenerEvent) => {
this.zone.run(() => {

View File

@@ -25,12 +25,10 @@ import moment from 'moment';
import 'moment/min/locales';
import {LoggerModule, NGXLogger, NgxLoggerLevel} from 'ngx-logger';
import SwiperCore, {FreeMode, Navigation} from 'swiper';
import {environment} from '../environments/environment';
import {AppRoutingModule} from './app-routing.module';
import {AppComponent} from './app.component';
import {CatalogModule} from './modules/catalog/catalog.module';
import {ConfigModule} from './modules/config/config.module';
import {ConfigProvider} from './modules/config/config.provider';
import {DashboardModule} from './modules/dashboard/dashboard.module';
import {DataModule} from './modules/data/data.module';
@@ -44,7 +42,6 @@ import {SettingsProvider} from './modules/settings/settings.provider';
import {StorageModule} from './modules/storage/storage.module';
import {ThingTranslateModule} from './translation/thing-translate.module';
import {UtilModule} from './util/util.module';
import {initLogger} from './_helpers/ts-logger';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {AboutModule} from './modules/about/about.module';
import {JobModule} from './modules/jobs/jobs.module';
@@ -91,28 +88,25 @@ export function initializerFactory(
) {
return async () => {
try {
initLogger(logger);
await storageProvider.init();
await configProvider.init();
await settingsProvider.init();
if (configProvider.firstSession) {
// set language from browser
await settingsProvider.setSettingValue(
'profile',
'language',
translateService.getBrowserLang() as SCSettingValue,
);
}
const languageCode = await settingsProvider.getSetting<string>('profile', 'language');
// this language will be used as a fallback when a translation isn't found in the current language
translateService.setDefaultLang('en');
translateService.use(languageCode);
moment.locale(languageCode);
const dateFnsLocale = await getDateFnsLocale(languageCode as SCLanguageCode);
setDefaultOptions({locale: dateFnsLocale});
dateFnsConfigurationService.setLocale(dateFnsLocale);
try {
if (configProvider.firstSession) {
// set language from browser
await settingsProvider.setSettingValue(
'profile',
'language',
translateService.getBrowserLang() as SCSettingValue,
);
}
const languageCode = (await settingsProvider.getValue('profile', 'language')) as string;
// this language will be used as a fallback when a translation isn't found in the current language
translateService.setDefaultLang('en');
translateService.use(languageCode);
moment.locale(languageCode);
const dateFnsLocale = await getDateFnsLocale(languageCode as SCLanguageCode);
setDefaultOptions({locale: dateFnsLocale});
dateFnsConfigurationService.setLocale(dateFnsLocale);
await defaultAuthService.init();
await paiaAuthService.init();
} catch (error) {
@@ -151,11 +145,12 @@ export function createTranslateLoader(http: HttpClient) {
BrowserAnimationsModule,
CatalogModule,
CommonModule,
ConfigModule,
DashboardModule,
DataModule,
HebisModule,
IonicModule.forRoot(),
IonicModule.forRoot({
animated: 'Cypress' in window ? false : undefined,
}),
IonIconModule,
JobModule,
FavoritesModule,

View File

@@ -14,7 +14,7 @@
*/
import {Component, OnInit} from '@angular/core';
import {ActivatedRoute} from '@angular/router';
import {SCAboutPage, SCAppConfiguration} from '@openstapps/core';
import {SCAboutPage} from '@openstapps/core';
import {ConfigProvider} from '../../config/config.provider';
import packageJson from '../../../../../package.json';
import config from 'capacitor.config';
@@ -42,8 +42,7 @@ export class AboutPageComponent implements OnInit {
async ngOnInit() {
const route = this.route.snapshot.url.map(it => it.path).join('/');
this.content =
(this.configProvider.getValue('aboutPages') as SCAppConfiguration['aboutPages'])[route] ?? {};
this.content = this.configProvider.config.app.aboutPages[route] ?? {};
this.version = Capacitor.getPlatform() === 'web' ? 'Web' : await App.getInfo().then(info => info.version);
}
}

View File

@@ -19,7 +19,6 @@ import {FormsModule} from '@angular/forms';
import {IonicModule} from '@ionic/angular';
import {TranslateModule} from '@ngx-translate/core';
import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {ConfigProvider} from '../config/config.provider';
import {AboutPageComponent} from './about-page/about-page.component';
import {MarkdownModule} from 'ngx-markdown';
import {AboutPageContentComponent} from './about-page/about-page-content.component';
@@ -64,6 +63,5 @@ const settingsRoutes: Routes = [
ScrollingModule,
UtilModule,
],
providers: [ConfigProvider],
})
export class AboutModule {}

View File

@@ -18,12 +18,7 @@ import {IPAIAAuthAction} from './paia/paia-auth-action';
import {AuthActions, IAuthAction} from 'ionic-appauth';
import {TranslateService} from '@ngx-translate/core';
import {JSONPath} from 'jsonpath-plus';
import {
SCAuthorizationProvider,
SCAuthorizationProviderType,
SCUserConfiguration,
SCUserConfigurationMap,
} from '@openstapps/core';
import {SCAuthorizationProviderType, SCUserConfiguration} from '@openstapps/core';
import {ConfigProvider} from '../config/config.provider';
import {StorageProvider} from '../storage/storage.provider';
import {DefaultAuthService} from './default-auth.service';
@@ -37,8 +32,6 @@ const AUTH_ORIGIN_PATH = 'stapps.auth.origin_path';
providedIn: 'root',
})
export class AuthHelperService {
userConfigurationMap: SCUserConfigurationMap;
constructor(
private translateService: TranslateService,
private configProvider: ConfigProvider,
@@ -47,14 +40,7 @@ export class AuthHelperService {
private paiaAuth: PAIAAuthService,
private browser: SimpleBrowser,
private alertController: AlertController,
) {
this.userConfigurationMap =
(
this.configProvider.getAnyValue('auth') as {
default: SCAuthorizationProvider;
}
).default?.endpoints.mapping ?? {};
}
) {}
public getAuthMessage(provider: SCAuthorizationProviderType, action: IAuthAction | IPAIAAuthAction) {
let message: string | undefined;
@@ -77,9 +63,10 @@ export class AuthHelperService {
name: '',
role: 'student',
};
for (const key in this.userConfigurationMap) {
const mapping = this.configProvider.config.auth.default!.endpoints.mapping;
for (const key in mapping) {
user[key as keyof SCUserConfiguration] = JSONPath({
path: this.userConfigurationMap[key as keyof SCUserConfiguration] as string,
path: mapping[key as keyof SCUserConfiguration] as string,
json: userInfo,
preventEval: true,
})[0];

View File

@@ -12,7 +12,6 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {
AuthorizationRequestHandler,
AuthorizationServiceConfiguration,
@@ -24,7 +23,6 @@ import {
} from '@openid/appauth';
import {AuthActionBuilder, Browser, DefaultBrowser, EndSessionHandler, UserInfoHandler} from 'ionic-appauth';
import {ConfigProvider} from '../config/config.provider';
import {SCAuthorizationProvider} from '@openstapps/core';
import {getClientConfig, getEndpointsConfig} from './auth.provider.methods';
import {Injectable} from '@angular/core';
import {AuthService} from './auth.service';
@@ -67,12 +65,9 @@ export class DefaultAuthService extends AuthService {
}
setupConfiguration() {
const authConfig = this.configProvider.getAnyValue('auth') as {
default: SCAuthorizationProvider;
};
this.authConfig = getClientConfig('default', authConfig);
this.authConfig = getClientConfig('default', this.configProvider.config.auth);
this.localConfiguration = new AuthorizationServiceConfiguration(
getEndpointsConfig('default', authConfig),
getEndpointsConfig('default', this.configProvider.config.auth),
);
}

View File

@@ -12,7 +12,6 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {
AuthorizationError,
AuthorizationRequest,
@@ -47,7 +46,6 @@ import {PAIAAuthorizationResponse} from './paia-authorization-response';
import {PAIAAuthorizationNotifier} from './paia-authorization-notifier';
import {PAIATokenResponse} from './paia-token-response';
import {IPAIAAuthAction, PAIAAuthActionBuilder} from './paia-auth-action';
import {SCAuthorizationProvider} from '@openstapps/core';
import {ConfigProvider} from '../../config/config.provider';
import {getClientConfig, getEndpointsConfig} from '../auth.provider.methods';
import {Injectable} from '@angular/core';
@@ -154,11 +152,10 @@ export class PAIAAuthService {
}
setupConfiguration() {
const authConfig = this.configProvider.getAnyValue('auth') as {
paia: SCAuthorizationProvider;
};
this.authConfig = getClientConfig('paia', authConfig);
this.localConfiguration = new AuthorizationServiceConfiguration(getEndpointsConfig('paia', authConfig));
this.authConfig = getClientConfig('paia', this.configProvider.config.auth);
this.localConfiguration = new AuthorizationServiceConfiguration(
getEndpointsConfig('paia', this.configProvider.config.auth),
);
}
protected notifyActionListers(action: IPAIAAuthAction) {

View File

@@ -12,7 +12,6 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Calendar} from '@awesome-cordova-plugins/calendar/ngx';
import {Injectable} from '@angular/core';
import {ICalEvent} from './ical/ical';
@@ -35,14 +34,14 @@ export class CalendarService {
goToDateClicked = this.goToDate.asObservable();
calendarName = 'StApps';
calendarName: string;
// eslint-disable-next-line @typescript-eslint/no-empty-function
constructor(
readonly calendar: Calendar,
private readonly configProvider: ConfigProvider,
) {
this.calendarName = (this.configProvider.getValue('name') as string) ?? 'StApps';
this.calendarName = this.configProvider.config.app.name ?? 'StApps';
}
async createCalendar(): Promise<CalendarInfo | undefined> {

View File

@@ -20,7 +20,6 @@ import {IonicModule} from '@ionic/angular';
import {TranslateModule} from '@ngx-translate/core';
import {MomentModule} from 'ngx-moment';
import {DataModule} from '../data/data.module';
import {SettingsProvider} from '../settings/settings.provider';
import {CatalogComponent} from './catalog.component';
import {UtilModule} from '../../util/util.module';
import {IonIconModule} from '../../util/ion-icon/ion-icon.module';
@@ -46,6 +45,5 @@ const catalogRoutes: Routes = [
DataModule,
UtilModule,
],
providers: [SettingsProvider],
})
export class CatalogModule {}

View File

@@ -16,12 +16,6 @@ import {TestBed} from '@angular/core/testing';
import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider';
import {StorageProvider} from '../storage/storage.provider';
import {ConfigProvider, STORAGE_KEY_CONFIG} from './config.provider';
import {
ConfigFetchError,
ConfigInitError,
SavedConfigNotAvailable,
WrongConfigVersionInStorage,
} from './errors';
import {NGXLogger} from 'ngx-logger';
import {sampleIndexResponse} from '../../_helpers/data/sample-configuration';

View File

@@ -14,19 +14,14 @@
*/
import {Injectable} from '@angular/core';
import {Client} from '@openstapps/api';
import {SCAppConfiguration, SCIndexResponse} from '@openstapps/core';
import {SCIndexResponse} from '@openstapps/core';
import packageInfo from '@openstapps/core/package.json';
import {NGXLogger} from 'ngx-logger';
import {environment} from '../../../environments/environment';
import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider';
import {StorageProvider} from '../storage/storage.provider';
import {
ConfigFetchError,
ConfigInitError,
ConfigValueNotAvailable,
SavedConfigNotAvailable,
WrongConfigVersionInStorage,
} from './errors';
import equals from 'fast-deep-equal/es6';
import {BehaviorSubject} from 'rxjs';
/**
* Key to store config in storage module
@@ -35,6 +30,17 @@ import {
*/
export const STORAGE_KEY_CONFIG = 'stapps.config';
/**
* Makes an object deeply immutable
*/
function deepFreeze<T extends object>(object: T) {
for (const key of Object.keys(object)) {
const value = (object as Record<string, unknown>)[key];
if (typeof value === 'object' && !Object.isFrozen(value)) deepFreeze(value!);
}
return Object.freeze(object);
}
/**
* Provides configuration
*/
@@ -50,7 +56,7 @@ export class ConfigProvider {
/**
* App configuration as IndexResponse
*/
config: SCIndexResponse;
config: Readonly<SCIndexResponse>;
/**
* Version of the @openstapps/core package that app is using
@@ -62,6 +68,11 @@ export class ConfigProvider {
*/
firstSession = true;
/**
* If the config requires an update
*/
needsUpdate$ = new BehaviorSubject(false);
/**
* Constructor, initialise api client
* @param storageProvider StorageProvider to load persistent configuration
@@ -76,104 +87,35 @@ export class ConfigProvider {
this.client = new Client(swHttpClient, environment.backend_url, environment.backend_version);
}
/**
* Fetches configuration from backend
*/
async fetch(): Promise<SCIndexResponse> {
try {
return await this.client.handshake(this.scVersion);
} catch {
throw new ConfigFetchError();
}
}
/**
* Returns the value of an app configuration
* @param attribute requested attribute from app configuration
*/
public getValue(attribute: keyof SCAppConfiguration) {
if (this.config.app[attribute] !== undefined) {
return this.config.app[attribute];
}
throw new ConfigValueNotAvailable(attribute);
}
/**
* Returns a value of the configuration (not only app configuration)
* @param attribute requested attribute from the configuration
*/
public getAnyValue(attribute: keyof SCIndexResponse) {
if (this.config[attribute] !== undefined) {
return this.config[attribute];
}
throw new ConfigValueNotAvailable(attribute);
}
/**
* Initialises the ConfigProvider
* @throws ConfigInitError if no configuration could be loaded.
* @throws WrongConfigVersionInStorage if fetch failed and saved config has wrong SCVersion
*/
async init(): Promise<void> {
let loadError;
let fetchError;
// load saved configuration
try {
this.config = await this.loadLocal();
this.firstSession = false;
this.logger.log(`initialised configuration from storage`);
if (this.config.backend.SCVersion !== this.scVersion) {
loadError = new WrongConfigVersionInStorage(this.scVersion, this.config.backend.SCVersion);
this.config = (await this.storageProvider.has(STORAGE_KEY_CONFIG))
? await this.storageProvider.get<SCIndexResponse>(STORAGE_KEY_CONFIG)
: undefined!;
this.firstSession = !this.config;
const updatedConfig = this.client.handshake(this.scVersion).then(async fetchedConfig => {
if (!equals(fetchedConfig, this.config)) {
await this.storageProvider.put(STORAGE_KEY_CONFIG, fetchedConfig);
this.logger.log(`Config updated`);
this.needsUpdate$.next(true);
this.needsUpdate$.complete();
}
} catch (error) {
loadError = error;
}
// fetch remote configuration from backend
try {
const fetchedConfig: SCIndexResponse = await this.fetch();
await this.set(fetchedConfig);
this.logger.log(`initialised configuration from remote`);
} catch (error) {
fetchError = error;
}
// check for occurred errors and throw them
if (loadError !== undefined && fetchError !== undefined) {
throw new ConfigInitError();
}
if (loadError !== undefined) {
this.logger.warn(loadError);
}
if (fetchError !== undefined) {
this.logger.warn(fetchError);
}
}
return fetchedConfig;
});
/**
* Returns saved configuration from StorageModule
* @throws SavedConfigNotAvailable if no configuration could be loaded
*/
async loadLocal(): Promise<SCIndexResponse> {
// get local configuration
if (await this.storageProvider.has(STORAGE_KEY_CONFIG)) {
return this.storageProvider.get<SCIndexResponse>(STORAGE_KEY_CONFIG);
this.config ??= await updatedConfig;
this.config = deepFreeze(this.config);
if (this.config.backend.SCVersion !== this.scVersion) {
this.logger.warn(
'Incompatible config, expected',
this.scVersion,
'but got',
this.config.backend.SCVersion,
);
}
throw new SavedConfigNotAvailable();
}
/**
* Saves the configuration from the provider
* @param config configuration to save
*/
async save(config: SCIndexResponse): Promise<void> {
await this.storageProvider.put(STORAGE_KEY_CONFIG, config);
}
/**
* Sets the configuration in the module and writes it into app storage
* @param config SCIndexResponse to set
*/
async set(config: SCIndexResponse): Promise<void> {
this.config = config;
await this.save(this.config);
}
}

View File

@@ -1,65 +0,0 @@
/*
* Copyright (C) 2022 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.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {AppError} from '../../_helpers/errors';
/**
* Error that is thrown when fetching from backend fails
*/
export class ConfigFetchError extends AppError {
constructor() {
super('ConfigFetchError', 'App configuration could not be fetched!');
}
}
/**
* Error that is thrown when the ConfigProvider could be initialised
*/
export class ConfigInitError extends AppError {
constructor() {
super('ConfigInitError', 'App configuration could not be initialised!');
}
}
/**
* Error that is thrown when the requested config value is not available
*/
export class ConfigValueNotAvailable extends AppError {
constructor(valueKey: string) {
super('ConfigValueNotAvailable', `No attribute "${valueKey}" in config available!`);
}
}
/**
* Error that is thrown when no saved config is available
*/
export class SavedConfigNotAvailable extends AppError {
constructor() {
super('SavedConfigNotAvailable', 'No saved app configuration available.');
}
}
/**
* Error that is thrown when the SCVersion of the saved config is not compatible with the app
*/
export class WrongConfigVersionInStorage extends AppError {
constructor(correctVersion: string, savedVersion: string) {
super(
'WrongConfigVersionInStorage',
`The saved configs backend version ${savedVersion} ` +
`does not equal the configured backend version ${correctVersion} of the app.`,
);
}
}

View File

@@ -21,7 +21,6 @@ import {SwiperModule} from 'swiper/angular';
import {TranslateModule, TranslatePipe} from '@ngx-translate/core';
import {MomentModule} from 'ngx-moment';
import {DataModule} from '../data/data.module';
import {SettingsProvider} from '../settings/settings.provider';
import {DashboardComponent} from './dashboard.component';
import {SearchSectionComponent} from './sections/search-section/search-section.component';
import {NewsSectionComponent} from './sections/news-section/news-section.component';
@@ -70,6 +69,6 @@ const catalogRoutes: Routes = [
NewsModule,
JobModule,
],
providers: [SettingsProvider, TranslatePipe],
providers: [TranslatePipe],
})
export class DashboardModule {}

View File

@@ -32,7 +32,6 @@ import {ScheduleProvider} from '../calendar/schedule.provider';
import {GeoNavigationDirective} from '../map/geo-navigation.directive';
import {MapWidgetComponent} from '../map/widget/map-widget.component';
import {MenuModule} from '../menu/menu.module';
import {SettingsProvider} from '../settings/settings.provider';
import {StorageModule} from '../storage/storage.module';
import {ActionChipListComponent} from './chips/action-chip-list.component';
import {AddEventActionChipComponent} from './chips/data/add-event-action-chip.component';
@@ -214,7 +213,6 @@ import {ShareButtonComponent} from './elements/share-button.component';
StAppsWebHttpClient,
CalendarService,
RoutingStackService,
SettingsProvider,
{
provide: SimpleBrowser,
useFactory: browserFactory,

View File

@@ -15,13 +15,13 @@
import {Component, ContentChild, EventEmitter, Input, OnInit, Output, TemplateRef} from '@angular/core';
import {ActivatedRoute, Router} from '@angular/router';
import {ModalController} from '@ionic/angular';
import {LangChangeEvent, TranslateService} from '@ngx-translate/core';
import {SCLanguageCode, SCSaveableThing, SCThings, SCUuid} from '@openstapps/core';
import {SCSaveableThing, SCThings, SCUuid} from '@openstapps/core';
import {DataProvider, DataScope} from '../data.provider';
import {FavoritesService} from '../../favorites/favorites.service';
import {take} from 'rxjs/operators';
import {Network} from '@capacitor/network';
import {DataListContext} from '../list/data-list.component';
import {lastValueFrom} from 'rxjs';
export interface ExternalDataLoadEvent {
uid: SCUuid;
@@ -29,6 +29,13 @@ export interface ExternalDataLoadEvent {
resolve: (item: SCThings | null | undefined) => void;
}
/**
* Type guard for SCSavableThing
*/
function isSCSavableThing(thing: SCThings | SCSaveableThing): thing is SCSaveableThing {
return (thing as SCSaveableThing).data !== undefined;
}
/**
* A Component to display an SCThing detailed
*/
@@ -53,11 +60,6 @@ export class DataDetailComponent implements OnInit {
@Input() autoRouteDataPath = true;
/**
* The language of the item
*/
language: SCLanguageCode;
/**
* Indicating wether internet connectivity is given or not
*/
@@ -79,20 +81,12 @@ export class DataDetailComponent implements OnInit {
@Output() loadItem: EventEmitter<ExternalDataLoadEvent> = new EventEmitter<ExternalDataLoadEvent>();
/**
* Type guard for SCSavableThing
*/
static isSCSavableThing(thing: SCThings | SCSaveableThing): thing is SCSaveableThing {
return (thing as SCSaveableThing).data !== undefined;
}
constructor(
protected readonly route: ActivatedRoute,
router: Router,
private readonly dataProvider: DataProvider,
private readonly favoritesService: FavoritesService,
readonly modalController: ModalController,
translateService: TranslateService,
) {
this.inputItem = router.getCurrentNavigation()?.extras.state?.item;
if (!this.inputItem?.origin) {
@@ -100,10 +94,6 @@ export class DataDetailComponent implements OnInit {
// This can happen, for example, when detail views use `inPlace` list items
delete this.inputItem;
}
this.language = translateService.currentLang as SCLanguageCode;
translateService.onLangChange.subscribe((event: LangChangeEvent) => {
this.language = event.lang as SCLanguageCode;
});
this.isDisconnected = new Promise(async resolve => {
const isConnected = (await Network.getStatus()).connected;
@@ -126,13 +116,8 @@ export class DataDetailComponent implements OnInit {
)
: this.dataProvider.get(uid, DataScope.Remote)));
this.item = item
? // eslint-disable-next-line unicorn/no-null
DataDetailComponent.isSCSavableThing(item)
? item.data
: item
: // eslint-disable-next-line unicorn/no-null
null;
// eslint-disable-next-line unicorn/no-null
this.item = item ? (isSCSavableThing(item) ? item.data : item) : null;
} catch {
// eslint-disable-next-line unicorn/no-null
this.item = null;
@@ -144,14 +129,10 @@ export class DataDetailComponent implements OnInit {
await this.getItem(uid ?? '', false);
// fallback to the saved item (from favorites)
if (this.item === null) {
this.favoritesService
.get(uid)
.pipe(take(1))
.subscribe(item => {
if (item !== undefined) {
this.item = item.data;
}
});
const item = await lastValueFrom(this.favoritesService.get(uid).pipe(take(1)));
if (item) {
this.item = item.data;
}
}
}
}

View File

@@ -30,8 +30,8 @@ export class OffersInListComponent {
@Input() set offers(it: Array<SCThingThatCanBeOfferedOffer<SCAcademicPriceGroup>>) {
this._offers = it;
this.price = it[0].prices?.default;
this.settingsProvider.getSetting('profile', 'group').then(group => {
this.price = it[0].prices?.[(group.value as string).replace(/s$/, '') as never];
this.settingsProvider.getSetting<string>('profile', 'group').then(group => {
this.price = it[0].prices?.[group.replace(/s$/, '') as never];
});
const availabilities = new Set(it.map(offer => offer.availability));

View File

@@ -17,14 +17,7 @@ import {ActivatedRoute, Router} from '@angular/router';
import {Keyboard} from '@capacitor/keyboard';
import {AlertController, AnimationBuilder, AnimationController} from '@ionic/angular';
import {Capacitor} from '@capacitor/core';
import {
SCFacet,
SCFeatureConfiguration,
SCSearchFilter,
SCSearchQuery,
SCSearchSort,
SCThings,
} from '@openstapps/core';
import {SCFacet, SCSearchFilter, SCSearchQuery, SCSearchSort, SCThings} from '@openstapps/core';
import {NGXLogger} from 'ngx-logger';
import {combineLatest, Subject} from 'rxjs';
import {debounceTime, distinctUntilChanged, startWith} from 'rxjs/operators';
@@ -170,9 +163,8 @@ export class SearchPageComponent implements OnInit {
private readonly route: ActivatedRoute,
protected positionService: PositionService,
private readonly configProvider: ConfigProvider,
animationController: AnimationController,
) {
this.routeAnimation = searchPageSwitchAnimation(animationController);
this.routeAnimation = searchPageSwitchAnimation(inject(AnimationController));
}
/**
@@ -323,16 +315,6 @@ export class SearchPageComponent implements OnInit {
this.queryChanged.next();
}
});
this.settingsProvider.settingsActionChanged$
.pipe(takeUntilDestroyed(this.destroy$))
.subscribe(({type, payload}) => {
if (type === 'stapps.settings.changed') {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const {category, name, value} = payload!;
this.logger.log(`received event "settings.changed" with category:
${category}, name: ${name}, value: ${JSON.stringify(value)}`);
}
});
this.dataRoutingService
.itemSelectListener()
.pipe(takeUntilDestroyed(this.destroy$))
@@ -342,12 +324,8 @@ export class SearchPageComponent implements OnInit {
}
});
}
try {
const features = this.configProvider.getValue('features') as SCFeatureConfiguration;
this.isHebisAvailable = !!features.plugins?.['hebis-plugin']?.urlPath;
} catch (error) {
this.logger.error(error);
}
this.isHebisAvailable =
this.configProvider.config.app.features.plugins?.['hebis-plugin']?.urlPath !== undefined;
}
/**

View File

@@ -21,7 +21,6 @@ import {
SCRatingResponse,
SCRatingRoute,
SCUserGroup,
SCUserGroupSetting,
SCUuid,
} from '@openstapps/core';
import {StAppsWebHttpClient} from './stapps-web-http-client.provider';
@@ -63,9 +62,7 @@ export class RatingProvider {
}
private get userGroup(): Promise<SCUserGroup> {
return this.settingsProvider
.getSetting('profile', 'group')
.then(it => (it as SCUserGroupSetting).value as SCUserGroup);
return this.settingsProvider.getSetting<SCUserGroup>('profile', 'group');
}
private async getStoredRatings(): Promise<RatingStorage> {

View File

@@ -75,7 +75,7 @@ export class PlaceMensaService {
sort: [
{
arguments: {
field: `offers.prices.${(priceGroup.value as string).replace(/s$/, '')}`,
field: `offers.prices.${(priceGroup as string).replace(/s$/, '')}`,
},
order: 'desc',
type: 'generic',

View File

@@ -12,21 +12,13 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Component, OnInit} from '@angular/core';
import {AlertController, AnimationController} from '@ionic/angular';
import {ActivatedRoute, Router} from '@angular/router';
import {NGXLogger} from 'ngx-logger';
import {Component, inject, OnInit} from '@angular/core';
import {debounceTime, distinctUntilChanged, startWith, take} from 'rxjs/operators';
import {combineLatest} from 'rxjs';
import {SCThingType} from '@openstapps/core';
import {FavoritesService} from './favorites.service';
import {DataRoutingService} from '../data/data-routing.service';
import {ContextMenuService} from '../menu/context/context-menu.service';
import {SearchPageComponent} from '../data/list/search-page.component';
import {DataProvider} from '../data/data.provider';
import {SettingsProvider} from '../settings/settings.provider';
import {PositionService} from '../map/position.service';
import {ConfigProvider} from '../config/config.provider';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
/**
@@ -42,34 +34,7 @@ export class FavoritesPageComponent extends SearchPageComponent implements OnIni
showNavigation = false;
constructor(
alertController: AlertController,
dataProvider: DataProvider,
contextMenuService: ContextMenuService,
settingsProvider: SettingsProvider,
logger: NGXLogger,
dataRoutingService: DataRoutingService,
router: Router,
route: ActivatedRoute,
positionService: PositionService,
private favoritesService: FavoritesService,
configProvider: ConfigProvider,
animationController: AnimationController,
) {
super(
alertController,
dataProvider,
contextMenuService,
settingsProvider,
logger,
dataRoutingService,
router,
route,
positionService,
configProvider,
animationController,
);
}
private favoritesService = inject(FavoritesService);
ngOnInit() {
super.ngOnInit(false);
@@ -96,16 +61,6 @@ export class FavoritesPageComponent extends SearchPageComponent implements OnIni
this.queryChanged.next();
}
});
this.settingsProvider.settingsActionChanged$
.pipe(takeUntilDestroyed(this.destroy$))
.subscribe(({type, payload}) => {
if (type === 'stapps.settings.changed') {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const {category, name, value} = payload!;
this.logger.log(`received event "settings.changed" with category:
${category}, name: ${name}, value: ${JSON.stringify(value)}`);
}
});
this.dataRoutingService
.itemSelectListener()
.pipe(takeUntilDestroyed(this.destroy$))

View File

@@ -28,7 +28,6 @@ import {
} from '@openstapps/core';
import {StorageProvider} from '../storage/storage.provider';
import {DataProvider} from '../data/data.provider';
import {ThingTranslatePipe} from '../../translation/thing-translate.pipe';
import {TranslateService} from '@ngx-translate/core';
import {ThingTranslateService} from '../../translation/thing-translate.service';
import {BehaviorSubject, Observable} from 'rxjs';
@@ -41,11 +40,6 @@ import {debounceTime, map} from 'rxjs/operators';
providedIn: 'root',
})
export class FavoritesService {
/**
* Translation pipe
*/
thingTranslatePipe: ThingTranslatePipe;
favorites = new BehaviorSubject<Map<string, SCFavorite>>(new Map<string, SCFavorite>());
// using debounce time 0 allows change detection to run through async suspension
@@ -93,8 +87,8 @@ export class FavoritesService {
return items.sort((a, b) => {
return (
new Intl.Collator(this.translate.currentLang).compare(
this.thingTranslatePipe.transform(field, a),
this.thingTranslatePipe.transform(field, b),
this.thingTranslate.get(a, field) as string,
this.thingTranslate.get(b, field) as string,
) * reverse
);
});
@@ -124,7 +118,6 @@ export class FavoritesService {
private readonly translate: TranslateService,
private readonly thingTranslate: ThingTranslateService,
) {
this.thingTranslatePipe = new ThingTranslatePipe(this.translate, this.thingTranslate);
void this.emitAll();
}
@@ -185,7 +178,9 @@ export class FavoritesService {
const textFilteredItems: SCIndexableThings[] = [];
for (const item of items) {
if (
this.thingTranslatePipe.transform('name', item).toLowerCase().includes(queryText.toLowerCase())
(this.thingTranslate.get(item, 'name') as string)
.toLowerCase()
.includes(queryText.toLowerCase())
) {
textFilteredItems.push(item);
}

View File

@@ -17,7 +17,6 @@ import {DaiaAvailabilityResponse, DaiaHolding, DaiaService} from './protocol/res
import {StorageProvider} from '../storage/storage.provider';
import {HttpClient, HttpHeaders} from '@angular/common/http';
import {ConfigProvider} from '../config/config.provider';
import {SCFeatureConfiguration} from '@openstapps/core';
import {NGXLogger} from 'ngx-logger';
import {TranslateService} from '@ngx-translate/core';
@@ -67,7 +66,7 @@ export class DaiaDataProvider {
async getAvailability(id: string): Promise<DaiaHolding[] | undefined> {
if (this.daiaServiceUrl === undefined) {
try {
const features = this.configProvider.getValue('features') as SCFeatureConfiguration;
const features = this.configProvider.config.app.features;
if (features.extern?.daia?.url) {
this.daiaServiceUrl = features.extern?.daia?.url;
} else {

View File

@@ -114,16 +114,6 @@ export class HebisSearchPageComponent extends SearchPageComponent implements OnI
this.queryChanged.next();
}
});
this.settingsProvider.settingsActionChanged$
.pipe(takeUntilDestroyed(this.destroy$))
.subscribe(({type, payload}) => {
if (type === 'stapps.settings.changed') {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const {category, name, value} = payload!;
this.logger.log(`received event "settings.changed" with category:
${category}, name: ${name}, value: ${JSON.stringify(value)}`);
}
});
this.dataRoutingService
.itemSelectListener()
.pipe(takeUntilDestroyed(this.destroy$))

View File

@@ -12,14 +12,9 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Injectable} from '@angular/core';
import {JQueryRequestor, Requestor} from '@openid/appauth';
import {
SCAuthorizationProviderType,
SCFeatureConfiguration,
SCFeatureConfigurationExtern,
} from '@openstapps/core';
import {SCAuthorizationProviderType, SCFeatureConfigurationExtern} from '@openstapps/core';
import {DocumentAction, PAIADocument, PAIADocumentStatus, PAIAFees, PAIAItems, PAIAPatron} from '../types';
import {HebisDataProvider} from '../../hebis/hebis-data.provider';
import {PAIATokenResponse} from '../../auth/paia/paia-token-response';
@@ -53,9 +48,7 @@ export class LibraryAccountService {
private readonly toastController: ToastController,
) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const config: SCFeatureConfigurationExtern = (
configProvider.getValue('features') as SCFeatureConfiguration
).extern!.paia;
const config: SCFeatureConfigurationExtern = configProvider.config.app.features.extern!.paia;
this.baseUrl = config.url;
this.authType = config.authProvider as SCAuthorizationProviderType;
}

View File

@@ -19,7 +19,6 @@ import {LeafletModule} from '@asymmetrik/ngx-leaflet';
import {LeafletMarkerClusterModule} from '@asymmetrik/ngx-leaflet-markercluster';
import {IonicModule} from '@ionic/angular';
import {TranslateModule} from '@ngx-translate/core';
import {Polygon} from 'geojson';
import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {ConfigProvider} from '../config/config.provider';
import {DataFacetsProvider} from '../data/data-facets.provider';
@@ -42,7 +41,7 @@ import {GeoNavigationDirective} from './geo-navigation.directive';
*/
export function initMapConfigFactory(configProvider: ConfigProvider, mapProvider: MapProvider) {
return async () => {
mapProvider.defaultPolygon = (await configProvider.getValue('campusPolygon')) as Polygon;
mapProvider.defaultPolygon = configProvider.config.app.campusPolygon;
};
}

View File

@@ -116,7 +116,7 @@ export class MapProvider {
private positionService: PositionService,
private configProvider: ConfigProvider,
) {
this.defaultPolygon = this.configProvider.getValue('campusPolygon') as Polygon;
this.defaultPolygon = this.configProvider.config.app.campusPolygon;
}
/**

View File

@@ -13,11 +13,11 @@
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Component, Input} from '@angular/core';
import {LangChangeEvent, TranslateService} from '@ngx-translate/core';
import {SCLanguage, SCThingTranslator, SCThingType, SCTranslations} from '@openstapps/core';
import {SCThingType} from '@openstapps/core';
import {ContextMenuService} from './context-menu.service';
import {FilterContext, FilterFacet, SortContext, SortContextOption} from './context-type.js';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {ThingTranslateService} from '../../../translation/thing-translate.service';
/**
* The context menu
@@ -59,11 +59,6 @@ export class ContextMenuComponent {
return this.filterOption.options.filter(it => it.buckets.length > 0);
}
/**
* Possible languages to be used for translation
*/
language: keyof SCTranslations<SCLanguage>;
/**
* Mapping of SCThingType
*/
@@ -74,22 +69,10 @@ export class ContextMenuComponent {
*/
sortOption: SortContext;
/**
* Core translator
*/
translator: SCThingTranslator;
constructor(
private translateService: TranslateService,
private readonly contextMenuService: ContextMenuService,
private readonly thingTranslateService: ThingTranslateService,
) {
this.language = this.translateService.currentLang as keyof SCTranslations<SCLanguage>;
this.translator = new SCThingTranslator(this.language);
this.translateService.onLangChange.pipe(takeUntilDestroyed()).subscribe((event: LangChangeEvent) => {
this.language = event.lang as keyof SCTranslations<SCLanguage>;
this.translator = new SCThingTranslator(this.language);
});
this.contextMenuService.filterContextChanged$.pipe(takeUntilDestroyed()).subscribe(filterContext => {
this.filterOption = filterContext;
});
@@ -109,7 +92,7 @@ export class ContextMenuComponent {
* Returns translated property value
*/
getTranslatedPropertyValue(onlyForType: SCThingType, field: string, key?: string): string | undefined {
return this.translator.translatedPropertyValue(onlyForType, field, key);
return this.thingTranslateService.translator.translatedPropertyValue(onlyForType, field, key);
}
/**

View File

@@ -13,74 +13,23 @@
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Component, OnInit} from '@angular/core';
import {LangChangeEvent, TranslateService} from '@ngx-translate/core';
import {
SCAppConfigurationMenuCategory,
SCLanguage,
SCThingTranslator,
SCTranslations,
} from '@openstapps/core';
import {NavigationService} from './navigation.service';
import config from 'capacitor.config';
import {SettingsProvider} from '../../settings/settings.provider';
import {BreakpointObserver} from '@angular/cdk/layout';
import {SCAppConfigurationMenuCategory} from '@openstapps/core';
import {ConfigProvider} from '../../config/config.provider';
/**
* Generated class for the MenuPage page.
*
* See https://ionicframework.com/docs/components/#navigation for more info on
* Ionic pages and navigation.
*/
@Component({
selector: 'stapps-navigation',
styleUrls: ['navigation.scss'],
templateUrl: 'navigation.html',
})
export class NavigationComponent implements OnInit {
showTabbar = true;
/**
* Name of the app
*/
appName = config.appName;
/**
* Possible languages to be used for translation
*/
language: keyof SCTranslations<SCLanguage>;
/**
* Menu entries from config module
*/
menu: SCAppConfigurationMenuCategory[];
/**
* Core translator
*/
translator: SCThingTranslator;
constructor(
public translateService: TranslateService,
private navigationService: NavigationService,
private settingsProvider: SettingsProvider,
private responsive: BreakpointObserver,
) {
translateService.onLangChange.subscribe((event: LangChangeEvent) => {
this.language = event.lang as keyof SCTranslations<SCLanguage>;
this.translator = new SCThingTranslator(this.language);
});
this.responsive.observe(['(min-width: 768px)']).subscribe(result => {
this.showTabbar = !result.matches;
});
}
constructor(private config: ConfigProvider) {}
async ngOnInit() {
this.language = (await this.settingsProvider.getValue(
'profile',
'language',
)) as keyof SCTranslations<SCLanguage>;
this.translator = new SCThingTranslator(this.language);
this.menu = await this.navigationService.getMenu();
this.menu = this.config.config.app.menus;
}
}

View File

@@ -34,11 +34,11 @@
class="menu-category"
>
<ion-icon slot="end" [name]="category.icon"></ion-icon>
<ion-label> {{ category.translations[language]?.title | titlecase }} </ion-label>
<ion-label> {{ 'title' | translateSimple: category | titlecase }} </ion-label>
</ion-item>
<ion-item *ngFor="let item of category.items" [rootLink]="item.route" [redirectedFrom]="item.route">
<ion-icon slot="end" [name]="item.icon"></ion-icon>
<ion-label> {{ item.translations[language]?.title | titlecase }} </ion-label>
<ion-label> {{ 'title' | translateSimple: item | titlecase }} </ion-label>
</ion-item>
</ion-list>
</ion-content>

View File

@@ -22,10 +22,11 @@ import {IonIconModule} from '../../../util/ion-icon/ion-icon.module';
import {TranslateModule} from '@ngx-translate/core';
import {RouterModule} from '@angular/router';
import {OfflineNoticeComponent} from './offline-notice.component';
import {ThingTranslateModule} from '../../../translation/thing-translate.module';
@NgModule({
declarations: [RootLinkDirective, NavigationComponent, TabsComponent, OfflineNoticeComponent],
imports: [CommonModule, IonicModule, IonIconModule, TranslateModule, RouterModule],
imports: [CommonModule, IonicModule, IonIconModule, TranslateModule, RouterModule, ThingTranslateModule],
exports: [TabsComponent, RootLinkDirective, NavigationComponent],
})
export class NavigationModule {}

View File

@@ -60,6 +60,7 @@ stapps-navigation-tabs {
}
}
stapps-offline-notice.needs-reload ~ ion-split-pane,
stapps-offline-notice.has-error ~ ion-split-pane,
stapps-offline-notice.is-offline ~ ion-split-pane {
margin-top: calc(var(--font-size-md) + 2 * var(--spacing-sm));

View File

@@ -1,40 +0,0 @@
/*
* Copyright (C) 2022 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.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Injectable} from '@angular/core';
import {SCAppConfigurationMenuCategory} from '@openstapps/core';
import {ConfigProvider} from '../../config/config.provider';
import {NGXLogger} from 'ngx-logger';
@Injectable({
providedIn: 'root',
})
export class NavigationService {
constructor(
private configProvider: ConfigProvider,
private logger: NGXLogger,
) {}
async getMenu() {
let menu: SCAppConfigurationMenuCategory[] = [];
try {
menu = this.configProvider.getValue('menus') as SCAppConfigurationMenuCategory[];
} catch (error) {
this.logger.error(`error from loading menu entries: ${error}`);
}
return menu;
}
}

View File

@@ -17,6 +17,10 @@ import {InternetConnectionService} from '../../../util/internet-connection.servi
import {Router} from '@angular/router';
import {NGXLogger} from 'ngx-logger';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {SettingsProvider} from '../../settings/settings.provider';
import {AnimationController} from '@ionic/angular';
import {filter, race} from 'rxjs';
import {ConfigProvider} from '../../config/config.provider';
@Component({
selector: 'stapps-offline-notice',
@@ -28,12 +32,17 @@ export class OfflineNoticeComponent {
@HostBinding('class.has-error') hasError = false;
@HostBinding('class.needs-reload') needsReload = false;
@ViewChild('spinIcon', {read: ElementRef}) spinIcon: ElementRef;
constructor(
readonly offlineProvider: InternetConnectionService,
readonly router: Router,
readonly logger: NGXLogger,
readonly animationCtl: AnimationController,
settingsProvider: SettingsProvider,
configProvider: ConfigProvider,
) {
this.offlineProvider.offline$.pipe(takeUntilDestroyed()).subscribe(isOffline => {
this.isOffline = isOffline;
@@ -41,6 +50,15 @@ export class OfflineNoticeComponent {
this.offlineProvider.error$.pipe(takeUntilDestroyed()).subscribe(hasError => {
this.hasError = hasError;
});
race(
settingsProvider.needsReload$.pipe(filter(it => it)),
configProvider.needsUpdate$.pipe(filter(it => it)),
)
.pipe(takeUntilDestroyed())
.subscribe(() => {
console.log('aha!');
this.needsReload = true;
});
}
retry() {
@@ -49,4 +67,15 @@ export class OfflineNoticeComponent {
this.spinIcon.nativeElement.classList.add('spin');
this.offlineProvider.retry();
}
async reloadPage() {
await this.animationCtl
.create()
.duration(100)
.fromTo('opacity', 1, 0)
.addElement(document.querySelector('ion-app')!)
.play();
window.location.reload();
}
}

View File

@@ -20,6 +20,10 @@
<ion-icon #spinIcon slot="start" [size]="16" [weight]="800" name="refresh"></ion-icon>
<ion-label>{{ 'app.errors.CONNECTION_ERROR' | translate }}</ion-label>
</ion-button>
<ion-button class="reload" color="warning" (click)="reloadPage()">
<ion-icon slot="start" [size]="16" [weight]="800" name="refresh"></ion-icon>
<ion-label>{{ 'settings.reloadPage' | translate }}</ion-label>
</ion-button>
<ion-button class="close" fill="clear" color="light" (click)="offlineProvider.dismissError()"
><ion-icon [size]="16" [weight]="800" name="close" slot="icon-only"></ion-icon
></ion-button>

View File

@@ -28,6 +28,7 @@
transition: all 150ms ease;
&.needs-reload,
&.is-offline,
&.has-error {
transform: translateY(0);
@@ -64,6 +65,7 @@
}
}
&.needs-reload > .reload,
&.is-offline > .offline-button,
&.has-error > .close,
&.has-error > .error-button {

View File

@@ -16,6 +16,7 @@
import type {AnimationBuilder} from '@ionic/angular';
import {AnimationController} from '@ionic/angular';
import type {AnimationOptions} from '@ionic/angular/providers/nav-controller';
import {iosDuration, iosEasing, mdDuration, mdEasing} from '../../../animation/easings';
/**
*
@@ -23,10 +24,11 @@ import type {AnimationOptions} from '@ionic/angular/providers/nav-controller';
export function tabsTransition(animationController: AnimationController): AnimationBuilder {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (_baseElement: HTMLElement, options: AnimationOptions | any) => {
const duration = options.duration || 350;
const contentExitDuration = options.contentExitDuration || 100;
const isMd = document.querySelector('ion-app.md') !== null;
const duration = isMd ? mdDuration : iosDuration;
const easing = isMd ? mdEasing : iosEasing;
const rootTransition = animationController.create().duration(duration);
const rootTransition = animationController.create().duration(duration).easing(easing);
const enterTransition = animationController
.create()
@@ -39,23 +41,15 @@ export function tabsTransition(animationController: AnimationController): Animat
.addElement(options.leavingEl);
const exitTransition = animationController
.create()
.duration(contentExitDuration * 2)
.easing('cubic-bezier(0.87, 0, 0.13, 1)')
.fromTo('opacity', '1', '0')
.addElement(options.leavingEl.querySelector('ion-header'));
const contentExit = animationController
.create()
.easing('linear')
.duration(contentExitDuration)
.fromTo('opacity', '1', '0')
.addElement(options.leavingEl.querySelectorAll(':scope > *:not(ion-header)'));
const contentEnter = animationController
.create()
.delay(contentExitDuration)
.duration(duration - contentExitDuration)
.easing('cubic-bezier(0.16, 1, 0.3, 1)')
.fromTo('transform', 'scale(1.025)', 'scale(1)')
.fromTo('opacity', '0', '1')
.fromTo('transform', 'scale(1.05)', 'scale(1)')
.addElement(options.enteringEl.querySelectorAll(':scope > *:not(ion-header)'));
rootTransition.addAnimation([enterTransition, contentExit, contentEnter, exitTransition, exitZIndex]);

View File

@@ -12,17 +12,11 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Component} from '@angular/core';
import {NavigationEnd, Router} from '@angular/router';
import {
SCAppConfigurationMenuCategory,
SCLanguage,
SCThingTranslator,
SCTranslations,
} from '@openstapps/core';
import {SCAppConfigurationMenuCategory} from '@openstapps/core';
import {ConfigProvider} from '../../config/config.provider';
import {LangChangeEvent, TranslateService} from '@ngx-translate/core';
import {TranslateService} from '@ngx-translate/core';
import {NGXLogger} from 'ngx-logger';
@Component({
@@ -31,21 +25,11 @@ import {NGXLogger} from 'ngx-logger';
styleUrls: ['./tabs.component.scss'],
})
export class TabsComponent {
/**
* Possible languages to be used for translation
*/
language: keyof SCTranslations<SCLanguage>;
/**
* Menu entries from config module
*/
menu: SCAppConfigurationMenuCategory[];
/**
* Core translator
*/
translator: SCThingTranslator;
/**
* Name of selected tab
*/
@@ -57,8 +41,6 @@ export class TabsComponent {
private readonly logger: NGXLogger,
private readonly router: Router,
) {
this.language = this.translateService.currentLang as keyof SCTranslations<SCLanguage>;
this.translator = new SCThingTranslator(this.language);
void this.loadMenuEntries();
this.router.events.subscribe((event: unknown) => {
if (event instanceof NavigationEnd) {
@@ -66,11 +48,6 @@ export class TabsComponent {
}
});
this.selectTab(router.url);
translateService.onLangChange?.subscribe((event: LangChangeEvent) => {
this.language = event.lang as keyof SCTranslations<SCLanguage>;
this.translator = new SCThingTranslator(this.language);
});
}
/**
@@ -78,7 +55,7 @@ export class TabsComponent {
*/
async loadMenuEntries() {
try {
const menus = (await this.configProvider.getValue('menus')) as SCAppConfigurationMenuCategory[];
const menus = this.configProvider.config.app.menus;
const menu = menus.slice(0, 5);
if (menu) {

View File

@@ -46,6 +46,6 @@
[tab]="category.title"
>
<ion-icon [name]="category.icon"></ion-icon>
<ion-label>{{ category.translations[language]?.title | titlecase }}</ion-label>
<ion-label>{{ 'title' | translateSimple: category | titlecase }}</ion-label>
</ion-tab-button>
</ion-tab-bar>

View File

@@ -1,9 +0,0 @@
<ng-container *ngFor="let setting of settings">
<stapps-chip-filter
[displayValue]="setting | settingValueTranslate | titlecase"
[value]="setting"
[active]="!!filtersMap.get($any(setting.name))"
(toggle)="stateChanged($any($event))"
>
</stapps-chip-filter>
</ng-container>

View File

@@ -1,74 +0,0 @@
/*
* Copyright (C) 2022 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.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';
import {newsFilterSettingsFieldsMapping, NewsFilterSettingsNames} from '../../news-filter-settings';
import {SCSearchValueFilter, SCSetting} from '@openstapps/core';
import {DataProvider} from '../../../data/data.provider';
@Component({
selector: 'stapps-news-settings-filter',
templateUrl: './news-settings-filter.component.html',
styleUrls: ['./news-settings-filter.component.scss'],
})
export class NewsSettingsFilterComponent implements OnInit {
/**
* A map of the filters where the keys are settings names
*/
filtersMap = new Map<NewsFilterSettingsNames, SCSearchValueFilter>();
/**
* Emits the current filters
*/
@Output() filtersChanged = new EventEmitter<SCSearchValueFilter[]>();
/**
* Provided settings to show the filters for
*/
@Input() settings: SCSetting[];
ngOnInit() {
for (const setting of this.settings) {
this.filtersMap.set(
setting.name as NewsFilterSettingsNames,
DataProvider.createValueFilter(
newsFilterSettingsFieldsMapping[setting.name as NewsFilterSettingsNames],
setting.value as string,
),
);
}
this.filtersChanged.emit([...this.filtersMap.values()]);
}
/**
* To be executed when a chip filter has been enabled/disabled
* @param setting The value of the filter
*/
stateChanged(setting: SCSetting) {
if (this.filtersMap.get(setting.name as NewsFilterSettingsNames) === undefined) {
this.filtersMap.set(
setting.name as NewsFilterSettingsNames,
DataProvider.createValueFilter(
newsFilterSettingsFieldsMapping[setting.name as NewsFilterSettingsNames],
setting.value as string,
),
);
} else {
this.filtersMap.delete(setting.name as NewsFilterSettingsNames);
}
this.filtersChanged.emit([...this.filtersMap.values()]);
}
}

View File

@@ -20,13 +20,11 @@ import {TranslateModule} from '@ngx-translate/core';
import {MomentModule} from 'ngx-moment';
import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {DataModule} from '../data/data.module';
import {SettingsProvider} from '../settings/settings.provider';
import {NewsItemComponent} from './item/news-item.component';
import {NewsPageComponent} from './page/news-page.component';
import {SkeletonNewsItemComponent} from './item/skeleton-news-item.component';
import {ChipFilterComponent} from '../data/chips/filter/chip-filter.component';
import {SettingsModule} from '../settings/settings.module';
import {NewsSettingsFilterComponent} from './elements/news-filter-settings/news-settings-filter.component';
import {UtilModule} from '../../util/util.module';
import {IonIconModule} from '../../util/ion-icon/ion-icon.module';
@@ -36,13 +34,7 @@ const newsRoutes: Routes = [{path: 'news', component: NewsPageComponent}];
* News Module
*/
@NgModule({
declarations: [
NewsPageComponent,
SkeletonNewsItemComponent,
NewsItemComponent,
ChipFilterComponent,
NewsSettingsFilterComponent,
],
declarations: [NewsPageComponent, SkeletonNewsItemComponent, NewsItemComponent, ChipFilterComponent],
imports: [
IonicModule.forRoot(),
ThingTranslateModule.forChild(),
@@ -56,7 +48,6 @@ const newsRoutes: Routes = [{path: 'news', component: NewsPageComponent}];
SettingsModule,
UtilModule,
],
providers: [SettingsProvider],
exports: [NewsItemComponent],
})
export class NewsModule {}

View File

@@ -19,17 +19,18 @@ import {
SCSearchBooleanFilter,
SCSearchFilter,
SCSearchQuery,
SCSearchValueFilter,
SCSetting,
} from '@openstapps/core';
import {DataProvider} from '../data/data.provider';
import {
newsFilterSettingsCategory,
newsFilterSettingsFieldsMapping,
NewsFilterSettingsNames,
} from './news-filter-settings';
import {SettingsProvider} from '../settings/settings.provider';
/**
* The mapping between settings and corresponding data fields for building a value filter
*/
const newsFilterSettingsFieldsMapping = [
['language', 'inLanguage'],
['group', 'audiences'],
] as const;
/**
* Service for providing news messages
*/
@@ -42,28 +43,22 @@ export class NewsProvider {
private settingsProvider: SettingsProvider,
) {}
async getCurrentSettings(): Promise<SCSetting[]> {
const settings: SCSetting[] = [];
for (const settingName of Object.keys(newsFilterSettingsFieldsMapping) as NewsFilterSettingsNames[]) {
settings.push(await this.settingsProvider.getSetting(newsFilterSettingsCategory, settingName));
}
return settings;
}
/**
* Gets the news filter based on user group and language settings
*/
async getCurrentFilters(): Promise<SCSearchFilter[]> {
const settings = await this.getCurrentSettings();
const filtersMap = new Map<NewsFilterSettingsNames, SCSearchValueFilter>();
for (const setting of settings) {
filtersMap.set(
setting.name as NewsFilterSettingsNames,
DataProvider.createValueFilter(
newsFilterSettingsFieldsMapping[setting.name as NewsFilterSettingsNames],
setting.value as string,
),
);
}
return [...filtersMap.values()];
return Promise.all(
newsFilterSettingsFieldsMapping.map(
async ([setting, field]) =>
({
type: 'value',
arguments: {
field,
value: await this.settingsProvider.getSetting('profile', setting),
},
}) satisfies SCSearchFilter,
),
);
}
/**

View File

@@ -12,9 +12,9 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Component, OnInit} from '@angular/core';
import {Component} from '@angular/core';
import {IonRefresher} from '@ionic/angular';
import {SCMessage, SCSearchFilter, SCSearchValueFilter, SCSetting} from '@openstapps/core';
import {SCMessage} from '@openstapps/core';
import {NewsProvider} from '../news.provider';
/**
@@ -25,7 +25,7 @@ import {NewsProvider} from '../news.provider';
templateUrl: 'news-page.html',
styleUrls: ['news-page.scss'],
})
export class NewsPageComponent implements OnInit {
export class NewsPageComponent {
/**
* Thing counter to start query the next page from
*/
@@ -51,24 +51,20 @@ export class NewsPageComponent implements OnInit {
*/
elementSize = [300, 300];
/**
* Relevant settings
*/
settings: SCSetting[];
/**
* Active filters
*/
filters: SCSearchFilter[];
constructor(private newsProvider: NewsProvider) {}
constructor(private newsProvider: NewsProvider) {
this.fetchNews();
}
/**
* Fetch news from the backend
*/
async fetchNews() {
this.from = this.pageSize;
this.news = await this.newsProvider.getList(this.pageSize, 0, [...this.filters]);
this.news = await this.newsProvider.getList(
this.pageSize,
0,
await this.newsProvider.getCurrentFilters(),
);
}
/**
@@ -77,7 +73,11 @@ export class NewsPageComponent implements OnInit {
async loadMore(infiniteScrollElement?: HTMLIonInfiniteScrollElement, more = this.pageSize): Promise<void> {
const from = this.from;
this.from += more;
const fetchedNews = await this.newsProvider.getList(more, from, [...this.filters]);
const fetchedNews = await this.newsProvider.getList(
more,
from,
await this.newsProvider.getCurrentFilters(),
);
this.news = [...this.news, ...fetchedNews];
await infiniteScrollElement?.complete();
@@ -96,13 +96,6 @@ export class NewsPageComponent implements OnInit {
}
}
/**
* Initialize the local variables on component initialization
*/
async ngOnInit() {
this.settings = await this.newsProvider.getCurrentSettings();
}
/**
* Updates the shown list
* @param refresher Refresher component that triggers the update
@@ -116,13 +109,4 @@ export class NewsPageComponent implements OnInit {
await refresher.complete();
}
}
/**
* Executed when filters have been changed
* @param filters Current filters to be used
*/
toggleFilter(filters: SCSearchValueFilter[]) {
this.filters = filters;
void this.fetchNews();
}
}

View File

@@ -32,17 +32,6 @@
>
</ion-refresher-content>
</ion-refresher>
<ion-grid>
<ion-row>
<ion-col size="12">
<stapps-news-settings-filter
*ngIf="settings"
[settings]="settings"
(filtersChanged)="toggleFilter($event)"
></stapps-news-settings-filter>
</ion-col>
</ion-row>
</ion-grid>
<div class="news-grid">
<ng-container *ngIf="!news">
<stapps-skeleton-news-item *ngFor="let skeleton of [1, 2, 3, 4, 5]"></stapps-skeleton-news-item>

View File

@@ -14,8 +14,7 @@
*/
import {Component, Input} from '@angular/core';
import {AlertController} from '@ionic/angular';
import {LangChangeEvent, TranslateService} from '@ngx-translate/core';
import {SCLanguageCode, SCSetting, SCSettingValue, SCSettingValues} from '@openstapps/core';
import {SCSetting, SCSettingValue, SCSettingValues} from '@openstapps/core';
import {SettingsProvider} from '../settings.provider';
/**
@@ -42,23 +41,10 @@ export class SettingsItemComponent {
*/
@Input() setting: SCSetting;
/**
*
* @param alertCtrl AlertController
* @param translateService TranslateService
* @param settingsProvider SettingProvider
*/
constructor(
private readonly alertCtrl: AlertController,
private readonly translateService: TranslateService,
private readonly settingsProvider: SettingsProvider,
) {
translateService.onLangChange.subscribe((_event: LangChangeEvent) => {
this.isVisible = false;
// TODO: Issue #53 check workaround for selected 'select option' not updating translation
setTimeout(() => (this.isVisible = true));
});
}
) {}
/**
* Shows alert with given title and message and a 'ok' button
@@ -82,14 +68,6 @@ export class SettingsItemComponent {
this.setting.value !== undefined &&
SettingsProvider.validateValue(this.setting, this.setting.value)
) {
// handle general settings, with special actions
switch (this.setting.name) {
case 'language': {
this.translateService.use(this.setting.value as SCLanguageCode);
break;
}
default:
}
await this.settingsProvider.setSettingValue(
this.setting.categories[0],
this.setting.name,
@@ -97,7 +75,7 @@ export class SettingsItemComponent {
);
} else {
// reset setting
this.setting.value = (await this.settingsProvider.getValue(
this.setting.value = (await this.settingsProvider.getSetting(
this.setting.categories[0],
this.setting.name,
)) as SCSettingValue | SCSettingValues;

View File

@@ -47,14 +47,6 @@ export class SettingsPageComponent implements OnInit {
*/
settingsCache: SettingsCache;
/**
*
* @param alertController AlertController
* @param settingsProvider SettingsProvider
* @param toastController ToastController
* @param translateService TranslateService
* @param changeDetectorRef ChangeDetectorRef
*/
constructor(
private readonly alertController: AlertController,
private readonly settingsProvider: SettingsProvider,

View File

@@ -26,20 +26,6 @@
<div class="settings-content">
<ng-container *ngFor="let categoryKey of categoriesOrder">
<ion-list *ngIf="objectKeys(settingsCache).includes(categoryKey)">
<!-- <ion-item-divider>
<h2>
{{
'categories[0]'
| thingTranslate
: $any(
settingsCache[categoryKey]?.settings[
objectKeys(settingsCache[categoryKey]?.settings)[0]
]
)
| titlecase
}}
</h2>
</ion-item-divider> -->
<stapps-settings-item
*ngFor="let settingKeys of objectKeys(settingsCache[categoryKey].settings)"
[setting]="settingsCache[categoryKey].settings[settingKeys]"
@@ -49,7 +35,7 @@
<calendar-sync-settings></calendar-sync-settings>
<ion-button expand="block" (click)="presentResetAlert()" fill="outline">
<ion-button expand="block" (click)="presentResetAlert()" fill="outline" color="danger">
{{ 'settings.resetSettings' | translate }}
<ion-icon slot="start" name="device_reset"></ion-icon>
</ion-button>

View File

@@ -12,11 +12,8 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Pipe, PipeTransform} from '@angular/core';
import {TranslateService} from '@ngx-translate/core';
import {SCSetting} from '@openstapps/core';
import {ThingTranslatePipe} from '../../translation/thing-translate.pipe';
import {ThingTranslateService} from '../../translation/thing-translate.service';
/**
@@ -27,14 +24,10 @@ import {ThingTranslateService} from '../../translation/thing-translate.service';
pure: true,
})
export class SettingTranslatePipe implements PipeTransform {
constructor(
private readonly translate: TranslateService,
private readonly thingTranslate: ThingTranslateService,
) {}
constructor(private readonly thingTranslate: ThingTranslateService) {}
transform(setting: SCSetting): string | undefined {
const thingTranslatePipe = new ThingTranslatePipe(this.translate, this.thingTranslate);
const translatedSettingValues = thingTranslatePipe.transform('values', setting);
transform(setting: SCSetting) {
const translatedSettingValues = this.thingTranslate.get(setting, 'values') as string;
return translatedSettingValues
? String(translatedSettingValues[setting.values?.indexOf(setting.value as string) as number])

View File

@@ -18,13 +18,10 @@ import {FormsModule} from '@angular/forms';
import {RouterModule, Routes} from '@angular/router';
import {IonicModule} from '@ionic/angular';
import {TranslateModule} from '@ngx-translate/core';
import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {ConfigProvider} from '../config/config.provider';
import {SettingsItemComponent} from './item/settings-item.component';
import {SettingsPageComponent} from './page/settings-page.component';
import {SettingTranslatePipe} from './setting-translate.pipe';
import {SettingsProvider} from './settings.provider';
import {CalendarSyncSettingsComponent} from './page/calendar-sync-settings.component';
import {ScheduleProvider} from '../calendar/schedule.provider';
import {ThingTranslatePipe} from '../../translation/thing-translate.pipe';
@@ -60,13 +57,6 @@ const settingsRoutes: Routes = [{path: 'settings', component: SettingsPageCompon
RouterModule.forChild(settingsRoutes),
UtilModule,
],
providers: [
ScheduleSyncService,
ConfigProvider,
SettingsProvider,
CalendarService,
ScheduleProvider,
ThingTranslatePipe,
],
providers: [ScheduleSyncService, CalendarService, ScheduleProvider, ThingTranslatePipe],
})
export class SettingsModule {}

View File

@@ -15,7 +15,7 @@
import {Injectable} from '@angular/core';
import {SCSetting, SCSettingValue, SCSettingValues} from '@openstapps/core';
import deepMerge from 'deepmerge';
import {Subject} from 'rxjs';
import {BehaviorSubject, Subject} from 'rxjs';
import {ConfigProvider} from '../config/config.provider';
import {StorageProvider} from '../storage/storage.provider';
@@ -89,7 +89,9 @@ export interface SettingsAction {
/**
* Provider for app settings
*/
@Injectable()
@Injectable({
providedIn: 'root',
})
export class SettingsProvider {
/**
* Source of settings actions
@@ -103,16 +105,16 @@ export class SettingsProvider {
*/
categoriesOrder: string[];
/**
* Settings actions observable
*/
settingsActionChanged$ = this.settingsActionSource.asObservable();
/**
* Cache for the imported settings
*/
settingsCache: SettingsCache;
/**
* Whether the page needs a reload
*/
needsReload$ = new BehaviorSubject(false);
/**
* Return true if all given values are valid to possible values in given settingInput
* @param possibleValues Possible values
@@ -148,9 +150,7 @@ export class SettingsProvider {
return false;
}
return (
possibleValues !== undefined && Array.isArray(possibleValues) && possibleValues.includes(enteredValue)
);
return Array.isArray(possibleValues) && possibleValues.includes(enteredValue);
}
/**
@@ -206,7 +206,7 @@ export class SettingsProvider {
}
/**
* Add an Setting to the Cache if not exist and set undefined value to defaultValue
* Add a Setting to the Cache if not exist and set undefined value to defaultValue
* @param setting Setting with categories, defaultValue, name, input type and valid values
*/
private addSetting(setting: SCSetting): void {
@@ -281,34 +281,20 @@ export class SettingsProvider {
return this.categoriesOrder;
}
/**
* Returns copy of a setting 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 getSetting(category: string, name: string): Promise<SCSetting> {
await this.init();
if (this.settingExists(category, name)) {
// return a copy of the settings
return JSON.parse(JSON.stringify(this.settingsCache[category].settings[name]));
}
throw new Error(`Setting "${name}" not provided`);
}
/**
* 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 getValue(category: string, name: string): Promise<SCSettingValue | SCSettingValues> {
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].value));
}
throw new Error(`Setting "${name}" not provided`);
public async getSetting<T extends SCSettingValue | SCSettingValues>(
category: 'profile' | string,
name: string,
): Promise<T> {
const settings = await this.storage.get<SettingValuesContainer>(STORAGE_KEY_SETTING_VALUES);
const value = settings[category]?.[name];
if (!value) throw new Error(`Setting "${name}" not provided`);
return value as T;
}
/**
@@ -319,8 +305,8 @@ export class SettingsProvider {
this.needsInit = false;
try {
const settings: SCSetting[] = this.configProvider.getValue('settings') as SCSetting[];
for (const setting of settings) this.addSetting(setting);
const settings: SCSetting[] = this.configProvider.config.app.settings;
for (const setting of settings) this.addSetting(JSON.parse(JSON.stringify(setting)));
for (const category of Object.keys(this.settingsCache)) {
if (!this.categoriesOrder.includes(category)) {
@@ -347,7 +333,6 @@ export class SettingsProvider {
: this.settingsCache[categoryKey].settings[settingKey].defaultValue;
}
}
await this.saveSettingValues();
}
}
@@ -397,6 +382,9 @@ export class SettingsProvider {
this.getSettingValuesFromCache(),
);
}
this.needsReload$.next(true);
this.needsReload$.complete();
}
/**

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2019 StApps
* Copyright (C) 2023 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.
@@ -12,16 +12,15 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {NgModule} from '@angular/core';
import {DataModule} from '../data/data.module';
import {StorageModule} from '../storage/storage.module';
import {ConfigProvider} from './config.provider';
import {Injectable, Pipe, PipeTransform} from '@angular/core';
/**
* TODO
*/
@NgModule({
imports: [StorageModule, DataModule],
providers: [ConfigProvider],
@Injectable()
@Pipe({
name: 'entries',
pure: true,
})
export class ConfigModule {}
export class EntriesPipe implements PipeTransform {
transform<T>(value: Record<string | number | symbol, T>): T[] {
return Object.values(value);
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2021 StApps
* Copyright (C) 2023 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.
@@ -12,21 +12,23 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {SCSettingCategories, SCThingsField} from '@openstapps/core';
/**
* Category of settings to use for news filter
*/
export const newsFilterSettingsCategory: SCSettingCategories = 'profile';
/**
* Settings to use for news filter
*/
export type NewsFilterSettingsNames = 'language' | 'group';
/**
* The mapping between settings and corresponding data fields for building a value filter
*/
export const newsFilterSettingsFieldsMapping: {
[key in NewsFilterSettingsNames]: SCThingsField;
} = {
language: 'inLanguage',
group: 'audiences',
};
import {Injectable, Pipe, PipeTransform} from '@angular/core';
@Injectable()
@Pipe({
name: 'join',
pure: true,
})
export class ArrayJoinPipe implements PipeTransform {
transform(anArray: unknown[] | unknown, separator: string | unknown): string {
if (typeof separator !== 'string' || separator.length <= 0) {
return '';
}
if (!Array.isArray(anArray)) {
throw new SyntaxError(`Wrong parameter in ArrayJoinPipe. Expected a valid Array, received: ${anArray}`);
}
return anArray.join(separator);
}
}

View File

@@ -1,416 +0,0 @@
/*
* Copyright (C) 2023 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.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Injectable, OnDestroy, Pipe, PipeTransform} from '@angular/core';
import {LangChangeEvent, TranslateService} from '@ngx-translate/core';
import moment from 'moment';
import {Subscription} from 'rxjs';
import {logger} from '../_helpers/ts-logger';
@Injectable()
@Pipe({
name: 'join',
pure: true,
})
export class ArrayJoinPipe implements PipeTransform {
value = '';
transform(anArray: unknown[] | unknown, separator: string | unknown): string {
if (typeof separator !== 'string' || separator.length <= 0) {
return this.value;
}
if (!Array.isArray(anArray)) {
throw new SyntaxError(`Wrong parameter in ArrayJoinPipe. Expected a valid Array, received: ${anArray}`);
}
this.value = anArray.join(separator);
return this.value;
}
}
@Injectable()
@Pipe({
name: 'entries',
pure: true,
})
export class EntriesPipe implements PipeTransform {
transform<T>(value: Record<string | number | symbol, T>): T[] {
return Object.values(value);
}
}
@Injectable()
@Pipe({
name: 'toUnix',
pure: true,
})
export class ToUnixPipe implements PipeTransform {
transform(value: string | number | Date | null | undefined): number {
return (value instanceof Date ? value : new Date(value ?? 0)).valueOf();
}
}
@Injectable()
@Pipe({
name: 'sentencecase',
pure: true,
})
export class SentenceCasePipe implements PipeTransform {
value = '';
transform(aString: string | unknown): string {
if (typeof aString !== 'string') {
throw new SyntaxError(
`Wrong parameter in StringSplitPipe. Expected a valid String, received: ${aString}`,
);
}
this.value = aString.slice(0, 1).toUpperCase() + aString.slice(1);
return this.value;
}
}
@Injectable()
@Pipe({
name: 'split',
pure: true,
})
export class StringSplitPipe implements PipeTransform {
value = new Array<unknown>();
transform(aString: string | unknown, splitter: string | unknown): unknown[] {
if (typeof splitter !== 'string' || splitter.length <= 0) {
return this.value as never;
}
if (typeof aString !== 'string') {
throw new SyntaxError(
`Wrong parameter in StringSplitPipe. Expected a valid String, received: ${aString}`,
);
}
this.value = aString.split(splitter);
return this.value as never;
}
}
@Injectable()
@Pipe({
name: 'durationLocalized',
pure: true,
})
export class DurationLocalizedPipe implements PipeTransform, OnDestroy {
locale: string;
onLangChange?: Subscription;
value: string;
frequencyPrefixes: {[iso6391Code: string]: string} = {
de: 'alle',
en: 'every',
es: 'cada',
pt: 'a cada',
fr: 'tous les',
cn: '每',
ru: 'kаждые',
};
constructor(private readonly translate: TranslateService) {
this.locale = translate.currentLang;
}
private _dispose(): void {
if (this.onLangChange?.closed === false) {
this.onLangChange?.unsubscribe();
}
}
ngOnDestroy(): void {
this._dispose();
}
/**
* @param value An ISO 8601 duration string
* @param isFrequency Boolean indicating if this duration is to be interpreted as repeat frequency
*/
transform(value: string | unknown, isFrequency = false): string {
this.updateValue(value, isFrequency);
this._dispose();
if (this.onLangChange?.closed === true) {
this.onLangChange = this.translate.onLangChange.subscribe((event: LangChangeEvent) => {
this.locale = event.lang;
this.updateValue(value, isFrequency);
});
}
return this.value;
}
updateValue(value: string | unknown, isFrequency = false): void {
if (typeof value !== 'string') {
logger.warn(`durationLocalized pipe unable to parse input: ${value}`);
return;
}
if (isFrequency) {
const fequencyPrefix = Object.keys(this.frequencyPrefixes).filter(element =>
this.locale.includes(element),
);
this.value = [
fequencyPrefix.length > 0 ? this.frequencyPrefixes[fequencyPrefix[0]] : this.frequencyPrefixes.en,
moment.duration(value).humanize(),
].join(' ');
} else {
this.value = moment.duration(value).humanize();
}
}
}
@Injectable()
@Pipe({
name: 'metersLocalized',
pure: false,
})
export class MetersLocalizedPipe implements PipeTransform, OnDestroy {
locale: string;
onLangChange?: Subscription;
value = '';
constructor(private readonly translate: TranslateService) {
this.locale = translate.currentLang;
}
private _dispose(): void {
if (this.onLangChange?.closed === false) {
this.onLangChange?.unsubscribe();
}
}
ngOnDestroy(): void {
this._dispose();
}
transform(value: string | number | unknown): string {
this.updateValue(value);
this._dispose();
if (this.onLangChange?.closed === true) {
this.onLangChange = this.translate.onLangChange.subscribe((event: LangChangeEvent) => {
this.locale = event.lang;
this.updateValue(value);
});
}
return this.value;
}
updateValue(value: string | number | unknown) {
if (typeof value !== 'string' && typeof value !== 'number') {
logger.warn(`metersLocalized pipe unable to parse input: ${value}`);
return;
}
const imperialLocale = ['US', 'UK', 'LR', 'MM'].some(term => this.locale.includes(term));
const meters = typeof value === 'string' ? Number.parseFloat(value) : (value as number);
if (imperialLocale) {
const yards = meters * 1.0936;
const options = {
style: 'unit',
unit: yards >= 1760 ? 'mile' : 'yard',
maximumFractionDigits: yards >= 1760 ? 1 : 0,
} as unknown as Intl.NumberFormatOptions;
this.value = new Intl.NumberFormat(this.locale, options).format(yards >= 1760 ? yards / 1760 : yards);
} else {
const options = {
style: 'unit',
unit: meters >= 1000 ? 'kilometer' : 'meter',
maximumFractionDigits: meters >= 1000 ? 1 : 0,
} as unknown as Intl.NumberFormatOptions;
this.value = new Intl.NumberFormat(this.locale, options).format(
meters >= 1000 ? meters / 1000 : meters,
);
}
}
}
@Injectable()
@Pipe({
name: 'isNaN',
pure: true,
})
export class IsNaNPipe implements PipeTransform {
transform(value: unknown): boolean {
return Number.isNaN(value);
}
}
@Injectable()
@Pipe({
name: 'isNumeric',
pure: true,
})
export class IsNumericPipe implements PipeTransform {
transform(value: unknown): boolean {
return !Number.isNaN(
typeof value === 'number' ? value : typeof value === 'string' ? Number.parseFloat(value) : Number.NaN,
);
}
}
@Injectable()
@Pipe({
name: 'numberLocalized',
pure: true,
})
export class NumberLocalizedPipe implements PipeTransform, OnDestroy {
locale: string;
onLangChange?: Subscription;
value: string;
constructor(private readonly translate: TranslateService) {
this.locale = translate.currentLang;
}
private _dispose(): void {
if (this.onLangChange?.closed === false) {
this.onLangChange?.unsubscribe();
}
}
ngOnDestroy(): void {
this._dispose();
}
/**
* @param value The number to be formatted
* @param formatOptions Formatting options to include.
* As specified by Intl.NumberFormatOptions as comma seperated key:value pairs.
*/
transform(value: string | number | unknown, formatOptions?: string): string {
this.updateValue(value, formatOptions);
this._dispose();
if (this.onLangChange?.closed === true) {
this.onLangChange = this.translate.onLangChange.subscribe((event: LangChangeEvent) => {
this.locale = event.lang;
this.updateValue(value, formatOptions);
});
}
return this.value;
}
updateValue(value: string | number | unknown, formatOptions?: string): void {
if (typeof value !== 'string' && typeof value !== 'number') {
logger.warn(`numberLocalized pipe unable to parse input: ${value}`);
return;
}
const options = formatOptions
?.split(',')
.map(element => element.split(':'))
.reduce(
(accumulator, [key, value_]) => ({
...accumulator,
[key.trim()]: value_.trim(),
}),
{},
) as Intl.NumberFormatOptions;
const float = typeof value === 'string' ? Number.parseFloat(value) : (value as number);
this.value = new Intl.NumberFormat(this.locale, options).format(float);
}
}
@Injectable()
@Pipe({
name: 'dateFormat',
pure: true,
})
export class DateLocalizedFormatPipe implements PipeTransform, OnDestroy {
locale: string;
onLangChange?: Subscription;
value: string;
constructor(private readonly translate: TranslateService) {
this.locale = translate.currentLang;
}
private _dispose(): void {
if (this.onLangChange?.closed === false) {
this.onLangChange?.unsubscribe();
}
}
ngOnDestroy(): void {
this._dispose();
}
/**
* @param value The date to be formatted
* @param formatOptions Dateformat options to include.
* As specified by Intl.DateTimeFormatOptions as comma seperated key:value pairs
* Default is year,month,day,hour and minute in numeric representation e.g. (en-US) "8/6/2021, 10:35"
*/
transform(value: string | unknown, formatOptions?: string): string {
this.updateValue(value, formatOptions);
this._dispose();
if (this.onLangChange?.closed === true) {
this.onLangChange = this.translate.onLangChange.subscribe((event: LangChangeEvent) => {
this.locale = event.lang;
this.updateValue(value, formatOptions);
});
}
return this.value;
}
updateValue(value: string | Date | unknown, formatOptions?: string): void {
if (typeof value !== 'string' && Object.prototype.toString.call(value) !== '[object Date]') {
logger.warn(`dateFormat pipe unable to parse input: ${value}`);
return;
}
const options = formatOptions
?.split(',')
.map(element => element.split(':'))
.reduce(
(accumulator, [key, value_]) => ({
...accumulator,
[key.trim()]: value_.trim(),
}),
{},
) as Intl.DateTimeFormatOptions;
const date = typeof value === 'string' ? Date.parse(value) : (value as Date);
this.value = new Intl.DateTimeFormat(
this.locale,
options ?? {
day: 'numeric',
month: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
},
).format(date);
}
}

View File

@@ -0,0 +1,47 @@
import {Injectable, Pipe, PipeTransform} from '@angular/core';
import {TranslateService} from '@ngx-translate/core';
import {logger} from '../../_helpers/ts-logger';
@Injectable()
@Pipe({
name: 'dateFormat',
pure: true,
})
export class DateLocalizedFormatPipe implements PipeTransform {
constructor(private readonly translate: TranslateService) {}
/**
* @param value The date to be formatted
* @param formatOptions Dateformat options to include.
* As specified by Intl.DateTimeFormatOptions as comma seperated key:value pairs
* Default is year,month,day,hour and minute in numeric representation e.g. (en-US) "8/6/2021, 10:35"
*/
transform(value: string | unknown, formatOptions?: string): string {
if (typeof value !== 'string' && Object.prototype.toString.call(value) !== '[object Date]') {
logger.warn(`dateFormat pipe unable to parse input: ${value}`);
return '';
}
const options = formatOptions
?.split(',')
.map(element => element.split(':'))
.reduce(
(accumulator, [key, value_]) => ({
...accumulator,
[key.trim()]: value_.trim(),
}),
{},
) as Intl.DateTimeFormatOptions;
const date = typeof value === 'string' ? Date.parse(value) : (value as Date);
return new Intl.DateTimeFormat(
this.translate.currentLang,
options ?? {
day: 'numeric',
month: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
},
).format(date);
}
}

View File

@@ -0,0 +1,47 @@
import {Injectable, Pipe, PipeTransform} from '@angular/core';
import {TranslateService} from '@ngx-translate/core';
import {logger} from '../../_helpers/ts-logger';
import moment from 'moment/moment';
const frequencyPrefixes: {[iso6391Code: string]: string} = {
de: 'alle',
en: 'every',
es: 'cada',
pt: 'a cada',
fr: 'tous les',
cn: '每',
ru: 'kаждые',
};
@Injectable()
@Pipe({
name: 'durationLocalized',
pure: true,
})
export class DurationLocalizedPipe implements PipeTransform {
constructor(private readonly translate: TranslateService) {}
/**
* @param value An ISO 8601 duration string
* @param isFrequency Boolean indicating if this duration is to be interpreted as repeat frequency
*/
transform(value: string | unknown, isFrequency = false): string {
if (typeof value !== 'string') {
logger.warn(`durationLocalized pipe unable to parse input: ${value}`);
return '';
}
if (isFrequency) {
const fequencyPrefix = Object.keys(frequencyPrefixes).filter(element =>
this.translate.currentLang.includes(element),
);
return [
fequencyPrefix.length > 0 ? frequencyPrefixes[fequencyPrefix[0]] : frequencyPrefixes.en,
moment.duration(value).humanize(),
].join(' ');
} else {
return moment.duration(value).humanize();
}
}
}

View File

@@ -0,0 +1,12 @@
import {Injectable, Pipe, PipeTransform} from '@angular/core';
@Injectable()
@Pipe({
name: 'toUnix',
pure: true,
})
export class ToUnixPipe implements PipeTransform {
transform(value: string | number | Date | null | undefined): number {
return (value instanceof Date ? value : new Date(value ?? 0)).valueOf();
}
}

View File

@@ -0,0 +1,12 @@
import {Injectable, Pipe, PipeTransform} from '@angular/core';
@Injectable()
@Pipe({
name: 'isNaN',
pure: true,
})
export class IsNaNPipe implements PipeTransform {
transform(value: unknown): boolean {
return Number.isNaN(value);
}
}

View File

@@ -0,0 +1,14 @@
import {Injectable, Pipe, PipeTransform} from '@angular/core';
@Injectable()
@Pipe({
name: 'isNumeric',
pure: true,
})
export class IsNumericPipe implements PipeTransform {
transform(value: unknown): boolean {
return !Number.isNaN(
typeof value === 'number' ? value : typeof value === 'string' ? Number.parseFloat(value) : Number.NaN,
);
}
}

View File

@@ -0,0 +1,43 @@
import {Injectable, Pipe, PipeTransform} from '@angular/core';
import {TranslateService} from '@ngx-translate/core';
import {logger} from '../../_helpers/ts-logger';
@Injectable()
@Pipe({
name: 'metersLocalized',
pure: true,
})
export class MetersLocalizedPipe implements PipeTransform {
constructor(private readonly translate: TranslateService) {}
transform(value: string | number | unknown): string {
if (typeof value !== 'string' && typeof value !== 'number') {
logger.warn(`metersLocalized pipe unable to parse input: ${value}`);
return '';
}
const imperialLocale = ['US', 'UK', 'LR', 'MM'].some(term => this.translate.currentLang.includes(term));
const meters = typeof value === 'string' ? Number.parseFloat(value) : (value as number);
if (imperialLocale) {
const yards = meters * 1.0936;
const options = {
style: 'unit',
unit: yards >= 1760 ? 'mile' : 'yard',
maximumFractionDigits: yards >= 1760 ? 1 : 0,
} as unknown as Intl.NumberFormatOptions;
return new Intl.NumberFormat(this.translate.currentLang, options).format(
yards >= 1760 ? yards / 1760 : yards,
);
} else {
const options = {
style: 'unit',
unit: meters >= 1000 ? 'kilometer' : 'meter',
maximumFractionDigits: meters >= 1000 ? 1 : 0,
} as unknown as Intl.NumberFormatOptions;
return new Intl.NumberFormat(this.translate.currentLang, options).format(
meters >= 1000 ? meters / 1000 : meters,
);
}
}
}

View File

@@ -0,0 +1,37 @@
import {Injectable, Pipe, PipeTransform} from '@angular/core';
import {TranslateService} from '@ngx-translate/core';
import {logger} from '../../_helpers/ts-logger';
@Injectable()
@Pipe({
name: 'numberLocalized',
pure: true,
})
export class NumberLocalizedPipe implements PipeTransform {
constructor(private readonly translate: TranslateService) {}
/**
* @param value The number to be formatted
* @param formatOptions Formatting options to include.
* As specified by Intl.NumberFormatOptions as comma seperated key:value pairs.
*/
transform(value: string | number | unknown, formatOptions?: string): string {
if (typeof value !== 'string' && typeof value !== 'number') {
logger.warn(`numberLocalized pipe unable to parse input: ${value}`);
return '';
}
const options = formatOptions
?.split(',')
.map(element => element.split(':'))
.reduce(
(accumulator, [key, value_]) => ({
...accumulator,
[key.trim()]: value_.trim(),
}),
{},
) as Intl.NumberFormatOptions;
const float = typeof value === 'string' ? Number.parseFloat(value) : (value as number);
return new Intl.NumberFormat(this.translate.currentLang, options).format(float);
}
}

View File

@@ -12,40 +12,23 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Injectable, OnDestroy, Pipe, PipeTransform} from '@angular/core';
import {Subscription} from 'rxjs';
import {TranslateService} from '@ngx-translate/core';
import {Injectable, Pipe, PipeTransform} from '@angular/core';
import {ThingTranslateService} from './thing-translate.service';
import {isThing, SCThings, SCThingType} from '@openstapps/core';
@Injectable()
@Pipe({
name: 'propertyNameTranslate',
pure: false, // required to update the value when the promise is resolved
pure: true,
})
export class PropertyNameTranslatePipe implements PipeTransform, OnDestroy {
value: unknown;
lastKey?: string;
lastType: string;
onLangChange: Subscription;
constructor(
private readonly translate: TranslateService,
private readonly thingTranslate: ThingTranslateService,
) {}
updateValue(key: string, type: string): void {
this.value = this.thingTranslate.getPropertyName(type as SCThingType, key);
}
export class PropertyNameTranslatePipe implements PipeTransform {
constructor(private readonly thingTranslate: ThingTranslateService) {}
transform<K extends string, Q extends keyof Extract<SCThings, {type: K}>>(query: Q, type: K): string;
transform<T extends SCThings, K extends keyof T>(query: K, thing: T): string;
transform(query: unknown, thingOrType: SCThings | string | unknown): unknown {
if (typeof query !== 'string' || query.length <= 0) {
return query;
return query as never;
}
if (!isThing(thingOrType) && typeof thingOrType !== 'string') {
@@ -54,37 +37,9 @@ export class PropertyNameTranslatePipe implements PipeTransform, OnDestroy {
);
}
// store the params, in case they change
this.lastKey = query;
this.lastType = typeof thingOrType === 'string' ? thingOrType : thingOrType.type;
this.updateValue(query, this.lastType);
// if there is a subscription to onLangChange, clean it
this._dispose();
if (this.onLangChange?.closed ?? true) {
this.onLangChange = this.translate.onLangChange.subscribe(() => {
if (typeof this.lastKey === 'string') {
this.lastKey = undefined; // we want to make sure it doesn't return the same value until it's been updated
this.updateValue(query, this.lastType);
}
});
}
return this.value;
}
/**
* Clean any existing subscription to change events
*/
private _dispose(): void {
if (this.onLangChange?.closed) {
this.onLangChange?.unsubscribe();
}
}
ngOnDestroy(): void {
this._dispose();
return this.thingTranslate.getPropertyName(
isThing(thingOrType) ? thingOrType.type : (thingOrType as SCThingType),
query,
);
}
}

View File

@@ -0,0 +1,18 @@
import {Injectable, Pipe, PipeTransform} from '@angular/core';
@Injectable()
@Pipe({
name: 'sentencecase',
pure: true,
})
export class SentenceCasePipe implements PipeTransform {
transform(aString: string | unknown): string {
if (typeof aString !== 'string') {
throw new SyntaxError(
`Wrong parameter in StringSplitPipe. Expected a valid String, received: ${aString}`,
);
}
return aString.slice(0, 1).toUpperCase() + aString.slice(1);
}
}

View File

@@ -0,0 +1,22 @@
import {Injectable, Pipe, PipeTransform} from '@angular/core';
@Injectable()
@Pipe({
name: 'split',
pure: true,
})
export class StringSplitPipe implements PipeTransform {
transform(aString: string | unknown, splitter: string | unknown): unknown[] {
if (typeof splitter !== 'string' || splitter.length <= 0) {
return [];
}
if (typeof aString !== 'string') {
throw new SyntaxError(
`Wrong parameter in StringSplitPipe. Expected a valid String, received: ${aString}`,
);
}
return aString.split(splitter);
}
}

View File

@@ -13,25 +13,23 @@
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {ModuleWithProviders, NgModule, Provider} from '@angular/core';
import {
ArrayJoinPipe,
DateLocalizedFormatPipe,
DurationLocalizedPipe,
EntriesPipe,
IsNaNPipe,
IsNumericPipe,
MetersLocalizedPipe,
NumberLocalizedPipe,
SentenceCasePipe,
StringSplitPipe,
ToUnixPipe,
} from './common-string-pipes';
import {ThingTranslateDefaultParser, ThingTranslateParser} from './thing-translate.parser';
import {ThingTranslatePipe} from './thing-translate.pipe';
import {ThingTranslateService} from './thing-translate.service';
import {IonIconModule} from '../util/ion-icon/ion-icon.module';
import {TranslateSimplePipe} from './translate-simple.pipe';
import {PropertyNameTranslatePipe} from './property-name-translate.pipe';
import {DateLocalizedFormatPipe} from './date-time/date-format.pipe';
import {NumberLocalizedPipe} from './numbers/number-localized.pipe';
import {IsNaNPipe} from './numbers/is-nan.pipe';
import {IsNumericPipe} from './numbers/is-numeric.pipe';
import {MetersLocalizedPipe} from './numbers/meters-localized.pipe';
import {DurationLocalizedPipe} from './date-time/duration-localized.pipe';
import {ToUnixPipe} from './date-time/to-unix.pipe';
import {ArrayJoinPipe} from './collections/join.pipe';
import {StringSplitPipe} from './strings/split.pipe';
import {SentenceCasePipe} from './strings/sentence-case.pipe';
import {EntriesPipe} from './collections/entries.pipe';
export interface ThingTranslateModuleConfig {
parser?: Provider;

View File

@@ -13,7 +13,6 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Injectable} from '@angular/core';
/* eslint-disable @typescript-eslint/member-ordering, class-methods-use-this */
@@ -51,10 +50,3 @@ export class ThingTranslateDefaultParser extends ThingTranslateParser {
return property;
}
}
/**
* TODO
*/
export function isDefined<T>(value?: T | null): value is T {
return value !== undefined && value !== null;
}

View File

@@ -12,35 +12,17 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Injectable, OnDestroy, Pipe, PipeTransform} from '@angular/core';
import {TranslateService} from '@ngx-translate/core';
import {Injectable, Pipe, PipeTransform} from '@angular/core';
import {isThing, SCThings, SCThingWithoutReferences} from '@openstapps/core';
import {Subscription} from 'rxjs';
import {ThingTranslateService} from './thing-translate.service';
@Injectable()
@Pipe({
name: 'thingTranslate',
pure: false, // required to update the value when the promise is resolved
pure: true,
})
export class ThingTranslatePipe implements PipeTransform, OnDestroy {
value: unknown;
lastKey?: string;
lastThing: SCThingWithoutReferences;
onLangChange: Subscription;
constructor(
private readonly translate: TranslateService,
// private readonly _ref: ChangeDetectorRef,
private readonly thingTranslate: ThingTranslateService,
) {}
updateValue(key: string, thing: SCThingWithoutReferences): void {
this.value = this.thingTranslate.get(thing as SCThings, key);
}
export class ThingTranslatePipe implements PipeTransform {
constructor(private readonly thingTranslate: ThingTranslateService) {}
transform<T extends SCThingWithoutReferences, P extends string[] | string | keyof T>(
query: P,
@@ -56,37 +38,6 @@ export class ThingTranslatePipe implements PipeTransform, OnDestroy {
);
}
// store the params, in case they change
this.lastKey = query;
this.lastThing = thing;
this.updateValue(query, thing);
// if there is a subscription to onLangChange, clean it
this._dispose();
if (this.onLangChange?.closed ?? true) {
this.onLangChange = this.translate.onLangChange.subscribe(() => {
if (typeof this.lastKey === 'string') {
this.lastKey = undefined; // we want to make sure it doesn't return the same value until it's been updated
this.updateValue(query, thing);
}
});
}
return this.value as never;
}
/**
* Clean any existing subscription to change events
*/
private _dispose(): void {
if (this.onLangChange?.closed) {
this.onLangChange?.unsubscribe();
}
}
ngOnDestroy(): void {
this._dispose();
return this.thingTranslate.get(thing as SCThings, query) as never;
}
}

View File

@@ -13,21 +13,9 @@
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Injectable} from '@angular/core';
import {LangChangeEvent, TranslateService} from '@ngx-translate/core';
import {
SCLanguage,
SCLanguageCode,
SCThings,
SCThingTranslator,
SCThingType,
SCTranslations,
} from '@openstapps/core';
import moment from 'moment';
import {isDefined, ThingTranslateParser} from './thing-translate.parser';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {setDefaultOptions} from 'date-fns';
import {DateFnsConfigurationService} from 'ngx-date-fns';
import {getDateFnsLocale} from './dfns-locale';
import {TranslateService} from '@ngx-translate/core';
import {SCLanguageCode, SCThings, SCThingTranslator, SCThingType} from '@openstapps/core';
import {ThingTranslateParser} from './thing-translate.parser';
// export const DEFAULT_LANGUAGE = new InjectionToken<string>('DEFAULT_LANGUAGE');
@@ -40,28 +28,16 @@ export class ThingTranslateService {
translator: SCThingTranslator;
/**
*
* @param translateService Instance of Angular TranslateService
* @param parser An instance of the parser currently used
* @param dfnsConfiguration the date fns configuration
*/
constructor(
private readonly translateService: TranslateService,
translateService: TranslateService,
public parser: ThingTranslateParser,
private dfnsConfiguration: DateFnsConfigurationService,
) {
this.translator = new SCThingTranslator(
(translateService.currentLang ?? translateService.defaultLang) as SCLanguageCode,
);
/** set the default language from configuration */
this.translateService.onLangChange.pipe(takeUntilDestroyed()).subscribe((event: LangChangeEvent) => {
this.translator.language = event.lang as keyof SCTranslations<SCLanguage>;
moment.locale(event.lang);
getDateFnsLocale(event.lang as SCLanguageCode).then(locale => {
setDefaultOptions({locale});
this.dfnsConfiguration.setLocale(locale);
});
});
}
/**
@@ -83,7 +59,7 @@ export class ThingTranslateService {
keyPath: string | string[],
// eslint-disable-next-line @typescript-eslint/ban-types
): string | number | boolean | object {
if (!isDefined(keyPath) || keyPath.length === 0) {
if (!keyPath || keyPath.length === 0) {
throw new Error(`Parameter "keyPath" required`);
}
if (Array.isArray(keyPath)) {
@@ -101,10 +77,10 @@ export class ThingTranslateService {
*/
public getPropertyName(type: SCThingType, keyPath: string | string[]): string {
const translatedPropertyNames = this.translator.translatedPropertyNames(type);
if (!isDefined(translatedPropertyNames)) {
if (!translatedPropertyNames) {
throw new Error(`Parameter "type" is an invalid SCThingType`);
}
if (!isDefined(keyPath) || keyPath.length === 0) {
if (!keyPath || keyPath.length === 0) {
throw new Error(`Parameter "keyPath" required`);
}
if (Array.isArray(keyPath)) {

View File

@@ -12,59 +12,29 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {DestroyRef, inject, Injectable, Pipe, PipeTransform} from '@angular/core';
import {Injectable, Pipe, PipeTransform} from '@angular/core';
import {TranslateService} from '@ngx-translate/core';
import {get} from '@openstapps/collection-utils';
import {Subscription} from 'rxjs';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
@Injectable()
@Pipe({
name: 'translateSimple',
pure: false,
pure: true,
})
export class TranslateSimplePipe implements PipeTransform {
value: unknown;
query: unknown;
thing: unknown;
onLangChange: Subscription;
destroy$ = inject(DestroyRef);
constructor(private readonly translate: TranslateService) {}
// eslint-disable-next-line @typescript-eslint/ban-types
private updateValue() {
try {
this.value =
get(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.thing as any).translations[this.translate.currentLang] ?? this.thing,
this.query as string,
) ?? this.thing;
} catch (error) {
console.warn(`${this.query}: ${error}`);
this.value = this.thing;
}
}
transform<T extends object, P extends keyof T>(query: P, thing: T): T[P];
transform<T extends object, P extends string | string[]>(query: P, thing: T): P | unknown {
// store the params, in case they change
this.query = query;
this.thing = thing;
this.updateValue();
this.onLangChange ??= this.translate.onLangChange
.pipe(takeUntilDestroyed(this.destroy$))
.subscribe(() => {
this.updateValue();
});
return this.value as never;
try {
return (get(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(thing as any).translations[this.translate.currentLang] ?? thing,
query as string,
) ?? thing) as never;
} catch (error) {
console.warn(`${query.toString()}: ${error}`);
return thing as never;
}
}
}

View File

@@ -550,6 +550,7 @@
"resetToast.message": "Einstellungen wurden zurückgesetzt",
"title": "Einstellungen",
"resetSettings": "Einstellungen zurücksetzen",
"reloadPage": "Aktualisierung erforderlich",
"calendar": {
"title": "Kalender",
"sync": {

View File

@@ -550,6 +550,7 @@
"resetToast.message": "Settings reset",
"title": "Settings",
"resetSettings": "Reset Settings",
"reloadPage": "Reload required",
"calendar": {
"title": "Calendar",
"sync": {

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#3880ff" />
<title>StApps</title>
<base href="/" />
@@ -24,7 +25,7 @@
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
</head>
<body style="background-color: var(--ion-color-primary)">
<body style="background: var(--ion-color-primary)">
<app-root></app-root>
</body>
</html>

6
pnpm-lock.yaml generated
View File

@@ -842,6 +842,9 @@ importers:
deepmerge:
specifier: 4.3.1
version: 4.3.1
fast-deep-equal:
specifier: 3.1.3
version: 3.1.3
form-data:
specifier: 4.0.0
version: 4.0.0
@@ -1032,9 +1035,6 @@ importers:
eslint-plugin-unicorn:
specifier: 47.0.0
version: 47.0.0(eslint@8.43.0)
fast-deep-equal:
specifier: 3.1.3
version: 3.1.3
fontkit:
specifier: 2.0.2
version: 2.0.2