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

@@ -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>