feat: timetable module - schedule and calendar

This commit is contained in:
Wieland Schöbl
2021-08-13 12:27:40 +00:00
parent e81b2e161d
commit d8ede006df
59 changed files with 3287 additions and 555 deletions

763
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View 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;
}
}

View 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'),
]),
),
]);

View File

@@ -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%)'})),
]),
]);

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
div {
display: flex;
flex-direction: row;
width: fit-content;
}

View File

@@ -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();
}
}

View File

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

View File

@@ -5,3 +5,7 @@
ion-card-content {
width: fit-content;
}
.action-buttons {
float: right;
}

View File

@@ -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,
);
}
}

View File

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

View File

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

View 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'});
});
});

View 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!;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}

View File

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

View File

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

View File

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

View 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,
);
}
}

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

View 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;
}
}

View 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);
}
}

View File

@@ -0,0 +1,9 @@
<ion-slides
#slides
pager="false"
[options]="slideOpts"
(ionSlideNextEnd)="onPageChange(1)"
(ionSlidePrevEnd)="onPageChange(-1)"
>
<ng-content></ng-content>
</ion-slides>

View File

@@ -0,0 +1,4 @@
ion-slides {
width: 100%;
height: 1100px; // BIG TODO: This is completely bypasses the scale parameter
}

View 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;
}
}

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

View 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%
);
}
}

View File

@@ -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*/);
}
}

View File

@@ -0,0 +1,6 @@
<div [style.marginTop.px]="(now - hoursRange.from) * scale">
<div>
<hr />
<div></div>
</div>
</div>

View 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);
}
}
}

View File

@@ -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',
};
}

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

View 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;
}
}

View 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;
}
}

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

View 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;
}
}

View File

@@ -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();
},
);
}
}

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

View 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%
}

View 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),
],
]);
});
});

View 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';
}
}

View 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 {}

View 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;
}

View 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 {}

View 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));
}
}

View File

@@ -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();

View 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);
}
}

View 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);
}
}

View 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;
}
}

View 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 {}

View File

@@ -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?",

View File

@@ -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?",