mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-09 11:12:52 +00:00
feat: timetable module - schedule and calendar
This commit is contained in:
763
package-lock.json
generated
763
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,7 @@
|
||||
"test": "ng test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "9.1.13",
|
||||
"@angular/cdk": "12.0.0",
|
||||
"@angular/common": "9.1.12",
|
||||
"@angular/core": "9.1.12",
|
||||
@@ -51,6 +52,7 @@
|
||||
"@asymmetrik/ngx-leaflet-markercluster": "2.1.1",
|
||||
"@capacitor/core": "2.4.6",
|
||||
"@ionic-native/core": "5.29.0",
|
||||
"@ionic-native/dialogs": "5.31.1",
|
||||
"@ionic-native/diagnostic": "5.32.0",
|
||||
"@ionic-native/geolocation": "5.29.0",
|
||||
"@ionic-native/network": "5.31.1",
|
||||
@@ -68,6 +70,7 @@
|
||||
"cordova-ios": "6.2.0",
|
||||
"cordova-plugin-androidx-adapter": "1.1.3",
|
||||
"cordova-plugin-device": "2.0.3",
|
||||
"cordova-plugin-dialogs": "2.0.2",
|
||||
"cordova.plugins.diagnostic": "6.0.3",
|
||||
"cordova-plugin-geolocation": "4.1.0",
|
||||
"cordova-plugin-ionic-keyboard": "2.2.0",
|
||||
|
||||
93
src/app/animation/animation-choreographer.ts
Normal file
93
src/app/animation/animation-choreographer.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (C) 2021 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 {SHARED_AXIS_DIRECTIONS} from './material-motion';
|
||||
|
||||
/**
|
||||
* /**
|
||||
* Choreograph a shared axis animation based on a row of values so that changing state
|
||||
* results in the correct, expected behavior of reverting the previous animation etc.
|
||||
*
|
||||
* The Choreographer manages motion of an element that changes value. This can be used in a variety of ways,
|
||||
* for example multi-view choreographing can be achieved as such
|
||||
*
|
||||
* ```html
|
||||
* <div [ngSwitch]='choreographer.state'
|
||||
* [@animation]='choreographer.animationState'
|
||||
* [@animation.done]='choreographer.done()'>
|
||||
* <div *ngSwitchCase='"a"'/>
|
||||
* <div *ngSwitchCase='"b"'/>
|
||||
* </div>
|
||||
* ```
|
||||
*
|
||||
* @see {@link https://material.io/design/motion/the-motion-system.html#shared-axis}
|
||||
*/
|
||||
export class SharedAxisChoreographer<T> {
|
||||
/**
|
||||
* Expected next value
|
||||
*/
|
||||
private expectedValue: T;
|
||||
|
||||
/**
|
||||
* Animation State
|
||||
*/
|
||||
animationState: string;
|
||||
|
||||
/**
|
||||
* Current value to read from
|
||||
*/
|
||||
currentValue: T;
|
||||
|
||||
constructor(initialValue: T, readonly pages?: T[]) {
|
||||
this.currentValue = initialValue;
|
||||
this.expectedValue = initialValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Must be linked to the animation callback
|
||||
*/
|
||||
animationDone() {
|
||||
this.animationState = 'in';
|
||||
this.currentValue = this.expectedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change view for a new state that the current active view should receive
|
||||
*/
|
||||
changeViewForState(newValue: T, direction?: -1 | 0 | 1) {
|
||||
if (direction === 0) {
|
||||
this.currentValue = this.expectedValue = newValue;
|
||||
return;
|
||||
}
|
||||
|
||||
this.expectedValue = newValue;
|
||||
|
||||
// pre-place animation state
|
||||
// new element comes in from the right and pushes the old one to the left
|
||||
this.animationState =
|
||||
SHARED_AXIS_DIRECTIONS[
|
||||
direction ?? this.getDirection(this.currentValue, newValue)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get direction from to
|
||||
*/
|
||||
getDirection(from: T, to: T) {
|
||||
const element = this.pages?.find(it => it === from || it === to);
|
||||
|
||||
return element === from ? 1 : element === to ? -1 : 0;
|
||||
}
|
||||
}
|
||||
103
src/app/animation/material-motion.ts
Normal file
103
src/app/animation/material-motion.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright (C) 2021 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 {
|
||||
animate,
|
||||
sequence,
|
||||
state,
|
||||
style,
|
||||
transition,
|
||||
trigger,
|
||||
} from '@angular/animations';
|
||||
|
||||
/**
|
||||
* Fade transition
|
||||
*
|
||||
* @see {@link https://material.io/design/motion/the-motion-system.html#fade}
|
||||
*/
|
||||
export const materialFade = trigger('materialFade', [
|
||||
state('in', style({opacity: 1})),
|
||||
transition(':enter', [style({opacity: 0}), animate('250ms ease')]),
|
||||
transition(':leave', [animate('200ms ease', style({opacity: 0}))]),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Fade transition
|
||||
*
|
||||
* @see {@link https://material.io/design/motion/the-motion-system.html#fade}
|
||||
*/
|
||||
export const materialManualFade = trigger('materialManualFade', [
|
||||
state('in', style({opacity: 1})),
|
||||
state('out', style({opacity: 0})),
|
||||
transition('in => out', animate('200ms ease')),
|
||||
transition('out => in', animate('250ms ease')),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Fade through transition
|
||||
*
|
||||
* @see {@link https://material.io/design/motion/the-motion-system.html#fade-through}
|
||||
*/
|
||||
export const materialFadeThrough = trigger('materialFadeThrough', [
|
||||
state('in', style({transform: 'scale(100%)', opacity: 1})),
|
||||
transition(':enter', [
|
||||
style({transform: 'scale(80%)', opacity: 0}),
|
||||
animate('250ms ease'),
|
||||
]),
|
||||
transition(':leave', [animate('200ms ease', style({opacity: 0}))]),
|
||||
]);
|
||||
|
||||
export const SHARED_AXIS_DIRECTIONS = {
|
||||
[-1]: 'go-backward',
|
||||
[0]: 'in',
|
||||
[1]: 'go-forward',
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared axis transition along the X-Axis
|
||||
*
|
||||
* Needs to be manually choreographed
|
||||
*
|
||||
* @see {@link https://material.io/design/motion/the-motion-system.html#shared-axis}
|
||||
* @see {SharedAxisChoreographer}
|
||||
*/
|
||||
export const materialSharedAxisX = trigger('materialSharedAxisX', [
|
||||
state(
|
||||
SHARED_AXIS_DIRECTIONS[-1],
|
||||
style({opacity: 0, transform: 'translateX(30px)'}),
|
||||
),
|
||||
state(
|
||||
SHARED_AXIS_DIRECTIONS[0],
|
||||
style({opacity: 1, transform: 'translateX(0)'}),
|
||||
),
|
||||
state(
|
||||
SHARED_AXIS_DIRECTIONS[1],
|
||||
style({opacity: 0, transform: 'translateX(-30px)'}),
|
||||
),
|
||||
transition(
|
||||
`${SHARED_AXIS_DIRECTIONS[-1]} => ${SHARED_AXIS_DIRECTIONS[0]}`,
|
||||
sequence([
|
||||
style({opacity: 0, transform: 'translateX(-30px)'}),
|
||||
animate('100ms ease-out'),
|
||||
]),
|
||||
),
|
||||
transition(`${SHARED_AXIS_DIRECTIONS[0]} => *`, animate('100ms ease-out')),
|
||||
transition(
|
||||
`${SHARED_AXIS_DIRECTIONS[1]} => ${SHARED_AXIS_DIRECTIONS[0]}`,
|
||||
sequence([
|
||||
style({opacity: 0, transform: 'translateX(30px)'}),
|
||||
animate('100ms ease-out'),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (C) 2021 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 {animate, style, transition, trigger} from '@angular/animations';
|
||||
|
||||
export const chipTransition = trigger('chipTransition', [
|
||||
transition(':enter', [
|
||||
style({
|
||||
'opacity': 0,
|
||||
'transform': 'scaleX(80%)',
|
||||
'transform-origin': 'left',
|
||||
}),
|
||||
animate('200ms ease', style({opacity: 1, transform: 'scaleX(100%)'})),
|
||||
]),
|
||||
]);
|
||||
|
||||
export const chipSkeletonTransition = trigger('chipSkeletonTransition', [
|
||||
transition(':leave', [
|
||||
style({
|
||||
'opacity': 1,
|
||||
'transform': 'scaleX(100%)',
|
||||
'transform-origin': 'left',
|
||||
}),
|
||||
animate('200ms ease', style({opacity: 0, transform: 'scaleX(120%)'})),
|
||||
]),
|
||||
]);
|
||||
@@ -23,8 +23,8 @@ import localeDe from '@angular/common/locales/de';
|
||||
import {APP_INITIALIZER, NgModule, Provider} from '@angular/core';
|
||||
import {BrowserModule} from '@angular/platform-browser';
|
||||
import {RouteReuseStrategy} from '@angular/router';
|
||||
import {Diagnostic} from '@ionic-native/diagnostic/ngx';
|
||||
import {SplashScreen} from '@ionic-native/splash-screen/ngx';
|
||||
import {Diagnostic} from '@ionic-native/diagnostic/ngx';
|
||||
import {StatusBar} from '@ionic-native/status-bar/ngx';
|
||||
import {IonicModule, IonicRouteStrategy} from '@ionic/angular';
|
||||
import {
|
||||
@@ -45,12 +45,15 @@ import {DataModule} from './modules/data/data.module';
|
||||
import {MapModule} from './modules/map/map.module';
|
||||
import {MenuModule} from './modules/menu/menu.module';
|
||||
import {NewsModule} from './modules/news/news.module';
|
||||
import {ScheduleModule} from './modules/schedule/schedule.module';
|
||||
import {SettingsModule} from './modules/settings/settings.module';
|
||||
import {SettingsProvider} from './modules/settings/settings.provider';
|
||||
import {StorageModule} from './modules/storage/storage.module';
|
||||
import {ThingTranslateModule} from './translation/thing-translate.module';
|
||||
import {fakeBackendProvider} from './_helpers/fake-backend.interceptor';
|
||||
import {UtilModule} from './util/util.module';
|
||||
import {initLogger} from './_helpers/ts-logger';
|
||||
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
|
||||
|
||||
registerLocaleData(localeDe);
|
||||
|
||||
@@ -132,6 +135,7 @@ const providers: Provider[] = [
|
||||
imports: [
|
||||
AppRoutingModule,
|
||||
BrowserModule,
|
||||
BrowserAnimationsModule,
|
||||
CommonModule,
|
||||
ConfigModule,
|
||||
DataModule,
|
||||
@@ -139,6 +143,7 @@ const providers: Provider[] = [
|
||||
MapModule,
|
||||
MenuModule,
|
||||
NewsModule,
|
||||
ScheduleModule,
|
||||
SettingsModule,
|
||||
StorageModule,
|
||||
ThingTranslateModule.forRoot(),
|
||||
@@ -149,6 +154,7 @@ const providers: Provider[] = [
|
||||
useFactory: createTranslateLoader,
|
||||
},
|
||||
}),
|
||||
UtilModule,
|
||||
// use maximal logging level when not in production, minimal (log only fatal errors) in production
|
||||
LoggerModule.forRoot({
|
||||
level: environment.production
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*ngIf="applicable['locate']()"
|
||||
[item]="item"
|
||||
></stapps-locate-action-chip>
|
||||
<!-- Add Event Chip needs to load data and should be the last -->
|
||||
<stapps-add-event-action-chip
|
||||
*ngIf="applicable['event']()"
|
||||
[item]="item"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/*
|
||||
* Copyright (C) 2021 StApps
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
@@ -12,10 +13,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 {ChangeDetectorRef, Component, Input, OnInit} from '@angular/core';
|
||||
import {SCDateSeries} from '@openstapps/core';
|
||||
import {every, groupBy, some, sortBy, values} from 'lodash-es';
|
||||
import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
} from '@angular/core';
|
||||
import {PopoverController} from '@ionic/angular';
|
||||
import {SCDateSeries, SCUuid} from '@openstapps/core';
|
||||
import {
|
||||
difference,
|
||||
every,
|
||||
flatMap,
|
||||
groupBy,
|
||||
mapValues,
|
||||
some,
|
||||
sortBy,
|
||||
union,
|
||||
values,
|
||||
} from 'lodash-es';
|
||||
import {capitalize, last} from 'lodash-es';
|
||||
import {Subscription} from 'rxjs';
|
||||
import {ScheduleProvider} from '../../schedule/schedule.provider';
|
||||
|
||||
enum Selection {
|
||||
ON = 2,
|
||||
@@ -28,7 +48,6 @@ enum Selection {
|
||||
*
|
||||
* The generic is to preserve type safety of how deep the tree goes.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
class TreeNode<T extends TreeNode<any> | SelectionValue> {
|
||||
/**
|
||||
* Value of this node
|
||||
@@ -54,19 +73,16 @@ class TreeNode<T extends TreeNode<any> | SelectionValue> {
|
||||
* Accumulate values of children to set current value
|
||||
*/
|
||||
private accumulateApplyValues() {
|
||||
const selections: number[] = this.children.map(
|
||||
it =>
|
||||
/* eslint-disable unicorn/no-nested-ternary */
|
||||
it instanceof TreeNode
|
||||
? it.checked
|
||||
? Selection.ON
|
||||
: it.indeterminate
|
||||
? Selection.PARTIAL
|
||||
: Selection.OFF
|
||||
: (it as SelectionValue).selected
|
||||
const selections: number[] = this.children.map(it =>
|
||||
it instanceof TreeNode
|
||||
? it.checked
|
||||
? Selection.ON
|
||||
: Selection.OFF,
|
||||
/* eslint-enable unicorn/no-nested-ternary */
|
||||
: it.indeterminate
|
||||
? Selection.PARTIAL
|
||||
: Selection.OFF
|
||||
: (it as SelectionValue).selected
|
||||
? Selection.ON
|
||||
: Selection.OFF,
|
||||
);
|
||||
|
||||
this.checked = every(selections, it => it === Selection.ON);
|
||||
@@ -83,7 +99,7 @@ class TreeNode<T extends TreeNode<any> | SelectionValue> {
|
||||
if (child instanceof TreeNode) {
|
||||
child.checked = this.checked;
|
||||
child.indeterminate = false;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// tslint:disable-next-line:no-any
|
||||
(child as TreeNode<any>).applyValueDownwards();
|
||||
} else {
|
||||
(child as SelectionValue).selected = this.checked;
|
||||
@@ -149,7 +165,7 @@ interface SelectionValue {
|
||||
templateUrl: 'add-event-popover.html',
|
||||
styleUrls: ['add-event-popover.scss'],
|
||||
})
|
||||
export class AddEventPopoverComponent implements OnInit {
|
||||
export class AddEventPopoverComponent implements OnInit, OnDestroy {
|
||||
/**
|
||||
* Lodash alias
|
||||
*/
|
||||
@@ -170,23 +186,70 @@ export class AddEventPopoverComponent implements OnInit {
|
||||
*/
|
||||
selection: TreeNode<TreeNode<SelectionValue>>;
|
||||
|
||||
constructor(readonly ref: ChangeDetectorRef) {}
|
||||
/**
|
||||
* Uuids
|
||||
*/
|
||||
uuids: SCUuid[];
|
||||
|
||||
/**
|
||||
* Uuid Subscription
|
||||
*/
|
||||
uuidSubscription: Subscription;
|
||||
|
||||
constructor(
|
||||
readonly ref: ChangeDetectorRef,
|
||||
readonly scheduleProvider: ScheduleProvider,
|
||||
readonly popoverController: PopoverController,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Destroy
|
||||
*/
|
||||
ngOnDestroy() {
|
||||
this.uuidSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Init
|
||||
*/
|
||||
ngOnInit() {
|
||||
this.selection = new TreeNode(
|
||||
values(
|
||||
groupBy(
|
||||
sortBy(
|
||||
this.items.map(item => ({selected: false, item: item})),
|
||||
it => it.item.frequency,
|
||||
),
|
||||
it => it.item.frequency,
|
||||
),
|
||||
).map(item => new TreeNode(item, this.ref)),
|
||||
this.ref,
|
||||
this.uuidSubscription = this.scheduleProvider.uuids$.subscribe(
|
||||
async result => {
|
||||
this.uuids = result;
|
||||
|
||||
this.selection = new TreeNode(
|
||||
values(
|
||||
groupBy(
|
||||
sortBy(
|
||||
this.items.map(item => ({
|
||||
selected: this.uuids.includes(item.uid),
|
||||
item: item,
|
||||
})),
|
||||
it => it.item.frequency,
|
||||
),
|
||||
it => it.item.frequency,
|
||||
),
|
||||
).map(item => new TreeNode(item, this.ref)),
|
||||
this.ref,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* On selection change
|
||||
*/
|
||||
async onCommit(save: boolean) {
|
||||
if (save) {
|
||||
const {false: unselected, true: selected} = mapValues(
|
||||
groupBy(flatMap(this.selection.children, 'children'), 'selected'),
|
||||
value => value.map(it => it.item.uid),
|
||||
);
|
||||
this.scheduleProvider.uuids$.next(
|
||||
union(difference(this.uuids, unselected), selected),
|
||||
);
|
||||
}
|
||||
|
||||
await this.popoverController.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,4 +45,12 @@
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-item-group>
|
||||
<div class="action-buttons">
|
||||
<ion-button (click)="onCommit(false)" fill="clear">{{
|
||||
'abort' | translate
|
||||
}}</ion-button>
|
||||
<ion-button (click)="onCommit(true)" fill="clear">{{
|
||||
'ok' | translate
|
||||
}}</ion-button>
|
||||
</div>
|
||||
</ion-card-content>
|
||||
|
||||
@@ -5,3 +5,7 @@
|
||||
ion-card-content {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
float: right;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable class-methods-use-this */
|
||||
/* tslint:disable:prefer-function-over-method */
|
||||
/*
|
||||
* Copyright (C) 2021 StApps
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
@@ -13,11 +13,18 @@
|
||||
* 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, Input, OnInit} from '@angular/core';
|
||||
import {Component, Input, OnDestroy, OnInit} from '@angular/core';
|
||||
import {PopoverController} from '@ionic/angular';
|
||||
import {SCDateSeries, SCThing, SCThingType} from '@openstapps/core';
|
||||
import {DataProvider} from '../../data.provider';
|
||||
import {SCDateSeries, SCThing, SCThingType, SCUuid} from '@openstapps/core';
|
||||
import {difference, map} from 'lodash-es';
|
||||
import {Subscription} from 'rxjs';
|
||||
import {ScheduleProvider} from '../../../schedule/schedule.provider';
|
||||
import {AddEventPopoverComponent} from '../add-event-popover.component';
|
||||
import {CoordinatedSearchProvider} from '../../coordinated-search.provider';
|
||||
import {
|
||||
chipSkeletonTransition,
|
||||
chipTransition,
|
||||
} from '../../../../animation/skeleton-transitions/chip-loading-transition';
|
||||
|
||||
enum AddEventStates {
|
||||
ADDED_ALL,
|
||||
@@ -33,8 +40,9 @@ enum AddEventStates {
|
||||
selector: 'stapps-add-event-action-chip',
|
||||
templateUrl: 'add-event-action-chip.html',
|
||||
styleUrls: ['add-event-action-chip.scss'],
|
||||
animations: [chipSkeletonTransition, chipTransition],
|
||||
})
|
||||
export class AddEventActionChipComponent implements OnInit {
|
||||
export class AddEventActionChipComponent implements OnInit, OnDestroy {
|
||||
/**
|
||||
* Associated date series
|
||||
*/
|
||||
@@ -91,9 +99,20 @@ export class AddEventActionChipComponent implements OnInit {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* UUIDs
|
||||
*/
|
||||
uuids: SCUuid[];
|
||||
|
||||
/**
|
||||
* UUID Subscription
|
||||
*/
|
||||
uuidSubscription: Subscription;
|
||||
|
||||
constructor(
|
||||
readonly popoverController: PopoverController,
|
||||
readonly dataProvider: DataProvider,
|
||||
readonly dataProvider: CoordinatedSearchProvider,
|
||||
readonly scheduleProvider: ScheduleProvider,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -107,6 +126,13 @@ export class AddEventActionChipComponent implements OnInit {
|
||||
this.disabled = disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO
|
||||
*/
|
||||
ngOnDestroy() {
|
||||
this.uuidSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Init
|
||||
*/
|
||||
@@ -115,7 +141,7 @@ export class AddEventActionChipComponent implements OnInit {
|
||||
this.item.type === SCThingType.DateSeries
|
||||
? Promise.resolve([this.item as SCDateSeries])
|
||||
: this.dataProvider
|
||||
.search({
|
||||
.coordinatedSearch({
|
||||
filter: {
|
||||
arguments: {
|
||||
filters: [
|
||||
@@ -140,19 +166,36 @@ export class AddEventActionChipComponent implements OnInit {
|
||||
},
|
||||
})
|
||||
.then(it => it.data as SCDateSeries[]);
|
||||
this.associatedDateSeries.then(it =>
|
||||
this.applyState(
|
||||
it.length === 0
|
||||
? AddEventStates.UNAVAILABLE
|
||||
: AddEventStates.REMOVED_ALL,
|
||||
),
|
||||
|
||||
this.uuidSubscription = this.scheduleProvider.uuids$.subscribe(
|
||||
async result => {
|
||||
this.uuids = result;
|
||||
const associatedDateSeries = await this.associatedDateSeries;
|
||||
if (associatedDateSeries.length === 0) {
|
||||
this.applyState(AddEventStates.UNAVAILABLE);
|
||||
|
||||
return;
|
||||
}
|
||||
switch (
|
||||
difference(map(associatedDateSeries, 'uid'), this.uuids).length
|
||||
) {
|
||||
case 0:
|
||||
this.applyState(AddEventStates.ADDED_ALL);
|
||||
break;
|
||||
case associatedDateSeries.length:
|
||||
this.applyState(AddEventStates.REMOVED_ALL);
|
||||
break;
|
||||
default:
|
||||
this.applyState(AddEventStates.ADDED_SOME);
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Action
|
||||
*/
|
||||
// @Override
|
||||
async onClick(event: MouseEvent) {
|
||||
const associatedDateSeries = await this.associatedDateSeries;
|
||||
const popover = await this.popoverController.create({
|
||||
@@ -165,12 +208,5 @@ export class AddEventActionChipComponent implements OnInit {
|
||||
event: event,
|
||||
});
|
||||
await popover.present();
|
||||
// TODO: replace dummy implementation
|
||||
await popover.onDidDismiss();
|
||||
this.applyState(
|
||||
this.state === AddEventStates.ADDED_ALL
|
||||
? AddEventStates.REMOVED_ALL
|
||||
: AddEventStates.ADDED_ALL,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<div *ngIf="associatedDateSeries | async as associatedDateSeries; else loading">
|
||||
<div class="stack-children">
|
||||
<ion-chip
|
||||
*ngIf="associatedDateSeries | async as associatedDateSeries; else loading"
|
||||
@chipTransition
|
||||
[disabled]="disabled"
|
||||
(click)="$event.stopPropagation(); onClick($event)"
|
||||
>
|
||||
<ion-icon [name]="icon"></ion-icon>
|
||||
<ion-label>{{ label | translate }}</ion-label>
|
||||
</ion-chip>
|
||||
<ng-template #loading>
|
||||
<ion-chip @chipSkeletonTransition>
|
||||
<ion-skeleton-text animated="true"></ion-skeleton-text>
|
||||
</ion-chip>
|
||||
</ng-template>
|
||||
</div>
|
||||
<ng-template #loading>
|
||||
<ion-chip>
|
||||
<ion-skeleton-text animated="true"></ion-skeleton-text>
|
||||
</ion-chip>
|
||||
</ng-template>
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
::ng-deep ion-skeleton-text {
|
||||
:host ::ng-deep ion-skeleton-text {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.stack-children {
|
||||
display: grid;
|
||||
align-items: start;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.stack-children > * {
|
||||
grid-column-start: 1;
|
||||
grid-row-start: 1;
|
||||
}
|
||||
|
||||
22
src/app/modules/data/coordinated-search.provider.spec.ts
Normal file
22
src/app/modules/data/coordinated-search.provider.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (C) 2021 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 {arrayToIndexMap} from './coordinated-search.provider';
|
||||
|
||||
describe('CoordinatedSearchProvider', () => {
|
||||
it('transform arrays correctly', () => {
|
||||
expect(arrayToIndexMap(['a', 'b', 'c'])).toEqual({0: 'a', 1: 'b', 2: 'c'});
|
||||
});
|
||||
});
|
||||
102
src/app/modules/data/coordinated-search.provider.ts
Normal file
102
src/app/modules/data/coordinated-search.provider.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright (C) 2021 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 {SCSearchRequest, SCSearchResponse} from '@openstapps/core';
|
||||
import {Injectable} from '@angular/core';
|
||||
import {DataProvider} from './data.provider';
|
||||
|
||||
/**
|
||||
* Delay execution for (at least) a set amount of time
|
||||
*/
|
||||
async function delay(ms: number): Promise<void> {
|
||||
return await new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms an array to an object with the indices as keys
|
||||
*
|
||||
* ['a', 'b', 'c'] => {0: 'a', 1: 'b', 2: 'c'}
|
||||
*/
|
||||
export function arrayToIndexMap<T>(array: T[]): Record<number, T> {
|
||||
// eslint-disable-next-line unicorn/no-array-reduce
|
||||
return array.reduce((previous, current, index) => {
|
||||
previous[index] = current;
|
||||
return previous;
|
||||
}, {} as Record<number, T>);
|
||||
}
|
||||
|
||||
interface OngoingQuery {
|
||||
request: SCSearchRequest;
|
||||
response?: Promise<SCSearchResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinated search request that bundles requests from multiple modules into a single one
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class CoordinatedSearchProvider {
|
||||
constructor(readonly dataProvider: DataProvider) {}
|
||||
|
||||
/**
|
||||
* Queue of ongoing queries
|
||||
*/
|
||||
queue: OngoingQuery[] = [];
|
||||
|
||||
/**
|
||||
* Default latency of search requests
|
||||
*/
|
||||
latencyMs = 50;
|
||||
|
||||
/**
|
||||
* Start a coordinated search that merges requests across components
|
||||
*
|
||||
* This method collects the request, then:
|
||||
* 1. If the queue is full, dispatches all immediately
|
||||
* 2. If not, waits a set amount of time for other requests to come in
|
||||
*/
|
||||
async coordinatedSearch(
|
||||
query: SCSearchRequest,
|
||||
latencyMs?: number,
|
||||
): Promise<SCSearchResponse> {
|
||||
const ongoingQuery: OngoingQuery = {request: query};
|
||||
this.queue.push(ongoingQuery);
|
||||
|
||||
if (this.queue.length < this.dataProvider.backendQueriesLimit) {
|
||||
await delay(latencyMs ?? this.latencyMs);
|
||||
}
|
||||
|
||||
if (this.queue.length > 0) {
|
||||
// because we are guaranteed to have limited our queue size to be
|
||||
// <= to the backendQueriesLimite as of above, we can bypass the wrapper
|
||||
// in the data provider that usually would be responsible for splitting up the requests
|
||||
const responses = this.dataProvider.client.multiSearch(
|
||||
arrayToIndexMap(this.queue.map(it => it.request)),
|
||||
);
|
||||
|
||||
for (const [index, request] of this.queue.entries()) {
|
||||
request.response = new Promise(resolve =>
|
||||
responses.then(it => resolve(it[index])),
|
||||
);
|
||||
}
|
||||
|
||||
this.queue = [];
|
||||
}
|
||||
|
||||
// Response is guaranteed to be defined here
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return await ongoingQuery.response!;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {MarkdownModule} from 'ngx-markdown';
|
||||
import {MomentModule} from 'ngx-moment';
|
||||
import {ThingTranslateModule} from '../../translation/thing-translate.module';
|
||||
import {MenuModule} from '../menu/menu.module';
|
||||
import {ScheduleProvider} from '../schedule/schedule.provider';
|
||||
import {StorageModule} from '../storage/storage.module';
|
||||
import {ActionChipListComponent} from './chips/action-chip-list.component';
|
||||
import {AddEventPopoverComponent} from './chips/add-event-popover.component';
|
||||
@@ -39,7 +40,6 @@ import {AddressDetailComponent} from './elements/address-detail.component';
|
||||
import {OffersDetailComponent} from './elements/offers-detail.component';
|
||||
import {OffersInListComponent} from './elements/offers-in-list.component';
|
||||
import {OriginDetailComponent} from './elements/origin-detail.component';
|
||||
import {OriginInListComponent} from './elements/origin-in-list.component';
|
||||
import {SimpleCardComponent} from './elements/simple-card.component';
|
||||
import {DataListComponent} from './list/data-list.component';
|
||||
import {FoodDataListComponent} from './list/food-data-list.component';
|
||||
@@ -59,7 +59,6 @@ import {PlaceDetailContentComponent} from './types/place/place-detail-content.co
|
||||
import {PlaceListItemComponent} from './types/place/place-list-item.component';
|
||||
import {PlaceMensaDetailComponent} from './types/place/special/mensa/place-mensa-detail.component';
|
||||
import {SemesterDetailContentComponent} from './types/semester/semester-detail-content.component';
|
||||
import {VideoDetailContentComponent} from './types/video/video-detail-content.component';
|
||||
import {MapWidgetComponent} from '../map/widget/map-widget.component';
|
||||
import {LeafletModule} from '@asymmetrik/ngx-leaflet';
|
||||
import {ArticleListItemComponent} from './types/article/article-list-item.component';
|
||||
@@ -73,29 +72,30 @@ import {LongInlineTextComponent} from './elements/long-inline-text.component';
|
||||
import {MessageListItemComponent} from './types/message/message-list-item.component';
|
||||
import {OrganizationListItemComponent} from './types/organization/organization-list-item.component';
|
||||
import {PersonListItemComponent} from './types/person/person-list-item.component';
|
||||
import {SemesterListItemComponent} from './types/semester/semester-list-item.component';
|
||||
import {SkeletonListItemComponent} from './elements/skeleton-list-item.component';
|
||||
import {SkeletonSegmentComponent} from './elements/skeleton-segment-button.component';
|
||||
import {VideoDetailContentComponent} from './types/video/video-detail-content.component';
|
||||
import {SemesterListItemComponent} from './types/semester/semester-list-item.component';
|
||||
import {VideoListItemComponent} from './types/video/video-list-item.component';
|
||||
import {OriginInListComponent} from './elements/origin-in-list.component';
|
||||
import {CoordinatedSearchProvider} from './coordinated-search.provider';
|
||||
|
||||
/**
|
||||
* Module for handling data
|
||||
*/
|
||||
@NgModule({
|
||||
declarations: [
|
||||
ActionChipListComponent,
|
||||
AddEventActionChipComponent,
|
||||
AddEventPopoverComponent,
|
||||
OffersDetailComponent,
|
||||
OffersInListComponent,
|
||||
AddressDetailComponent,
|
||||
ArticleDetailContentComponent,
|
||||
ArticleListItemComponent,
|
||||
SimpleCardComponent,
|
||||
SkeletonSimpleCardComponent,
|
||||
CatalogDetailContentComponent,
|
||||
CatalogListItemComponent,
|
||||
DataDetailComponent,
|
||||
DataDetailContentComponent,
|
||||
FoodDataListComponent,
|
||||
DataIconPipe,
|
||||
DataListComponent,
|
||||
DataListItemComponent,
|
||||
DateSeriesDetailContentComponent,
|
||||
@@ -106,10 +106,14 @@ import {VideoListItemComponent} from './types/video/video-list-item.component';
|
||||
EventListItemComponent,
|
||||
FavoriteDetailContentComponent,
|
||||
FavoriteListItemComponent,
|
||||
FoodDataListComponent,
|
||||
LocateActionChipComponent,
|
||||
LongInlineTextComponent,
|
||||
MapWidgetComponent,
|
||||
MessageDetailContentComponent,
|
||||
MessageListItemComponent,
|
||||
OffersDetailComponent,
|
||||
OffersInListComponent,
|
||||
OrganizationDetailContentComponent,
|
||||
OrganizationListItemComponent,
|
||||
OriginDetailComponent,
|
||||
@@ -122,21 +126,20 @@ import {VideoListItemComponent} from './types/video/video-list-item.component';
|
||||
SearchPageComponent,
|
||||
SemesterDetailContentComponent,
|
||||
SemesterListItemComponent,
|
||||
SimpleCardComponent,
|
||||
SkeletonListItemComponent,
|
||||
SkeletonSegmentComponent,
|
||||
SkeletonSimpleCardComponent,
|
||||
VideoDetailContentComponent,
|
||||
VideoListItemComponent,
|
||||
DataIconPipe,
|
||||
ActionChipListComponent,
|
||||
AddEventActionChipComponent,
|
||||
LocateActionChipComponent,
|
||||
],
|
||||
entryComponents: [DataListComponent],
|
||||
imports: [
|
||||
IonicModule.forRoot(),
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
DataRoutingModule,
|
||||
FormsModule,
|
||||
HttpClientModule,
|
||||
IonicModule.forRoot(),
|
||||
LeafletModule,
|
||||
MarkdownModule.forRoot(),
|
||||
MenuModule,
|
||||
@@ -150,16 +153,26 @@ import {VideoListItemComponent} from './types/video/video-list-item.component';
|
||||
TranslateModule.forChild(),
|
||||
ThingTranslateModule.forChild(),
|
||||
],
|
||||
providers: [DataProvider, DataFacetsProvider, Network, StAppsWebHttpClient],
|
||||
providers: [
|
||||
CoordinatedSearchProvider,
|
||||
DataProvider,
|
||||
DataFacetsProvider,
|
||||
Network,
|
||||
ScheduleProvider,
|
||||
StAppsWebHttpClient,
|
||||
],
|
||||
exports: [
|
||||
DataDetailComponent,
|
||||
DataDetailContentComponent,
|
||||
DataIconPipe,
|
||||
DataListComponent,
|
||||
DataListItemComponent,
|
||||
DataDetailComponent,
|
||||
SkeletonSimpleCardComponent,
|
||||
SkeletonListItemComponent,
|
||||
DataIconPipe,
|
||||
DateSeriesListItemComponent,
|
||||
PlaceListItemComponent,
|
||||
DataDetailContentComponent,
|
||||
SimpleCardComponent,
|
||||
SkeletonListItemComponent,
|
||||
SkeletonSimpleCardComponent,
|
||||
SearchPageComponent,
|
||||
],
|
||||
})
|
||||
export class DataModule {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (C) 2018-2021 StApps
|
||||
* Copyright (C) 2021 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.
|
||||
|
||||
@@ -49,6 +49,11 @@ export class SearchPageComponent implements OnInit, OnDestroy {
|
||||
*/
|
||||
@Input() forcedFilter?: SCSearchFilter;
|
||||
|
||||
/**
|
||||
* If routing should be done if the user clicks on an item
|
||||
*/
|
||||
@Input() itemRouting? = true;
|
||||
|
||||
/**
|
||||
* Thing counter to start query the next page from
|
||||
*/
|
||||
@@ -120,46 +125,6 @@ export class SearchPageComponent implements OnInit, OnDestroy {
|
||||
protected router: Router,
|
||||
) {
|
||||
this.initialize();
|
||||
|
||||
combineLatest([
|
||||
this.queryTextChanged.pipe(
|
||||
debounceTime(this.searchQueryDueTime),
|
||||
distinctUntilChanged(),
|
||||
startWith(this.queryText),
|
||||
),
|
||||
this.contextMenuService.filterQueryChanged$.pipe(
|
||||
startWith(this.filterQuery),
|
||||
),
|
||||
this.contextMenuService.sortQueryChanged$.pipe(startWith(this.sortQuery)),
|
||||
]).subscribe(async query => {
|
||||
this.queryText = query[0];
|
||||
this.filterQuery = query[1];
|
||||
this.sortQuery = query[2];
|
||||
this.from = 0;
|
||||
await this.fetchAndUpdateItems();
|
||||
this.queryChanged.next();
|
||||
});
|
||||
|
||||
this.fetchAndUpdateItems();
|
||||
|
||||
/**
|
||||
* Subscribe to 'settings.changed' events
|
||||
*/
|
||||
this.subscriptions.push(
|
||||
this.settingsProvider.settingsActionChanged$.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().subscribe(item => {
|
||||
void this.router.navigate(['data-detail', item.uid]);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,6 +226,48 @@ export class SearchPageComponent implements OnInit, OnDestroy {
|
||||
* Initialises the possible sort options in ContextMenuService
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
combineLatest([
|
||||
this.queryTextChanged.pipe(
|
||||
debounceTime(this.searchQueryDueTime),
|
||||
distinctUntilChanged(),
|
||||
startWith(this.queryText),
|
||||
),
|
||||
this.contextMenuService.filterQueryChanged$.pipe(
|
||||
startWith(this.filterQuery),
|
||||
),
|
||||
this.contextMenuService.sortQueryChanged$.pipe(startWith(this.sortQuery)),
|
||||
]).subscribe(async query => {
|
||||
this.queryText = query[0];
|
||||
this.filterQuery = query[1];
|
||||
this.sortQuery = query[2];
|
||||
this.from = 0;
|
||||
await this.fetchAndUpdateItems();
|
||||
this.queryChanged.next();
|
||||
});
|
||||
|
||||
void this.fetchAndUpdateItems();
|
||||
|
||||
/**
|
||||
* Subscribe to 'settings.changed' events
|
||||
*/
|
||||
this.subscriptions.push(
|
||||
this.settingsProvider.settingsActionChanged$.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().subscribe(item => {
|
||||
if (this.itemRouting) {
|
||||
void this.router.navigate(['data-detail', item.uid]);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
this.contextMenuService.setContextSort({
|
||||
name: 'sort',
|
||||
reversed: false,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{{ 'inPlace' | propertyNameTranslate: item | titlecase }}
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<ion-icon name="location"></ion-icon>
|
||||
<ion-icon name="location"> </ion-icon>
|
||||
<a [routerLink]="['/data-detail', item.inPlace.uid]">{{
|
||||
'name' | thingTranslate: item.inPlace
|
||||
}}</a>
|
||||
@@ -17,6 +17,10 @@
|
||||
[title]="'Duration'"
|
||||
[content]="[item.duration | amDuration: 'minutes']"
|
||||
></stapps-simple-card>
|
||||
<stapps-simple-card
|
||||
[title]="'Time'"
|
||||
[content]="[item.dates[0] | amDateFormat]"
|
||||
></stapps-simple-card>
|
||||
<stapps-simple-card
|
||||
*ngIf="item.performers"
|
||||
[title]="'performers' | propertyNameTranslate: item | titlecase"
|
||||
|
||||
@@ -22,8 +22,14 @@ import {DataListItemComponent} from '../../list/data-list-item.component';
|
||||
@Component({
|
||||
selector: 'stapps-date-series-list-item',
|
||||
templateUrl: 'date-series-list-item.html',
|
||||
styleUrls: ['date-series-list-item.scss'],
|
||||
})
|
||||
export class DateSeriesListItemComponent extends DataListItemComponent {
|
||||
/**
|
||||
* Compact view for schedule
|
||||
*/
|
||||
@Input() compact = false;
|
||||
|
||||
/**
|
||||
* TODO
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.remove-button {
|
||||
&:hover {
|
||||
--color-hover: var(--ion-color-danger);
|
||||
--border-color: var(--ion-color-danger);
|
||||
}
|
||||
--color: var(--ion-color-success);
|
||||
--border-color: var(--ion-color-success);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
<ng-container *ngIf="item.type === 'academic event'">
|
||||
<stapps-add-event-action-chip [item]="item" style="margin: 2px">
|
||||
</stapps-add-event-action-chip>
|
||||
<stapps-simple-card
|
||||
*ngIf="item.categories"
|
||||
[title]="'Categories'"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (C) 2020-2021 StApps
|
||||
* Copyright (C) 2020 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.
|
||||
@@ -15,90 +15,17 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {
|
||||
SCFacet,
|
||||
SCFacetBucket,
|
||||
SCSearchFilter,
|
||||
SCSearchSort,
|
||||
SCThingType,
|
||||
} from '@openstapps/core';
|
||||
import {Subject} from 'rxjs';
|
||||
|
||||
export type ContextType = FilterContext | SortContext;
|
||||
|
||||
/**
|
||||
* A sort context
|
||||
*/
|
||||
interface SortContext {
|
||||
/**
|
||||
* Name of the context
|
||||
*/
|
||||
name: 'sort';
|
||||
|
||||
/**
|
||||
* Reverse option
|
||||
*/
|
||||
reversed: boolean;
|
||||
|
||||
/**
|
||||
* sort value
|
||||
*/
|
||||
value: string;
|
||||
|
||||
/**
|
||||
* Sort options
|
||||
*/
|
||||
values: SortContextOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A sort context option
|
||||
*/
|
||||
interface SortContextOption {
|
||||
/**
|
||||
* sort option is reversible
|
||||
*/
|
||||
reversible: boolean;
|
||||
|
||||
/**
|
||||
* sort option value
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A filter context
|
||||
*/
|
||||
interface FilterContext {
|
||||
/**
|
||||
* Compact view of the filter options
|
||||
*/
|
||||
compact?: boolean;
|
||||
/**
|
||||
* Name of the context
|
||||
*/
|
||||
name: 'filter';
|
||||
|
||||
/**
|
||||
* Filter values
|
||||
*/
|
||||
options: FilterFacet[];
|
||||
}
|
||||
|
||||
interface FilterFacet extends SCFacet {
|
||||
/**
|
||||
* FilterBuckets of a FilterFacet
|
||||
*/
|
||||
buckets: FilterBucket[];
|
||||
/**
|
||||
* Compact view of the option buckets
|
||||
*/
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
interface FilterBucket extends SCFacetBucket {
|
||||
/**
|
||||
* Sets the Filter active
|
||||
*/
|
||||
checked: boolean;
|
||||
}
|
||||
import {
|
||||
FilterBucket,
|
||||
FilterContext,
|
||||
FilterFacet,
|
||||
SortContext,
|
||||
} from './context-type';
|
||||
|
||||
/**
|
||||
* ContextMenuService provides bidirectional communication of context menu options and search queries
|
||||
@@ -113,13 +40,11 @@ export class ContextMenuService {
|
||||
/**
|
||||
* Container for the filter context
|
||||
*/
|
||||
// tslint:disable-next-line:member-ordering
|
||||
filterOptions = new Subject<FilterContext>();
|
||||
|
||||
/**
|
||||
* Observable filterContext streams
|
||||
*/
|
||||
// tslint:disable-next-line:member-ordering
|
||||
filterContextChanged$ = this.filterOptions.asObservable();
|
||||
|
||||
/**
|
||||
@@ -130,19 +55,21 @@ export class ContextMenuService {
|
||||
/**
|
||||
* Observable filterContext streams
|
||||
*/
|
||||
// tslint:disable-next-line:member-ordering
|
||||
filterQueryChanged$ = this.filterQuery.asObservable();
|
||||
|
||||
/**
|
||||
* Forced SCThingTypeFilter
|
||||
*/
|
||||
forcedType?: SCThingType;
|
||||
|
||||
/**
|
||||
* Container for the sort context
|
||||
*/
|
||||
// tslint:disable-next-line:member-ordering
|
||||
sortOptions = new Subject<SortContext>();
|
||||
|
||||
/**
|
||||
* Observable SortContext streams
|
||||
*/
|
||||
// tslint:disable-next-line:member-ordering
|
||||
sortContextChanged$ = this.sortOptions.asObservable();
|
||||
|
||||
/**
|
||||
@@ -153,7 +80,6 @@ export class ContextMenuService {
|
||||
/**
|
||||
* Observable SortContext streams
|
||||
*/
|
||||
// tslint:disable-next-line:member-ordering
|
||||
sortQueryChanged$ = this.sortQuery.asObservable();
|
||||
|
||||
/**
|
||||
@@ -166,6 +92,16 @@ export class ContextMenuService {
|
||||
): SCSearchFilter | undefined => {
|
||||
const filters: SCSearchFilter[] = [];
|
||||
|
||||
if (typeof this.forcedType !== 'undefined') {
|
||||
filters.push({
|
||||
arguments: {
|
||||
field: 'type',
|
||||
value: this.forcedType,
|
||||
},
|
||||
type: 'value',
|
||||
});
|
||||
}
|
||||
|
||||
for (const filterFacet of filterContext.options) {
|
||||
const optionFilters: SCSearchFilter[] = [];
|
||||
for (const filterBucket of filterFacet.buckets) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (C) 2018-2020 StApps
|
||||
* Copyright (C) 2018, 2019 StApps
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, version 3.
|
||||
@@ -45,6 +45,15 @@ export class NavigationComponent {
|
||||
*/
|
||||
menu: SCAppConfigurationMenuCategory[];
|
||||
|
||||
/**
|
||||
* Possible languages to be used for translation
|
||||
*/
|
||||
public pages = [
|
||||
{title: 'Search', url: '/search', icon: 'search'},
|
||||
{title: 'Schedule', url: '/schedule', icon: 'calendar'},
|
||||
{title: 'Settings', url: '/settings', icon: 'settings'},
|
||||
];
|
||||
|
||||
/**
|
||||
* Core translator
|
||||
*/
|
||||
|
||||
324
src/app/modules/schedule/page/calendar-view.component.ts
Normal file
324
src/app/modules/schedule/page/calendar-view.component.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
/*
|
||||
* Copyright (C) 2021 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,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
SimpleChanges,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import {ActivatedRoute} from '@angular/router';
|
||||
import {IonDatetime, Platform} from '@ionic/angular';
|
||||
import {SCUuid} from '@openstapps/core';
|
||||
import {last} from 'lodash-es';
|
||||
import moment, {Moment} from 'moment';
|
||||
import {DateFormatPipe} from 'ngx-moment';
|
||||
import {Subscription} from 'rxjs';
|
||||
import {SharedAxisChoreographer} from '../../../animation/animation-choreographer';
|
||||
import {
|
||||
materialFade,
|
||||
materialManualFade,
|
||||
materialSharedAxisX,
|
||||
} from '../../../animation/material-motion';
|
||||
import {ScheduleProvider} from '../schedule.provider';
|
||||
import {ScheduleEvent, ScheduleResponsiveBreakpoint} from './schema/schema';
|
||||
|
||||
/**
|
||||
* Component that displays the schedule
|
||||
*/
|
||||
@Component({
|
||||
selector: 'stapps-calendar-view',
|
||||
templateUrl: 'calendar-view.html',
|
||||
styleUrls: ['calendar-view.scss'],
|
||||
animations: [materialFade, materialSharedAxisX, materialManualFade],
|
||||
})
|
||||
export class CalendarViewComponent implements OnDestroy, OnInit, OnChanges {
|
||||
/**
|
||||
* UUID subscription
|
||||
*/
|
||||
private _uuidSubscription: Subscription;
|
||||
|
||||
/**
|
||||
* The day that is routed to
|
||||
*/
|
||||
protected routeDate: Moment;
|
||||
|
||||
/**
|
||||
* @see {blockDateTimeChange}
|
||||
*/
|
||||
anticipateDatetimeChangeBlocked = false;
|
||||
|
||||
/**
|
||||
* @see {blockDateTimeChange}
|
||||
*/
|
||||
// tslint:disable-next-line:no-magic-numbers
|
||||
readonly anticipateDatetimeChangeTimeoutMs: 100;
|
||||
|
||||
/**
|
||||
* Animation state for cards
|
||||
*/
|
||||
cardsAnimationState: 'in' | 'out' = 'out';
|
||||
|
||||
/**
|
||||
* The cursor
|
||||
*/
|
||||
@ViewChild('cursor', {read: HTMLElement}) cursor?: HTMLElement;
|
||||
|
||||
/**
|
||||
* Choreographer
|
||||
*/
|
||||
dateLabelsChoreographer: SharedAxisChoreographer<Moment[]>;
|
||||
|
||||
/**
|
||||
* The date range to initially display
|
||||
*/
|
||||
displayDates: Moment[][] = [];
|
||||
|
||||
/**
|
||||
* Hours for grid
|
||||
*/
|
||||
readonly hours: number[];
|
||||
|
||||
/**
|
||||
* Height of the slides based on the displayed hours
|
||||
*/
|
||||
readonly hoursAmount: number;
|
||||
|
||||
/**
|
||||
* Range of hours to display
|
||||
*/
|
||||
@Input() readonly hoursRange = {
|
||||
from: 5,
|
||||
to: 22,
|
||||
};
|
||||
|
||||
/**
|
||||
* Layout of the schedule
|
||||
*/
|
||||
@Input() layout: ScheduleResponsiveBreakpoint;
|
||||
|
||||
/**
|
||||
* Get the date format for the date field
|
||||
*/
|
||||
// tslint:disable-next-line:prefer-function-over-method
|
||||
localDateFormat = moment.localeData().longDateFormat('L');
|
||||
|
||||
/**
|
||||
* Route fragment
|
||||
*/
|
||||
routeFragment = 'schedule/calendar';
|
||||
|
||||
/**
|
||||
* Vertical scale of the schedule (distance between hour lines)
|
||||
*/
|
||||
scale = 60;
|
||||
|
||||
/**
|
||||
* date -> (uid -> event)
|
||||
*/
|
||||
testSchedule: Record<string, Record<SCUuid, ScheduleEvent>> = {};
|
||||
|
||||
/**
|
||||
* UUIDs
|
||||
*/
|
||||
uuids: SCUuid[];
|
||||
|
||||
constructor(
|
||||
protected readonly scheduleProvider: ScheduleProvider,
|
||||
protected readonly activatedRoute: ActivatedRoute,
|
||||
protected readonly datePipe: DateFormatPipe,
|
||||
protected readonly platform: Platform,
|
||||
) {
|
||||
// This could be done directly on the properties too instead of
|
||||
// here in the constructor, but because of TSLint member ordering,
|
||||
// some properties wouldn't be initialized, and if you disable
|
||||
// member ordering, auto-fixing the file can still cause reordering
|
||||
// of properties.
|
||||
this.hoursAmount = this.hoursRange.to - this.hoursRange.from + 1;
|
||||
this.hours = [...Array.from({length: this.hoursAmount}).keys()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Because of some stupid Ionic implementation, there is no
|
||||
* way to wait for the datetime picker to be dismissed without
|
||||
* listening for (ionChange). Unfortunately that also includes
|
||||
* changes caused by a page change, so whenever we do that,
|
||||
* we have to block the event for a few milliseconds.
|
||||
*/
|
||||
blockDateTimeChange() {
|
||||
this.anticipateDatetimeChangeBlocked = true;
|
||||
setTimeout(() => {
|
||||
this.anticipateDatetimeChangeBlocked = false;
|
||||
}, this.anticipateDatetimeChangeTimeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine displayed dates according to display size
|
||||
*/
|
||||
determineDisplayDates() {
|
||||
// let's boldly assume that we at least display one day
|
||||
|
||||
const out = [moment(this.routeDate).startOf(this.layout.startOf)];
|
||||
for (let i = 1; i < this.layout.days; i++) {
|
||||
out.push(out[0].clone().add(i, 'day'));
|
||||
}
|
||||
|
||||
this.displayDates = [
|
||||
out.map(it => it.clone().subtract(this.layout.days, 'days')),
|
||||
out,
|
||||
out.map(it => it.clone().add(this.layout.days, 'days')),
|
||||
];
|
||||
|
||||
this.dateLabelsChoreographer?.changeViewForState(this.getDateLabels(), 0);
|
||||
// void this.mainSlides.slideTo(this.mode === 'schedule' ? 0 : 1, 0, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get date labels
|
||||
*/
|
||||
getDateLabels(): Moment[] {
|
||||
return (this.displayDates[1] ?? this.displayDates[0]).map(it => it.clone());
|
||||
}
|
||||
|
||||
/**
|
||||
* Jump to a date
|
||||
*/
|
||||
jumpToDate(alt: IonDatetime, offset = 0, date?: Moment) {
|
||||
if (this.anticipateDatetimeChangeBlocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDate = (date ?? moment(alt.value)).subtract(offset, 'days');
|
||||
const direction = this.routeDate.isBefore(newDate)
|
||||
? 1
|
||||
: this.routeDate.isAfter(newDate)
|
||||
? -1
|
||||
: 0;
|
||||
|
||||
this.blockDateTimeChange();
|
||||
this.routeDate = newDate;
|
||||
this.determineDisplayDates();
|
||||
|
||||
this.dateLabelsChoreographer.changeViewForState(
|
||||
this.getDateLabels(),
|
||||
direction,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load events
|
||||
*/
|
||||
async loadEvents(): Promise<void> {
|
||||
this.cardsAnimationState = 'out';
|
||||
const dateSeries = await this.scheduleProvider.getDateSeries(this.uuids);
|
||||
|
||||
this.testSchedule = {};
|
||||
|
||||
for (const series of dateSeries) {
|
||||
for (const date of series.dates) {
|
||||
const parsedDate = moment(date).startOf('day').unix();
|
||||
|
||||
// fall back to default
|
||||
(this.testSchedule[parsedDate] ?? (this.testSchedule[parsedDate] = {}))[
|
||||
series.uid
|
||||
] = {
|
||||
dateSeries: series,
|
||||
time: {
|
||||
start: moment(date).hours(),
|
||||
duration: series.duration,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.cursor?.scrollIntoView();
|
||||
this.cardsAnimationState = 'in';
|
||||
}
|
||||
|
||||
/**
|
||||
* On Changes
|
||||
*/
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
const layout = changes.layout?.currentValue as
|
||||
| ScheduleResponsiveBreakpoint
|
||||
| undefined;
|
||||
if (layout) {
|
||||
this.determineDisplayDates();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OnDestroy
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this._uuidSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize
|
||||
*/
|
||||
ngOnInit() {
|
||||
this._uuidSubscription = this.scheduleProvider.uuids$.subscribe(
|
||||
async result => {
|
||||
this.uuids = result;
|
||||
await this.loadEvents();
|
||||
},
|
||||
);
|
||||
|
||||
let dayString: string | number | null =
|
||||
this.activatedRoute.snapshot.paramMap.get('date');
|
||||
if (dayString == undefined || dayString === 'now') {
|
||||
const urlFragment: string = last(window.location.href.split('/')) ?? '';
|
||||
|
||||
dayString = /^\d{4}-\d{2}-\d{2}$/.test(urlFragment)
|
||||
? urlFragment
|
||||
: moment.now();
|
||||
}
|
||||
this.routeDate = moment(dayString).startOf('day');
|
||||
this.dateLabelsChoreographer = new SharedAxisChoreographer(
|
||||
this.getDateLabels(),
|
||||
);
|
||||
|
||||
this.determineDisplayDates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Change page
|
||||
*/
|
||||
async onPageChange(direction: number) {
|
||||
this.blockDateTimeChange();
|
||||
const amount = direction * this.displayDates[0].length;
|
||||
|
||||
this.routeDate.add(amount, 'days');
|
||||
window.history.replaceState(
|
||||
{},
|
||||
'',
|
||||
`#/${this.routeFragment}/${this.routeDate.format('YYYY-MM-DD')}`,
|
||||
);
|
||||
|
||||
for (const slide of this.displayDates) {
|
||||
for (const date of slide) {
|
||||
date.add(amount, 'days');
|
||||
}
|
||||
}
|
||||
|
||||
this.dateLabelsChoreographer.changeViewForState(
|
||||
this.getDateLabels(),
|
||||
direction > 0 ? 1 : direction < 0 ? -1 : 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
86
src/app/modules/schedule/page/calendar-view.html
Normal file
86
src/app/modules/schedule/page/calendar-view.html
Normal file
@@ -0,0 +1,86 @@
|
||||
<div>
|
||||
<ion-button fill="clear" class="left-button" (click)="mainSlides.prevPage()">
|
||||
<ion-icon slot="icon-only" name="chevron-back-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" class="right-button" (click)="mainSlides.nextPage()">
|
||||
<ion-icon slot="icon-only" name="chevron-forward-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-item>
|
||||
<ion-grid
|
||||
class="day-labels"
|
||||
[@materialSharedAxisX]="dateLabelsChoreographer.animationState"
|
||||
(@materialSharedAxisX.done)="dateLabelsChoreographer.animationDone()"
|
||||
>
|
||||
<ion-row>
|
||||
<ion-col
|
||||
*ngFor="
|
||||
let item of dateLabelsChoreographer.currentValue;
|
||||
let idx = index
|
||||
"
|
||||
>
|
||||
<ion-button expand="block" fill="clear" (click)="datetime.open()">
|
||||
<ion-label>
|
||||
{{
|
||||
item
|
||||
| amDateFormat: ((item | dateIsThis: 'week') ? 'dddd' : 'll')
|
||||
}}
|
||||
</ion-label>
|
||||
</ion-button>
|
||||
|
||||
<!-- This poor datetime element is a phantom element to provide us with a date picker -->
|
||||
<ion-datetime
|
||||
#datetime
|
||||
[displayFormat]="localDateFormat"
|
||||
[value]="item.toISOString()"
|
||||
(ionChange)="jumpToDate(datetime, idx)"
|
||||
>
|
||||
</ion-datetime>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-item>
|
||||
</div>
|
||||
<ion-content>
|
||||
<stapps-infinite-slides
|
||||
#mainSlides
|
||||
(pageChangeCallback)="onPageChange($event.direction)"
|
||||
>
|
||||
<ion-slide class="slide" *ngFor="let slide of displayDates">
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col *ngFor="let item of slide">
|
||||
<div
|
||||
class="vertical-line"
|
||||
[style.height.px]="hoursAmount * scale"
|
||||
></div>
|
||||
<stapps-schedule-cursor
|
||||
*ngIf="item | dateIsThis: 'date'"
|
||||
[hoursRange]="hoursRange"
|
||||
[scale]="scale"
|
||||
#cursor
|
||||
>
|
||||
</stapps-schedule-cursor>
|
||||
<div [@materialManualFade]="cardsAnimationState">
|
||||
<stapps-schedule-card
|
||||
*ngFor="let entry of testSchedule[item.unix()] | keyvalue"
|
||||
[scheduleEvent]="entry.value"
|
||||
[fromHour]="hoursRange.from"
|
||||
[scale]="scale"
|
||||
>
|
||||
</stapps-schedule-card>
|
||||
</div>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-slide>
|
||||
</stapps-infinite-slides>
|
||||
|
||||
<div
|
||||
class="hour-lines"
|
||||
*ngFor="let i of hours"
|
||||
[style.marginTop.px]="i * scale"
|
||||
>
|
||||
<ion-label>{{ i + hoursRange.from }}:00</ion-label>
|
||||
<hr class="horizontal-line" />
|
||||
</div>
|
||||
</ion-content>
|
||||
97
src/app/modules/schedule/page/calendar-view.scss
Normal file
97
src/app/modules/schedule/page/calendar-view.scss
Normal file
@@ -0,0 +1,97 @@
|
||||
div {
|
||||
position: relative;
|
||||
|
||||
.left-button, .right-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.left-button {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.right-button {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.day-labels {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 8px 20px 8px 30px;
|
||||
width: 100%;
|
||||
|
||||
ion-row {
|
||||
padding-right: 20px;
|
||||
|
||||
ion-col {
|
||||
ion-button {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
font-size: large;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// phantom element
|
||||
ion-datetime {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
height: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.slide {
|
||||
ion-grid {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: fit-content;
|
||||
width: 100%;
|
||||
padding-top: 8px;
|
||||
|
||||
ion-row {
|
||||
ion-col {
|
||||
width: 100%;
|
||||
|
||||
.vertical-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-left: 1px solid #dbdbdb;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
stapps-schedule-card {
|
||||
z-index: 4;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hour-lines {
|
||||
top: 0;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
|
||||
ion-label {
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.horizontal-line {
|
||||
width: 100%;
|
||||
top: 0;
|
||||
border-top: 1px solid #dbdbdb;
|
||||
}
|
||||
}
|
||||
107
src/app/modules/schedule/page/grid/infinite-slides.component.ts
Normal file
107
src/app/modules/schedule/page/grid/infinite-slides.component.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright (C) 2021 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, Output, ViewChild} from '@angular/core';
|
||||
import {IonSlides} from '@ionic/angular';
|
||||
|
||||
/**
|
||||
* Component that can display infinite slides
|
||||
*/
|
||||
@Component({
|
||||
selector: 'stapps-infinite-slides',
|
||||
templateUrl: 'infinite-slides.html',
|
||||
styleUrls: ['infinite-slides.scss'],
|
||||
})
|
||||
export class InfiniteSlidesComponent {
|
||||
/**
|
||||
* If the view was initialized
|
||||
*/
|
||||
initialized = false;
|
||||
|
||||
/**
|
||||
* Callback for when the page has changed
|
||||
*
|
||||
* The caller needs to replace the component here
|
||||
*/
|
||||
@Output() pageChangeCallback: EventEmitter<{
|
||||
/**
|
||||
* The current page
|
||||
*/
|
||||
currentPage: number;
|
||||
|
||||
/**
|
||||
* The direction that was scrolled
|
||||
*/
|
||||
direction: number;
|
||||
}> = new EventEmitter();
|
||||
|
||||
/**
|
||||
* The virtual page we are currently on
|
||||
*/
|
||||
page = 0;
|
||||
|
||||
/**
|
||||
* Options for IonSlides
|
||||
*/
|
||||
@Input() slideOpts = {
|
||||
initialSlide: 1,
|
||||
speed: 200,
|
||||
loop: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Slider element
|
||||
*/
|
||||
@ViewChild('slides') slides: IonSlides;
|
||||
|
||||
/**
|
||||
* Slide to next page
|
||||
*/
|
||||
async nextPage() {
|
||||
await this.slides.slideNext(this.slideOpts.speed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change page
|
||||
*/
|
||||
async onPageChange(direction: number) {
|
||||
if (!this.initialized) {
|
||||
// setting the initial page to 1 causes a page change to
|
||||
// be emitted initially, which intern would cause the
|
||||
// page to actually change one to far, so we listen for
|
||||
// that first page change and skip it
|
||||
this.initialized = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.page += direction;
|
||||
|
||||
this.pageChangeCallback.emit({
|
||||
currentPage: this.page,
|
||||
direction: direction,
|
||||
});
|
||||
|
||||
// tslint:disable-next-line:no-magic-numbers
|
||||
this.slides.slideTo(1, 0, false).then();
|
||||
}
|
||||
|
||||
/**
|
||||
* Slide to previous page
|
||||
*/
|
||||
async prevPage() {
|
||||
await this.slides.slidePrev(this.slideOpts.speed);
|
||||
}
|
||||
}
|
||||
9
src/app/modules/schedule/page/grid/infinite-slides.html
Normal file
9
src/app/modules/schedule/page/grid/infinite-slides.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<ion-slides
|
||||
#slides
|
||||
pager="false"
|
||||
[options]="slideOpts"
|
||||
(ionSlideNextEnd)="onPageChange(1)"
|
||||
(ionSlidePrevEnd)="onPageChange(-1)"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
</ion-slides>
|
||||
4
src/app/modules/schedule/page/grid/infinite-slides.scss
Normal file
4
src/app/modules/schedule/page/grid/infinite-slides.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
ion-slides {
|
||||
width: 100%;
|
||||
height: 1100px; // BIG TODO: This is completely bypasses the scale parameter
|
||||
}
|
||||
100
src/app/modules/schedule/page/grid/schedule-card.component.ts
Normal file
100
src/app/modules/schedule/page/grid/schedule-card.component.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright (C) 2020 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, Input, OnInit} from '@angular/core';
|
||||
import moment from 'moment';
|
||||
import {ScheduleProvider} from '../../schedule.provider';
|
||||
import {ScheduleEvent} from '../schema/schema';
|
||||
|
||||
/**
|
||||
* Component that can display a schedule event
|
||||
*/
|
||||
@Component({
|
||||
selector: 'stapps-schedule-card',
|
||||
templateUrl: 'schedule-card.html',
|
||||
styleUrls: ['../../../data/list/data-list-item.scss', 'schedule-card.scss'],
|
||||
})
|
||||
export class ScheduleCardComponent implements OnInit {
|
||||
/**
|
||||
* The hour from which on the schedule is displayed
|
||||
*/
|
||||
@Input() fromHour = 0;
|
||||
|
||||
/**
|
||||
* Card Y start position
|
||||
*/
|
||||
fromY = 0;
|
||||
|
||||
/**
|
||||
* Card Y end position
|
||||
*/
|
||||
height = 0;
|
||||
|
||||
/**
|
||||
* Show the card without a top offset
|
||||
*/
|
||||
@Input() noOffset = false;
|
||||
|
||||
/**
|
||||
* The scale of the schedule
|
||||
*/
|
||||
@Input() scale = 1;
|
||||
|
||||
/**
|
||||
* The event
|
||||
*/
|
||||
@Input() scheduleEvent: ScheduleEvent;
|
||||
|
||||
/**
|
||||
* The title of the event
|
||||
*/
|
||||
title: string;
|
||||
|
||||
constructor(private readonly scheduleProvider: ScheduleProvider) {}
|
||||
|
||||
/**
|
||||
* Get the note text
|
||||
*/
|
||||
getNote(): string | undefined {
|
||||
return 'categories' in this.scheduleEvent.dateSeries.event
|
||||
? this.scheduleEvent.dateSeries.event.categories?.join(', ')
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializer
|
||||
*/
|
||||
ngOnInit() {
|
||||
this.fromY = this.noOffset ? 0 : this.scheduleEvent.time.start;
|
||||
this.height = moment.duration(this.scheduleEvent.time.duration).asHours();
|
||||
|
||||
this.title = this.scheduleEvent.dateSeries.event.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the event
|
||||
*/
|
||||
removeEvent(): false {
|
||||
if (confirm('Remove event?')) {
|
||||
this.scheduleProvider.uuids$.next(
|
||||
this.scheduleProvider.uuids$.value.filter(
|
||||
it => it !== this.scheduleEvent.dateSeries.uid,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// to prevent event propagation
|
||||
return false;
|
||||
}
|
||||
}
|
||||
30
src/app/modules/schedule/page/grid/schedule-card.html
Normal file
30
src/app/modules/schedule/page/grid/schedule-card.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<ion-card
|
||||
[style.height.px]="height * scale"
|
||||
[style.marginTop.px]="(fromY - fromHour) * scale - 5"
|
||||
[routerLink]="['/data-detail', scheduleEvent.dateSeries.event.uid]"
|
||||
>
|
||||
<ion-card-header mode="md">
|
||||
<ion-card-title>
|
||||
{{
|
||||
this.scheduleEvent.dateSeries.event.name
|
||||
| nullishCoalesce: this.scheduleEvent.dateSeries.name
|
||||
}}
|
||||
</ion-card-title>
|
||||
<ion-card-subtitle>
|
||||
<ion-icon name="calendar"></ion-icon>
|
||||
<span class="repetitions">
|
||||
{{ scheduleEvent.dateSeries.frequency }}
|
||||
until
|
||||
{{
|
||||
scheduleEvent.dateSeries.dates | last | amDateFormat: 'DD. MMM YYYY'
|
||||
}}
|
||||
</span>
|
||||
</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<ion-note>
|
||||
{{ getNote() }}
|
||||
</ion-note>
|
||||
</ion-card-content>
|
||||
<div></div>
|
||||
</ion-card>
|
||||
37
src/app/modules/schedule/page/grid/schedule-card.scss
Normal file
37
src/app/modules/schedule/page/grid/schedule-card.scss
Normal file
@@ -0,0 +1,37 @@
|
||||
ion-card {
|
||||
z-index: 2;
|
||||
|
||||
ion-grid {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
ion-row {
|
||||
ion-col {
|
||||
height: 5px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ion-card-header {
|
||||
height: available;
|
||||
width: 100%;
|
||||
|
||||
ion-card-title {
|
||||
height: 50px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
height: 20px;
|
||||
width: 100%;
|
||||
background: linear-gradient(
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 255) 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (C) 2018, 2019 StApps
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, version 3.
|
||||
*
|
||||
* 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, Input, OnInit} from '@angular/core';
|
||||
import moment from 'moment';
|
||||
import {HoursRange} from '../schema/schema';
|
||||
|
||||
/**
|
||||
* Component that displays the schedule
|
||||
*/
|
||||
@Component({
|
||||
selector: 'stapps-schedule-cursor',
|
||||
templateUrl: 'schedule-cursor.html',
|
||||
styleUrls: ['schedule-cursor.scss'],
|
||||
})
|
||||
export class ScheduleCursorComponent implements OnInit {
|
||||
/**
|
||||
* Cursor update
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore unused
|
||||
private cursorInterval: NodeJS.Timeout;
|
||||
|
||||
/**
|
||||
* Range of hours to display
|
||||
*/
|
||||
@Input() readonly hoursRange: HoursRange;
|
||||
|
||||
/**
|
||||
* Cursor
|
||||
*/
|
||||
now = ScheduleCursorComponent.getCursorTime();
|
||||
|
||||
/**
|
||||
* Vertical scale of the schedule (distance between hour lines)
|
||||
*/
|
||||
@Input() readonly scale: number;
|
||||
|
||||
/**
|
||||
* Get a floating point time 0..24
|
||||
*/
|
||||
static getCursorTime(): number {
|
||||
const mnt = moment(moment.now());
|
||||
|
||||
const hh = mnt.hours();
|
||||
const mm = mnt.minutes();
|
||||
|
||||
// tslint:disable-next-line:no-magic-numbers
|
||||
return hh + mm / 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize
|
||||
*/
|
||||
ngOnInit() {
|
||||
this.cursorInterval = setInterval(async () => {
|
||||
this.now = ScheduleCursorComponent.getCursorTime();
|
||||
// tslint:disable-next-line:no-magic-numbers
|
||||
}, 1000 * 60 /*1 Minute*/);
|
||||
}
|
||||
}
|
||||
6
src/app/modules/schedule/page/grid/schedule-cursor.html
Normal file
6
src/app/modules/schedule/page/grid/schedule-cursor.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<div [style.marginTop.px]="(now - hoursRange.from) * scale">
|
||||
<div>
|
||||
<hr />
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
38
src/app/modules/schedule/page/grid/schedule-cursor.scss
Normal file
38
src/app/modules/schedule/page/grid/schedule-cursor.scss
Normal file
@@ -0,0 +1,38 @@
|
||||
div {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
z-index: 0;
|
||||
|
||||
div {
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
|
||||
hr {
|
||||
width: calc(100% - 8px);
|
||||
position: absolute;
|
||||
margin-left: 4px;
|
||||
margin-right: 16px;
|
||||
margin-top: 8px;
|
||||
height: 2px;
|
||||
border-top: 2px solid var(--ion-color-primary);
|
||||
margin-block-start: 0;
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
div {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: -4px;
|
||||
border-radius: 50% 0 50% 50%;
|
||||
transform: rotateZ(45deg);
|
||||
background-color: var(--ion-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (C) 2020 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, Input} from '@angular/core';
|
||||
import {SCSearchFilter, SCThingType} from '@openstapps/core';
|
||||
|
||||
/**
|
||||
* TODO
|
||||
*/
|
||||
@Component({
|
||||
selector: 'modal-event-creator',
|
||||
templateUrl: 'modal-event-creator.html',
|
||||
styleUrls: ['modal-event-creator.scss'],
|
||||
})
|
||||
export class ModalEventCreatorComponent {
|
||||
/**
|
||||
* Action when close is pressed
|
||||
*/
|
||||
@Input() dismissAction: () => void;
|
||||
|
||||
/**
|
||||
* Forced filter
|
||||
*/
|
||||
filter: SCSearchFilter = {
|
||||
arguments: {
|
||||
field: 'type',
|
||||
value: SCThingType.AcademicEvent,
|
||||
},
|
||||
type: 'value',
|
||||
};
|
||||
}
|
||||
15
src/app/modules/schedule/page/modal/modal-event-creator.html
Normal file
15
src/app/modules/schedule/page/modal/modal-event-creator.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<ion-card-header>
|
||||
<ion-card-title>{{
|
||||
'schedule.addEventModal.addEvent' | translate
|
||||
}}</ion-card-title>
|
||||
<ion-button fill="clear" (click)="dismissAction()">
|
||||
<ion-label>{{ 'schedule.addEventModal.close' | translate }}</ion-label>
|
||||
</ion-button>
|
||||
</ion-card-header>
|
||||
|
||||
<ion-card-content>
|
||||
<stapps-search-page
|
||||
[forcedFilter]="filter"
|
||||
[itemRouting]="false"
|
||||
></stapps-search-page>
|
||||
</ion-card-content>
|
||||
17
src/app/modules/schedule/page/modal/modal-event-creator.scss
Normal file
17
src/app/modules/schedule/page/modal/modal-event-creator.scss
Normal file
@@ -0,0 +1,17 @@
|
||||
ion-card-header {
|
||||
ion-button {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
ion-card-content {
|
||||
height: 100%;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
|
||||
stapps-data-list {
|
||||
height: available;
|
||||
}
|
||||
}
|
||||
243
src/app/modules/schedule/page/schedule-page.component.ts
Normal file
243
src/app/modules/schedule/page/schedule-page.component.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* Copyright (C) 2021 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 {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
HostListener,
|
||||
OnInit,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import {ActivatedRoute} from '@angular/router';
|
||||
import {AnimationController, ModalController} from '@ionic/angular';
|
||||
import {last} from 'lodash-es';
|
||||
import {SharedAxisChoreographer} from '../../../animation/animation-choreographer';
|
||||
import {materialSharedAxisX} from '../../../animation/material-motion';
|
||||
import {ModalEventCreatorComponent} from './modal/modal-event-creator.component';
|
||||
import {ScheduleResponsiveBreakpoint} from './schema/schema';
|
||||
import {animate, style, transition, trigger} from '@angular/animations';
|
||||
|
||||
/**
|
||||
* This needs to be sorted by break point low -> high
|
||||
*
|
||||
* Last entry must have `until: Infinity`
|
||||
*/
|
||||
const responsiveConfig: ScheduleResponsiveBreakpoint[] = [
|
||||
{
|
||||
until: 768,
|
||||
days: 1,
|
||||
startOf: 'day',
|
||||
},
|
||||
{
|
||||
until: 1700,
|
||||
days: 3,
|
||||
startOf: 'day',
|
||||
},
|
||||
{
|
||||
until: Number.POSITIVE_INFINITY,
|
||||
days: 7,
|
||||
startOf: 'week',
|
||||
},
|
||||
];
|
||||
|
||||
const fabAnimations = trigger('fabAnimation', [
|
||||
transition(':leave', [
|
||||
style({opacity: 1, transform: 'translate(0, 0) scale(1)'}),
|
||||
animate(
|
||||
'100ms ease-in',
|
||||
style({opacity: 0, transform: 'translate(-5vw, -5vh) scale(200%)'}),
|
||||
),
|
||||
]),
|
||||
transition(':enter', [
|
||||
style({opacity: 0, transform: 'translate(-5vw, -5vh) scale(200%)'}),
|
||||
animate(
|
||||
'200ms ease-out',
|
||||
style({opacity: 1, transform: 'translate(0, 0) scale(1)'}),
|
||||
),
|
||||
]),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Component that displays the schedule
|
||||
*/
|
||||
@Component({
|
||||
selector: 'stapps-schedule-page',
|
||||
templateUrl: 'schedule-page.html',
|
||||
styleUrls: ['schedule-page.scss'],
|
||||
animations: [materialSharedAxisX, fabAnimations],
|
||||
})
|
||||
export class SchedulePageComponent implements OnInit, AfterViewInit {
|
||||
/**
|
||||
* Current width of the window
|
||||
*/
|
||||
private currentWindowWidth: number = window.innerWidth;
|
||||
|
||||
/**
|
||||
* Actual Segment Tab
|
||||
*/
|
||||
actualSegmentValue?: string | null;
|
||||
|
||||
fabVisible = true;
|
||||
|
||||
/**
|
||||
* Layout
|
||||
*/
|
||||
layout: ScheduleResponsiveBreakpoint = SchedulePageComponent.getDaysToDisplay(
|
||||
this.currentWindowWidth,
|
||||
);
|
||||
|
||||
/**
|
||||
* Vertical scale of the schedule (distance between hour lines)
|
||||
*/
|
||||
scale = 60;
|
||||
|
||||
@ViewChild('segment') segmentView!: HTMLIonSegmentElement;
|
||||
|
||||
/**
|
||||
* Choreographer for the tab switching
|
||||
*/
|
||||
tabChoreographer: SharedAxisChoreographer<string | null | undefined>;
|
||||
|
||||
/**
|
||||
* Weekly config for schedule
|
||||
*/
|
||||
weeklyConfig: ScheduleResponsiveBreakpoint = {
|
||||
until: Number.POSITIVE_INFINITY,
|
||||
days: 7,
|
||||
startOf: 'week',
|
||||
};
|
||||
|
||||
/**
|
||||
* Amount of days that should be shown according to current display width
|
||||
*/
|
||||
static getDaysToDisplay(width: number): ScheduleResponsiveBreakpoint {
|
||||
// the search could be optimized, but probably would have little
|
||||
// actual effect with five entries.
|
||||
// we can be sure we get an hit when the last value.until is infinity
|
||||
// (unless someone has a display that reaches across the universe)
|
||||
return (
|
||||
responsiveConfig.find(value => width < value.until) ??
|
||||
responsiveConfig[responsiveConfig.length - 1]
|
||||
);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly modalController: ModalController,
|
||||
private readonly activatedRoute: ActivatedRoute,
|
||||
private readonly animationController: AnimationController,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.tabChoreographer = new SharedAxisChoreographer(
|
||||
this.activatedRoute.snapshot.paramMap.get('mode'),
|
||||
['calendar', 'recurring', 'single'],
|
||||
);
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.segmentView.value = this.tabChoreographer.currentValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize callback
|
||||
*
|
||||
* Note: this may not fire when the browser transfers from full screen to windowed
|
||||
* (Firefox & Chrome tested)
|
||||
*/
|
||||
@HostListener('window:resize', ['$event'])
|
||||
onResize(_: UIEvent) {
|
||||
const current = SchedulePageComponent.getDaysToDisplay(
|
||||
this.currentWindowWidth,
|
||||
);
|
||||
const next = SchedulePageComponent.getDaysToDisplay(window.innerWidth);
|
||||
this.currentWindowWidth = window.innerWidth;
|
||||
|
||||
if (current.days === next.days) {
|
||||
this.layout = next;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When the segment changes
|
||||
*/
|
||||
onSegmentChange() {
|
||||
window.history.replaceState(
|
||||
{},
|
||||
'',
|
||||
`/#/schedule/${this.segmentView.value}/${last(
|
||||
window.location.href.split('/'),
|
||||
)}`,
|
||||
);
|
||||
this.tabChoreographer.changeViewForState(this.segmentView.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add event modal sheet
|
||||
*/
|
||||
async showCreateEventModal() {
|
||||
this.fabVisible = false;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any,unicorn/consistent-function-scoping
|
||||
const enterAnimation = (baseElement: any) => {
|
||||
const backdropAnimation = this.animationController
|
||||
.create()
|
||||
.addElement(baseElement.querySelector('.modal-wrapper'))
|
||||
.fromTo('opacity', '0', 'var(--backdrop-opacity)');
|
||||
|
||||
const wrapperAnimation = this.animationController
|
||||
.create()
|
||||
.addElement(baseElement.querySelector('.modal-wrapper'))
|
||||
.keyframes([
|
||||
{
|
||||
opacity: '0',
|
||||
transform: 'translate(30vw, 30vh) scale(0.5)',
|
||||
},
|
||||
{
|
||||
opacity: '1',
|
||||
transform: 'translate(0, 0) scale(1)',
|
||||
},
|
||||
]);
|
||||
|
||||
return this.animationController
|
||||
.create()
|
||||
.addElement(baseElement)
|
||||
.easing('ease-out')
|
||||
.duration(150)
|
||||
.addAnimation([backdropAnimation, wrapperAnimation]);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any,unicorn/consistent-function-scoping
|
||||
const leaveAnimation = (baseElement: any) => {
|
||||
return enterAnimation(baseElement).direction('reverse');
|
||||
};
|
||||
|
||||
const modal = await this.modalController.create({
|
||||
component: ModalEventCreatorComponent,
|
||||
swipeToClose: true,
|
||||
cssClass: 'add-modal',
|
||||
componentProps: {
|
||||
dismissAction: () => {
|
||||
modal.dismiss();
|
||||
},
|
||||
},
|
||||
enterAnimation,
|
||||
leaveAnimation,
|
||||
});
|
||||
|
||||
await modal.present();
|
||||
await modal.onWillDismiss();
|
||||
|
||||
this.fabVisible = true;
|
||||
}
|
||||
}
|
||||
57
src/app/modules/schedule/page/schedule-page.html
Normal file
57
src/app/modules/schedule/page/schedule-page.html
Normal file
@@ -0,0 +1,57 @@
|
||||
<!--
|
||||
~ Copyright (C) 2021 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/>.
|
||||
-->
|
||||
|
||||
<ion-header>
|
||||
<ion-segment #segment value="calendar" (ionChange)="onSegmentChange()">
|
||||
<ion-segment-button value="calendar">
|
||||
<ion-label>{{ 'schedule.calendar' | translate }}</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button value="recurring">
|
||||
<ion-label>{{ 'schedule.recurring' | translate }}</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button value="single">
|
||||
<ion-label>{{ 'schedule.single' | translate }}</ion-label>
|
||||
</ion-segment-button>
|
||||
</ion-segment>
|
||||
</ion-header>
|
||||
|
||||
<div
|
||||
[ngSwitch]="tabChoreographer.currentValue"
|
||||
[@materialSharedAxisX]="tabChoreographer.animationState"
|
||||
(@materialSharedAxisX.done)="tabChoreographer.animationDone()"
|
||||
>
|
||||
<stapps-calendar-view
|
||||
*ngSwitchCase="'calendar'"
|
||||
[layout]="layout"
|
||||
></stapps-calendar-view>
|
||||
<!-- Schedule view needs full week -->
|
||||
<stapps-schedule-view
|
||||
*ngSwitchCase="'recurring'"
|
||||
[layout]="weeklyConfig"
|
||||
></stapps-schedule-view>
|
||||
<stapps-single-events *ngSwitchCase="'single'"></stapps-single-events>
|
||||
</div>
|
||||
|
||||
<ion-fab
|
||||
*ngIf="fabVisible"
|
||||
@fabAnimation
|
||||
vertical="bottom"
|
||||
horizontal="end"
|
||||
slot="fixed"
|
||||
>
|
||||
<ion-fab-button (click)="showCreateEventModal()">
|
||||
<ion-icon name="add"></ion-icon>
|
||||
</ion-fab-button>
|
||||
</ion-fab>
|
||||
26
src/app/modules/schedule/page/schedule-page.scss
Normal file
26
src/app/modules/schedule/page/schedule-page.scss
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
ion-header {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
div {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.content-container {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.add-modal {
|
||||
align-items: flex-end !important;
|
||||
justify-content: flex-end !important;
|
||||
|
||||
.modal-wrapper {
|
||||
transform-origin: bottom right !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright (C) 2021 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, Input, OnDestroy, OnInit} from '@angular/core';
|
||||
import {SCDateSeries, SCUuid} from '@openstapps/core';
|
||||
import {flatMap, groupBy, omit, sortBy} from 'lodash-es';
|
||||
import moment from 'moment';
|
||||
import {Subscription} from 'rxjs';
|
||||
import {materialFade} from '../../../animation/material-motion';
|
||||
import {ScheduleProvider} from '../schedule.provider';
|
||||
import {ScheduleEvent} from './schema/schema';
|
||||
|
||||
/**
|
||||
* A single event
|
||||
*/
|
||||
export interface ScheduleSingleEvent {
|
||||
/**
|
||||
* Day the event is on
|
||||
*/
|
||||
day: string;
|
||||
|
||||
/**
|
||||
* Event the date is referring to
|
||||
*/
|
||||
event: ScheduleEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that displays single events one after each other
|
||||
*/
|
||||
@Component({
|
||||
selector: 'stapps-single-events',
|
||||
templateUrl: 'schedule-single-events.html',
|
||||
styleUrls: ['schedule-single-events.scss'],
|
||||
animations: [materialFade],
|
||||
})
|
||||
export class ScheduleSingleEventsComponent implements OnInit, OnDestroy {
|
||||
/**
|
||||
* UUID subscription
|
||||
*/
|
||||
private _uuidSubscription: Subscription;
|
||||
|
||||
/**
|
||||
* The events to display
|
||||
*/
|
||||
private uuids: SCUuid[];
|
||||
|
||||
/**
|
||||
* Events that are displayed
|
||||
*/
|
||||
events: Promise<ScheduleSingleEvent[][]>;
|
||||
|
||||
/**
|
||||
* Scale of the view
|
||||
*/
|
||||
@Input() scale = 60;
|
||||
|
||||
/**
|
||||
* Sorts dates to a list of days with events on each
|
||||
*/
|
||||
static groupDateSeriesToDays(
|
||||
dateSeries: SCDateSeries[],
|
||||
): ScheduleSingleEvent[][] {
|
||||
return sortBy(
|
||||
groupBy(
|
||||
flatMap(dateSeries, event =>
|
||||
event.dates.map(date => ({
|
||||
dateUnix: moment(date).unix(),
|
||||
day: moment(date).startOf('day').toISOString(),
|
||||
event: {
|
||||
dateSeries: event,
|
||||
time: {
|
||||
start:
|
||||
moment(date).hour() +
|
||||
moment(date)
|
||||
// tslint:disable-next-line:no-magic-numbers
|
||||
.minute() /
|
||||
60,
|
||||
duration: event.duration,
|
||||
},
|
||||
},
|
||||
})),
|
||||
)
|
||||
.sort((a, b) => a.dateUnix - b.dateUnix)
|
||||
.map(event => omit(event, 'dateUnix')),
|
||||
'day',
|
||||
),
|
||||
'day',
|
||||
);
|
||||
}
|
||||
|
||||
constructor(protected readonly scheduleProvider: ScheduleProvider) {}
|
||||
|
||||
/**
|
||||
* Fetch date series items
|
||||
*/
|
||||
async fetchDateSeries(): Promise<ScheduleSingleEvent[][]> {
|
||||
// TODO: only single events
|
||||
const dateSeries = await this.scheduleProvider.getDateSeries(
|
||||
this.uuids,
|
||||
undefined /*TODO*/,
|
||||
moment(moment.now()).startOf('week').toISOString(),
|
||||
);
|
||||
|
||||
// TODO: replace with filter
|
||||
return ScheduleSingleEventsComponent.groupDateSeriesToDays(
|
||||
dateSeries.filter(it => it.frequency === 'single'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* OnDestroy
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this._uuidSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize
|
||||
*/
|
||||
ngOnInit() {
|
||||
this._uuidSubscription = this.scheduleProvider.uuids$.subscribe(
|
||||
async result => {
|
||||
this.uuids = result;
|
||||
this.events = this.fetchDateSeries();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
21
src/app/modules/schedule/page/schedule-single-events.html
Normal file
21
src/app/modules/schedule/page/schedule-single-events.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<ion-content @materialFade>
|
||||
<ion-list lines="none">
|
||||
<ion-item-group *ngFor="let day of events | async" @materialFade>
|
||||
<ion-label class="day-label" color="medium">
|
||||
{{ day[0].day | amDateFormat: 'LL' }}
|
||||
</ion-label>
|
||||
<ion-item *ngFor="let event of day" lines="none">
|
||||
<ion-avatar class="hour-label">
|
||||
{{ event.event.dateSeries.dates[0] | amDateFormat: 'HH:mm' }}
|
||||
</ion-avatar>
|
||||
<stapps-schedule-card
|
||||
class="event-card"
|
||||
[scheduleEvent]="event.event"
|
||||
[noOffset]="true"
|
||||
[scale]="scale"
|
||||
>
|
||||
</stapps-schedule-card>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-list>
|
||||
</ion-content>
|
||||
17
src/app/modules/schedule/page/schedule-single-events.scss
Normal file
17
src/app/modules/schedule/page/schedule-single-events.scss
Normal file
@@ -0,0 +1,17 @@
|
||||
ion-content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hour-label {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.day-label {
|
||||
padding: 16px;
|
||||
font-size: large;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.event-card {
|
||||
width: 100%
|
||||
}
|
||||
64
src/app/modules/schedule/page/schedule-single-events.spec.ts
Normal file
64
src/app/modules/schedule/page/schedule-single-events.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (C) 2021 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 {SCDateSeries} from '@openstapps/core';
|
||||
import {ScheduleSingleEventsComponent} from './schedule-single-events.component';
|
||||
import moment from 'moment';
|
||||
|
||||
describe('ScheduleSingleEvents', () => {
|
||||
it('should group date series to days', () => {
|
||||
const events: Partial<SCDateSeries>[] = [
|
||||
{
|
||||
dates: ['2021-12-24T10:00Z', '2021-12-24T12:00Z'],
|
||||
duration: 'A',
|
||||
},
|
||||
{
|
||||
dates: ['2021-12-20T10:00Z'],
|
||||
duration: 'B',
|
||||
},
|
||||
{
|
||||
dates: ['2021-12-24T10:15Z'],
|
||||
duration: 'C',
|
||||
},
|
||||
];
|
||||
|
||||
const grouped = ScheduleSingleEventsComponent.groupDateSeriesToDays(
|
||||
events as SCDateSeries[],
|
||||
);
|
||||
const seriesToDate = (series: Partial<SCDateSeries>, index: number) => {
|
||||
const time = moment(series.dates?.[index]);
|
||||
|
||||
return {
|
||||
day: time.clone().startOf('day').toISOString(),
|
||||
event: {
|
||||
dateSeries: series as SCDateSeries,
|
||||
time: {
|
||||
start: time.hour() + time.minute() / 60,
|
||||
duration: series.duration as string,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
expect(grouped).toEqual([
|
||||
[seriesToDate(events[1], 0)],
|
||||
[
|
||||
seriesToDate(events[0], 0),
|
||||
seriesToDate(events[2], 0),
|
||||
seriesToDate(events[0], 1),
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
108
src/app/modules/schedule/page/schedule-view.component.ts
Normal file
108
src/app/modules/schedule/page/schedule-view.component.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright (C) 2021 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} from '@angular/core';
|
||||
import {ActivatedRoute} from '@angular/router';
|
||||
import {Platform} from '@ionic/angular';
|
||||
import moment from 'moment';
|
||||
import {DateFormatPipe} from 'ngx-moment';
|
||||
import {
|
||||
materialFade,
|
||||
materialManualFade,
|
||||
materialSharedAxisX,
|
||||
} from '../../../animation/material-motion';
|
||||
import {ScheduleProvider} from '../schedule.provider';
|
||||
import {CalendarViewComponent} from './calendar-view.component';
|
||||
|
||||
/**
|
||||
* Component that displays the schedule
|
||||
*/
|
||||
@Component({
|
||||
selector: 'stapps-schedule-view',
|
||||
// this is intentional for extending
|
||||
templateUrl: 'calendar-view.html',
|
||||
styleUrls: ['calendar-view.scss'],
|
||||
animations: [materialFade, materialSharedAxisX, materialManualFade],
|
||||
})
|
||||
export class ScheduleViewComponent extends CalendarViewComponent {
|
||||
/**
|
||||
* Route Fragment
|
||||
*/
|
||||
// @Override
|
||||
routeFragment = 'schedule/recurring';
|
||||
|
||||
constructor(
|
||||
scheduleProvider: ScheduleProvider,
|
||||
activatedRoute: ActivatedRoute,
|
||||
datePipe: DateFormatPipe,
|
||||
platform: Platform,
|
||||
) {
|
||||
super(scheduleProvider, activatedRoute, datePipe, platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine displayed dates according to display size
|
||||
*/
|
||||
// @Override
|
||||
determineDisplayDates() {
|
||||
// let's boldly assume that we at least display one day
|
||||
|
||||
const out = [moment(moment.now()).startOf(this.layout.startOf)];
|
||||
for (let i = 1; i < this.layout.days; i++) {
|
||||
out.push(out[0].clone().add(i, 'day'));
|
||||
}
|
||||
|
||||
this.displayDates = [out];
|
||||
|
||||
// void this.mainSlides.slideTo(this.mode === 'schedule' ? 0 : 1, 0, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load events
|
||||
*/
|
||||
// @Override
|
||||
async loadEvents(): Promise<void> {
|
||||
this.cardsAnimationState = 'out';
|
||||
const dateSeries = await this.scheduleProvider.getDateSeries(
|
||||
this.uuids,
|
||||
undefined,
|
||||
moment(moment.now()).startOf('week').toISOString(),
|
||||
);
|
||||
|
||||
this.testSchedule = {};
|
||||
|
||||
for (const series of dateSeries) {
|
||||
if (series.dates.length > 0) {
|
||||
const date = moment(moment.now())
|
||||
.startOf('week')
|
||||
.day(moment(series.dates[0]).day())
|
||||
.unix();
|
||||
|
||||
// fall back to default
|
||||
(this.testSchedule[date] ?? (this.testSchedule[date] = {}))[
|
||||
series.uid
|
||||
] = {
|
||||
dateSeries: series,
|
||||
time: {
|
||||
start: moment(series.dates[0]).hours(),
|
||||
duration: series.duration,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.cursor?.scrollIntoView();
|
||||
this.cardsAnimationState = 'in';
|
||||
}
|
||||
}
|
||||
21
src/app/modules/schedule/page/schedule.service.ts
Normal file
21
src/app/modules/schedule/page/schedule.service.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (C) 2020 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';
|
||||
|
||||
/**
|
||||
* MenuService provides bidirectional communication of context menu options and search queries
|
||||
*/
|
||||
@Injectable()
|
||||
export class ScheduleService {}
|
||||
69
src/app/modules/schedule/page/schema/schema.ts
Normal file
69
src/app/modules/schedule/page/schema/schema.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (C) 2020 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 {SCDateSeries, SCISO8601Duration} from '@openstapps/core';
|
||||
import {unitOfTime} from 'moment';
|
||||
|
||||
interface DateRange {
|
||||
duration: SCISO8601Duration;
|
||||
start: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal interface to provide information about a custom event
|
||||
*/
|
||||
export interface ScheduleEvent {
|
||||
/**
|
||||
* UUIDs of things related to the event
|
||||
*/
|
||||
dateSeries: SCDateSeries;
|
||||
|
||||
/**
|
||||
* How long the event goes
|
||||
*/
|
||||
time: DateRange;
|
||||
}
|
||||
|
||||
/**
|
||||
* Range of hours
|
||||
*/
|
||||
export interface HoursRange {
|
||||
/**
|
||||
* Start hour
|
||||
*/
|
||||
from: number;
|
||||
|
||||
/**
|
||||
* End hour
|
||||
*/
|
||||
to: number;
|
||||
}
|
||||
|
||||
export interface ScheduleResponsiveBreakpoint {
|
||||
/**
|
||||
* Number of days to display
|
||||
*/
|
||||
days: number;
|
||||
|
||||
/**
|
||||
* When the first day should start
|
||||
*/
|
||||
startOf: unitOfTime.StartOf;
|
||||
|
||||
/**
|
||||
* Width until next breakpoint is hit
|
||||
*/
|
||||
until: number;
|
||||
}
|
||||
71
src/app/modules/schedule/schedule.module.ts
Normal file
71
src/app/modules/schedule/schedule.module.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (C) 2018, 2019 StApps
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, version 3.
|
||||
*
|
||||
* 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 {CommonModule} from '@angular/common';
|
||||
import {NgModule} from '@angular/core';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {RouterModule, Routes} from '@angular/router';
|
||||
import {IonicModule} from '@ionic/angular';
|
||||
import {TranslateModule} from '@ngx-translate/core';
|
||||
import {ScheduleCardComponent} from './page/grid/schedule-card.component';
|
||||
|
||||
import {DateFormatPipe, MomentModule} from 'ngx-moment';
|
||||
import {UtilModule} from '../../util/util.module';
|
||||
import {DataModule} from '../data/data.module';
|
||||
import {DataProvider} from '../data/data.provider';
|
||||
import {CalendarViewComponent} from './page/calendar-view.component';
|
||||
import {InfiniteSlidesComponent} from './page/grid/infinite-slides.component';
|
||||
import {ScheduleCursorComponent} from './page/grid/schedule-cursor.component';
|
||||
import {ModalEventCreatorComponent} from './page/modal/modal-event-creator.component';
|
||||
import {SchedulePageComponent} from './page/schedule-page.component';
|
||||
import {ScheduleSingleEventsComponent} from './page/schedule-single-events.component';
|
||||
import {ScheduleViewComponent} from './page/schedule-view.component';
|
||||
import {ScheduleProvider} from './schedule.provider';
|
||||
|
||||
const settingsRoutes: Routes = [
|
||||
{path: 'schedule', redirectTo: 'schedule/calendar/now'},
|
||||
{path: 'schedule/calendar', redirectTo: 'schedule/calendar/now'},
|
||||
{path: 'schedule/recurring', redirectTo: 'schedule/recurring/now'},
|
||||
{path: 'schedule/single', redirectTo: 'schedule/single/now'},
|
||||
// calendar | recurring | single
|
||||
{path: 'schedule/:mode/:date', component: SchedulePageComponent},
|
||||
];
|
||||
|
||||
/**
|
||||
* Schedule Module
|
||||
*/
|
||||
@NgModule({
|
||||
declarations: [
|
||||
CalendarViewComponent,
|
||||
InfiniteSlidesComponent,
|
||||
ModalEventCreatorComponent,
|
||||
ScheduleCardComponent,
|
||||
ScheduleCursorComponent,
|
||||
SchedulePageComponent,
|
||||
ScheduleSingleEventsComponent,
|
||||
ScheduleViewComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
UtilModule,
|
||||
IonicModule.forRoot(),
|
||||
TranslateModule.forChild(),
|
||||
RouterModule.forChild(settingsRoutes),
|
||||
DataModule,
|
||||
MomentModule,
|
||||
],
|
||||
providers: [ScheduleProvider, DataProvider, DateFormatPipe],
|
||||
})
|
||||
export class ScheduleModule {}
|
||||
195
src/app/modules/schedule/schedule.provider.ts
Normal file
195
src/app/modules/schedule/schedule.provider.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/* eslint-disable unicorn/no-null */
|
||||
/*
|
||||
* Copyright (C) 2018-2020 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} from '@angular/core';
|
||||
import {
|
||||
Bounds,
|
||||
SCDateSeries,
|
||||
SCISO8601Date,
|
||||
SCSearchFilter,
|
||||
SCThingType,
|
||||
SCUuid,
|
||||
} from '@openstapps/core';
|
||||
import {BehaviorSubject, Subscription} from 'rxjs';
|
||||
import {DataProvider} from '../data/data.provider';
|
||||
|
||||
/**
|
||||
* Provider for app settings
|
||||
*/
|
||||
@Injectable()
|
||||
export class ScheduleProvider implements OnDestroy {
|
||||
// tslint:disable:prefer-function-over-method
|
||||
|
||||
/**
|
||||
* Storage key for event UUIDs
|
||||
*/
|
||||
private static uuidStorageKey = 'schedule::event_uuids';
|
||||
|
||||
private _uuids$?: BehaviorSubject<SCUuid[]>;
|
||||
|
||||
private _uuidSubscription?: Subscription;
|
||||
|
||||
constructor(private readonly dataProvider: DataProvider) {
|
||||
window.addEventListener('storage', this.storageEventListener.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Push one or more values to local storage
|
||||
*/
|
||||
private static get<T>(key: string): T[] {
|
||||
const item = localStorage.getItem(key);
|
||||
if (item == undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return JSON.parse(item) as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Push one or more values to local storage
|
||||
*/
|
||||
private static set<T>(key: string, item: T[]) {
|
||||
const newValue = JSON.stringify(item);
|
||||
// prevent feedback loop from storageEvent -> _uuids$.next() -> set -> storageEvent
|
||||
if (newValue !== localStorage.getItem(key)) {
|
||||
localStorage.setItem(key, newValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO
|
||||
*/
|
||||
public get uuids$(): BehaviorSubject<SCUuid[]> {
|
||||
if (!this._uuids$) {
|
||||
this._uuids$ = new BehaviorSubject(
|
||||
ScheduleProvider.get<SCUuid>(ScheduleProvider.uuidStorageKey),
|
||||
);
|
||||
this._uuidSubscription = this._uuids$.subscribe(result => {
|
||||
ScheduleProvider.set(ScheduleProvider.uuidStorageKey, result);
|
||||
});
|
||||
}
|
||||
|
||||
return this._uuids$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to updates in local storage
|
||||
*/
|
||||
private storageEventListener(event: StorageEvent) {
|
||||
if (
|
||||
event.newValue &&
|
||||
event.storageArea === localStorage &&
|
||||
event.key === ScheduleProvider.uuidStorageKey
|
||||
) {
|
||||
this._uuids$?.next(JSON.parse(event.newValue));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Date Series
|
||||
*/
|
||||
async getDateSeries(
|
||||
uuids: SCUuid[],
|
||||
frequencies?: Array<'single' | 'weekly' | 'biweekly'>,
|
||||
from?: SCISO8601Date | 'now',
|
||||
to?: SCISO8601Date | 'now',
|
||||
): Promise<SCDateSeries[]> {
|
||||
if (uuids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filters: SCSearchFilter[] = [
|
||||
{
|
||||
arguments: {
|
||||
field: 'type',
|
||||
value: SCThingType.DateSeries,
|
||||
},
|
||||
type: 'value',
|
||||
},
|
||||
{
|
||||
arguments: {
|
||||
filters: uuids.map(uid => ({
|
||||
arguments: {
|
||||
field: 'uid',
|
||||
value: uid,
|
||||
},
|
||||
type: 'value',
|
||||
})),
|
||||
operation: 'or',
|
||||
},
|
||||
type: 'boolean',
|
||||
},
|
||||
];
|
||||
|
||||
if (frequencies) {
|
||||
filters.push({
|
||||
arguments: {
|
||||
filters: frequencies.map(frequency => ({
|
||||
arguments: {
|
||||
field: 'frequency',
|
||||
value: frequency,
|
||||
},
|
||||
type: 'value',
|
||||
})),
|
||||
operation: 'or',
|
||||
},
|
||||
type: 'boolean',
|
||||
});
|
||||
}
|
||||
|
||||
if (from || to) {
|
||||
const bounds: Bounds<string> = {};
|
||||
if (from) {
|
||||
bounds.lowerBound = {
|
||||
limit: from,
|
||||
mode: 'inclusive',
|
||||
};
|
||||
}
|
||||
if (to) {
|
||||
bounds.upperBound = {
|
||||
limit: to,
|
||||
mode: 'inclusive',
|
||||
};
|
||||
}
|
||||
filters.push({
|
||||
arguments: {
|
||||
field: 'dates',
|
||||
bounds: bounds,
|
||||
},
|
||||
type: 'date range',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
await this.dataProvider.search({
|
||||
filter: {
|
||||
arguments: {
|
||||
filters: filters,
|
||||
operation: 'and',
|
||||
},
|
||||
type: 'boolean',
|
||||
},
|
||||
})
|
||||
).data as SCDateSeries[];
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this._uuidSubscription?.unsubscribe();
|
||||
window.removeEventListener('storage', this.storageEventListener.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -135,7 +135,7 @@ describe('StorageProvider', () => {
|
||||
spyOn(storage, 'clear').and.callThrough();
|
||||
await storageProvider.putMultiple(sampleEntries);
|
||||
let entries = await storageProvider.getAll();
|
||||
expect([...entries.values()].length).toBe(3);
|
||||
expect([...entries.values()].length).not.toBe(0);
|
||||
await storageProvider.deleteAll();
|
||||
|
||||
entries = await storageProvider.getAll();
|
||||
|
||||
35
src/app/util/array-last.pipe.ts
Normal file
35
src/app/util/array-last.pipe.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (C) 2021 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, Pipe, PipeTransform} from '@angular/core';
|
||||
import {last} from 'lodash-es';
|
||||
|
||||
/**
|
||||
* Get the last value of an array
|
||||
*/
|
||||
@Injectable()
|
||||
@Pipe({
|
||||
name: 'last',
|
||||
pure: true,
|
||||
})
|
||||
export class ArrayLastPipe implements PipeTransform {
|
||||
/**
|
||||
* Transform
|
||||
*/
|
||||
// tslint:disable-next-line:prefer-function-over-method
|
||||
transform<T>(value: T[]): T | undefined {
|
||||
return last(value);
|
||||
}
|
||||
}
|
||||
36
src/app/util/date-is-today.pipe.ts
Normal file
36
src/app/util/date-is-today.pipe.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (C) 2021 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, Pipe, PipeTransform} from '@angular/core';
|
||||
import moment, {Moment, unitOfTime} from 'moment';
|
||||
|
||||
/**
|
||||
* Get the last value of an array
|
||||
*/
|
||||
@Injectable()
|
||||
@Pipe({
|
||||
name: 'dateIsThis',
|
||||
pure: false, // pure pipe can break in some change detection scenarios,
|
||||
// specifically, on the calendar view it causes it to stay true even when you navigate
|
||||
})
|
||||
export class DateIsThisPipe implements PipeTransform {
|
||||
/**
|
||||
* Transform
|
||||
*/
|
||||
// tslint:disable-next-line:prefer-function-over-method
|
||||
transform(value: Moment, granularity: unitOfTime.StartOf): boolean {
|
||||
return value.isSame(moment(moment.now()), granularity);
|
||||
}
|
||||
}
|
||||
34
src/app/util/nullish-coalecing.pipe.ts
Normal file
34
src/app/util/nullish-coalecing.pipe.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (C) 2021 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, Pipe, PipeTransform} from '@angular/core';
|
||||
|
||||
/**
|
||||
* Get the last value of an array
|
||||
*/
|
||||
@Injectable()
|
||||
@Pipe({
|
||||
name: 'nullishCoalesce',
|
||||
pure: true,
|
||||
})
|
||||
export class NullishCoalescingPipe implements PipeTransform {
|
||||
/**
|
||||
* Transform
|
||||
*/
|
||||
// tslint:disable-next-line:prefer-function-over-method
|
||||
transform<T, G>(value: T, fallback: G): T | G {
|
||||
return value ?? fallback;
|
||||
}
|
||||
}
|
||||
25
src/app/util/util.module.ts
Normal file
25
src/app/util/util.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (C) 2021 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 {NgModule} from '@angular/core';
|
||||
import {ArrayLastPipe} from './array-last.pipe';
|
||||
import {DateIsThisPipe} from './date-is-today.pipe';
|
||||
import {NullishCoalescingPipe} from './nullish-coalecing.pipe';
|
||||
|
||||
@NgModule({
|
||||
declarations: [ArrayLastPipe, DateIsThisPipe, NullishCoalescingPipe],
|
||||
exports: [ArrayLastPipe, DateIsThisPipe, NullishCoalescingPipe],
|
||||
})
|
||||
export class UtilModule {}
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"ok": "ok",
|
||||
"abort": "abbrechen",
|
||||
"app": {
|
||||
"ui": {
|
||||
"CLOSE": "Schließen"
|
||||
@@ -118,6 +120,22 @@
|
||||
"search": {
|
||||
"nothing_found": "Keine Ergebnisse"
|
||||
},
|
||||
"schedule": {
|
||||
"recurring": "Stundenplan",
|
||||
"calendar": "Kalender",
|
||||
"single": "Einzeltermine",
|
||||
"addEventModal": {
|
||||
"close": "Schließen",
|
||||
"addEvent": "Events Hinzufügen"
|
||||
}
|
||||
},
|
||||
"chips": {
|
||||
"addEvent": {
|
||||
"addEvent": "Event hinzufügen",
|
||||
"addedToEvents": "Event hinzugefügt",
|
||||
"pastEvent": "Event ist vorbei"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"resetAlert.title": "Alle Einstellungen zurücksetzen?",
|
||||
"resetAlert.message": "Sind Sie sich sicher, alle Einstellungen auf ihre Anfangswerte zurückzusetzen?",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"ok": "ok",
|
||||
"abort": "abort",
|
||||
"app": {
|
||||
"ui": {
|
||||
"CLOSE": "Close"
|
||||
@@ -118,6 +120,22 @@
|
||||
"search": {
|
||||
"nothing_found": "No results"
|
||||
},
|
||||
"schedule": {
|
||||
"recurring": "Recurring",
|
||||
"calendar": "Calendar",
|
||||
"single": "Single Events",
|
||||
"addEventModal": {
|
||||
"close": "close",
|
||||
"addEvent": "Add Events"
|
||||
}
|
||||
},
|
||||
"chips": {
|
||||
"addEvent": {
|
||||
"addEvent": "Add event",
|
||||
"addedToEvents": "Added to events",
|
||||
"pastEvent": "Event is over"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"resetAlert.title": "Reset all settings?",
|
||||
"resetAlert.message": "Are you sure to reset all settings to their default values?",
|
||||
|
||||
Reference in New Issue
Block a user