Compare commits

..

16 Commits

Author SHA1 Message Date
Rainer Killinger
5050ac90eb docs: update changelogs for release
ci: publish release
2024-06-28 09:28:39 +02:00
Rainer Killinger
688bc5f2e7 refactor: add changeset 2024-06-28 09:03:54 +02:00
Rainer Killinger
ad174dd7d7 refactor: use static map files from backend 2024-06-28 08:59:08 +02:00
Rainer Killinger
26e654f5b8 fix: app license overview 2024-06-27 14:57:24 +02:00
Rainer Killinger
5439484a90 fix: changelog typo 2024-06-27 12:06:52 +02:00
Rainer Killinger
68f3366a27 refactor: adjustments for recent PAIA changes 2024-06-27 09:23:55 +00:00
Jovan Krunić
dea9a82105 fix: do not fetch remote configuration if offline
Closes #206
2024-06-17 09:55:29 +00:00
39d2801114 feat: store id cards 2024-06-12 13:51:46 +02:00
Rainer Killinger
341b209092 refactor: display id-cards in their own modal 2024-06-05 11:07:30 +00:00
Rainer Killinger
a6b88d3534 refactor: add f-u specific changelogs 2024-05-27 16:27:37 +02:00
Rainer Killinger
be863daaef refactor: updated used licences within the app 2024-05-27 15:36:00 +02:00
2f64d69693 feat: migrate to protomaps and maplibre 2024-05-27 15:07:27 +02:00
964516d3cf fix: remove noUnused* TSConfig options
TSConfig options prevent Angular from compiling the app. This is
specifically harsh with the noUnused* rules, which require you to
strictly remove any unused variables even in dev mode while testing.
Since this case is already covered by ESLint, the TSConfig option was
removed.
2024-05-27 15:07:27 +02:00
71ff9fd960 fix: favorite button 2024-05-27 15:07:27 +02:00
abf9999461 feat: type-safe sc-icons 2024-05-27 15:07:26 +02:00
53c3d0ba0c refactor: replace rfdc with native structuredClone 2024-05-27 15:07:25 +02:00
200 changed files with 3457 additions and 2433 deletions

View File

@@ -2,7 +2,13 @@
/** @type {import('syncpack').RcFile} */ /** @type {import('syncpack').RcFile} */
const config = { const config = {
semverGroups: [{range: ''}], semverGroups: [
{
range: '',
dependencies: ['**'],
packages: ['**'],
}
],
source: ['package.json', '**/package.json'], source: ['package.json', '**/package.json'],
indent: ' ', indent: ' ',
sortFirst: [ sortFirst: [

View File

@@ -1,5 +1,18 @@
# @openstapps/backend # @openstapps/backend
## 3.3.0
### Minor Changes
- 688bc5f2: v3.3.0 changes
### Patch Changes
- Updated dependencies [688bc5f2]
- @openstapps/core@3.3.0
- @openstapps/core-tools@3.3.0
- @openstapps/logger@3.0.0
## 3.2.0 ## 3.2.0
### Minor Changes ### Minor Changes

View File

@@ -0,0 +1,15 @@
# Goethe-Uni App 2.5
Die Goethe-Uni App ist noch besser geworden!
## Komplett neue Kartenansicht
Wir haben die Karte überarbeitet, um eine klarere und schnellere Übersicht zu bieten.
## Deutschlandticket mit an Bord
Wenn du das Upgrade des Semesterticket zum Deutschlandticket gemacht hast und eingeloggt bist, findet es sich jetzt auch in der App.
## Bibliotheksdienste sind wieder voll funktionsfähig
Aufgrund einiger Adhoc-Änderungen im Bibliothekssystem haben wir die App so angepasst, dass sie damit umgehen kann.

View File

@@ -0,0 +1,15 @@
# Goethe-Uni App 2.5
The Goethe-Uni App got even better!
## Completely new map view
We overhauled the map to offer you a clearer and faster and overview.
## Deutschlandticket included
If you upgraded your Semesterticket to a Deutschlandticket it will now reside in the App if you are logged in.
## Library services are fully functional again
Due to some adhoc changes in the library system we adjusted the app to handle them properly.

View File

@@ -1,7 +1,7 @@
{ {
"name": "@openstapps/backend", "name": "@openstapps/backend",
"description": "A reference implementation for a StApps backend", "description": "A reference implementation for a StApps backend",
"version": "3.2.0", "version": "3.3.0",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",

View File

@@ -13,15 +13,34 @@
* You should have received a copy of the GNU Affero General Public License * You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {SCConfigFile, SCSearchQuery, SCSearchResponse, SCThings, SCUuid} from '@openstapps/core'; import {
SCConfigFile,
SCPlace,
SCPlaceWithoutReferences,
SCSearchQuery,
SCSearchResponse,
SCThingWithCategoriesWithoutReferences,
SCThings,
SCUuid,
} from '@openstapps/core';
import {MailQueue} from '../notification/mail-queue.js'; import {MailQueue} from '../notification/mail-queue.js';
import {Bulk} from './bulk-storage.js'; import {Bulk} from './bulk-storage.js';
import {FeatureCollection, Point, Polygon} from 'geojson';
/** /**
* Creates an instance of a database * Creates an instance of a database
*/ */
export type DatabaseConstructor = new (config: SCConfigFile, mailQueue?: MailQueue) => Database; export type DatabaseConstructor = new (config: SCConfigFile, mailQueue?: MailQueue) => Database;
export type SupplementaryGeoJSON = FeatureCollection<Point | Polygon, SupplementaryGeoJSONThing>;
export type SupplementaryGeoJSONThing = Pick<
Extract<SCThings, SCPlace>,
Exclude<
keyof SCPlaceWithoutReferences | keyof SCThingWithCategoriesWithoutReferences<never, never>,
'geo' | 'origin' | 'translations'
>
>;
/** /**
* Defines what one database class needs to have defined * Defines what one database class needs to have defined
*/ */
@@ -82,4 +101,9 @@ export interface Database {
* @param params Parameters which form a search query to search the backend data * @param params Parameters which form a search query to search the backend data
*/ */
search(parameters: SCSearchQuery): Promise<SCSearchResponse>; search(parameters: SCSearchQuery): Promise<SCSearchResponse>;
/**
* Get geo info for display on a map
*/
geo(): Promise<SupplementaryGeoJSON>;
} }

View File

@@ -26,7 +26,7 @@ import {Logger} from '@openstapps/logger';
import moment from 'moment'; import moment from 'moment';
import {MailQueue} from '../../notification/mail-queue.js'; import {MailQueue} from '../../notification/mail-queue.js';
import {Bulk} from '../bulk-storage.js'; import {Bulk} from '../bulk-storage.js';
import {Database} from '../database.js'; import {Database, SupplementaryGeoJSON, SupplementaryGeoJSONThing} from '../database.js';
import {parseAggregations} from './aggregations.js'; import {parseAggregations} from './aggregations.js';
import * as Monitoring from './monitoring.js'; import * as Monitoring from './monitoring.js';
import {buildQuery} from './query/query.js'; import {buildQuery} from './query/query.js';
@@ -46,6 +46,7 @@ import {
} from './util/index.js'; } from './util/index.js';
import {noUndefined} from './util/no-undefined.js'; import {noUndefined} from './util/no-undefined.js';
import {retryCatch, RetryOptions} from './util/retry.js'; import {retryCatch, RetryOptions} from './util/retry.js';
import {Feature, Point, Polygon} from 'geojson';
/** /**
* A database interface for elasticsearch * A database interface for elasticsearch
@@ -405,4 +406,49 @@ export class Elasticsearch implements Database {
}, },
}; };
} }
async geo(): Promise<SupplementaryGeoJSON> {
const searchResponse = await this.client.search<Extract<SCThings, {geo: unknown}>>({
body: {
query: {
exists: {
field: 'geo',
},
},
},
from: 0,
allow_no_indices: true,
index: ACTIVE_INDICES_ALIAS,
size: 1,
});
return {
type: 'FeatureCollection',
features: searchResponse.hits.hits
.map(thing => {
return thing._source?.geo
? ({
id: Number(thing._source.identifiers?.['OSM']) || undefined,
type: 'Feature',
geometry: thing._source.geo.polygon ?? thing._source.geo.point,
properties: {
name: thing._source.name,
sameAs: thing._source.sameAs,
image: thing._source.image,
alternateNames: thing._source.alternateNames,
description: thing._source.description,
identifiers: thing._source.identifiers,
categories: thing._source.categories,
categorySpecificValues: thing._source.categorySpecificValues,
openingHours: thing._source.openingHours,
address: thing._source.address,
uid: thing._source.uid,
type: thing._source.type,
},
} satisfies Feature<Polygon | Point, SupplementaryGeoJSONThing>)
: undefined;
})
.filter(noUndefined),
};
}
} }

View File

@@ -19,14 +19,29 @@ import {QueryDslSpecificQueryContainer} from '../../types/util.js';
* Converts a geo filter to elasticsearch syntax * Converts a geo filter to elasticsearch syntax
* @param filter A search filter for the retrieval of the data * @param filter A search filter for the retrieval of the data
*/ */
export function buildGeoFilter(filter: SCGeoFilter): QueryDslSpecificQueryContainer<'geo_shape'> { export function buildGeoFilter(filter: SCGeoFilter): QueryDslSpecificQueryContainer<'bool'> {
return { return {
geo_shape: { bool: {
ignore_unmapped: true, should: [
[`${filter.arguments.field}.polygon`]: { {
shape: filter.arguments.shape, geo_shape: {
relation: filter.arguments.spatialRelation, ignore_unmapped: true,
}, [`${filter.arguments.field}.polygon`]: {
shape: filter.arguments.shape,
relation: filter.arguments.spatialRelation,
},
},
} satisfies QueryDslSpecificQueryContainer<'geo_shape'>,
{
geo_shape: {
ignore_unmapped: true,
[`${filter.arguments.field}.point`]: {
shape: filter.arguments.shape,
relation: filter.arguments.spatialRelation,
},
},
} satisfies QueryDslSpecificQueryContainer<'geo_shape'>,
],
}, },
}; };
} }

View File

@@ -22,7 +22,7 @@ import http from 'http';
import {MailQueue} from '../src/notification/mail-queue.js'; import {MailQueue} from '../src/notification/mail-queue.js';
import {Bulk, BulkStorage} from '../src/storage/bulk-storage.js'; import {Bulk, BulkStorage} from '../src/storage/bulk-storage.js';
import getPort from 'get-port'; import getPort from 'get-port';
import {Database} from '../src/storage/database.js'; import {Database, SupplementaryGeoJSON} from '../src/storage/database.js';
import {v4} from 'uuid'; import {v4} from 'uuid';
import {backendConfig} from '../src/config.js'; import {backendConfig} from '../src/config.js';
import {getIndexUID} from '../src/storage/elasticsearch/util/index.js'; import {getIndexUID} from '../src/storage/elasticsearch/util/index.js';
@@ -58,7 +58,6 @@ export async function startApp(): Promise<Express> {
* An elasticsearch mock * An elasticsearch mock
*/ */
export class ElasticsearchMock implements Database { export class ElasticsearchMock implements Database {
// @ts-expect-error never read
private bulk: Bulk | undefined; private bulk: Bulk | undefined;
private storageMock = new Map<string, SCThings>(); private storageMock = new Map<string, SCThings>();
@@ -67,6 +66,10 @@ export class ElasticsearchMock implements Database {
// Nothing to do here // Nothing to do here
} }
geo(): Promise<SupplementaryGeoJSON> {
throw new Error('Method not implemented.');
}
bulkCreated(bulk: Bulk): Promise<void> { bulkCreated(bulk: Bulk): Promise<void> {
this.bulk = bulk; this.bulk = bulk;
return Promise.resolve(undefined); return Promise.resolve(undefined);

View File

@@ -44,7 +44,6 @@ describe('Search route', async function () {
}); });
it('should reject GET, PUT with a valid search query', async function () { it('should reject GET, PUT with a valid search query', async function () {
// const expectedParams = JSON.parse(JSON.stringify(defaultParams));
const {status} = await testApp.get('/search').set('Accept', 'application/json').send({ const {status} = await testApp.get('/search').set('Accept', 'application/json').send({
query: 'Some search terms', query: 'Some search terms',
}); });

View File

@@ -479,18 +479,39 @@ describe('Query', function () {
it('should build geo filter for shapes and points', function () { it('should build geo filter for shapes and points', function () {
const filter = buildFilter(searchFilters.geoPoint); const filter = buildFilter(searchFilters.geoPoint);
const expectedFilter = { const expectedFilter = {
geo_shape: { bool: {
'geo.polygon': { should: [
relation: undefined, {
shape: { geo_shape: {
type: 'envelope', 'geo.polygon': {
coordinates: [ relation: undefined,
[50.123, 8.123], shape: {
[50.123, 8.123], coordinates: [
], [50.123, 8.123],
[50.123, 8.123],
],
type: 'envelope',
},
},
'ignore_unmapped': true,
},
}, },
}, {
'ignore_unmapped': true, geo_shape: {
'geo.point': {
relation: undefined,
shape: {
coordinates: [
[50.123, 8.123],
[50.123, 8.123],
],
type: 'envelope',
},
},
'ignore_unmapped': true,
},
},
],
}, },
}; };
@@ -500,18 +521,39 @@ describe('Query', function () {
it('should build geo filter for shapes only', function () { it('should build geo filter for shapes only', function () {
const filter = buildFilter(searchFilters.geoShape); const filter = buildFilter(searchFilters.geoShape);
const expectedFilter = { const expectedFilter = {
geo_shape: { bool: {
'geo.polygon': { should: [
relation: 'contains', {
shape: { geo_shape: {
type: 'envelope', 'geo.polygon': {
coordinates: [ relation: 'contains',
[50.123, 8.123], shape: {
[50.123, 8.123], coordinates: [
], [50.123, 8.123],
[50.123, 8.123],
],
type: 'envelope',
},
},
'ignore_unmapped': true,
},
}, },
}, {
'ignore_unmapped': true, geo_shape: {
'geo.point': {
relation: 'contains',
shape: {
coordinates: [
[50.123, 8.123],
[50.123, 8.123],
],
type: 'envelope',
},
},
'ignore_unmapped': true,
},
},
],
}, },
}; };

View File

@@ -1,5 +1,11 @@
# @openstapps/tsconfig # @openstapps/tsconfig
## 3.3.0
### Minor Changes
- 688bc5f2: v3.3.0 changes
## 3.0.0 ## 3.0.0
### Major Changes ### Major Changes
@@ -30,7 +36,7 @@
```js ```js
#!/usr/bin/env node #!/usr/bin/env node
import './lib/app.js'; import "./lib/app.js";
``` ```
- 64caebaf: Migrate to ESM - 64caebaf: Migrate to ESM
@@ -105,7 +111,7 @@
```js ```js
#!/usr/bin/env node #!/usr/bin/env node
import './lib/app.js'; import "./lib/app.js";
``` ```
- 64caebaf: Migrate to ESM - 64caebaf: Migrate to ESM

View File

@@ -1,7 +1,7 @@
{ {
"name": "@openstapps/tsconfig", "name": "@openstapps/tsconfig",
"description": "The tsconfig for the openstapps project", "description": "The tsconfig for the openstapps project",
"version": "3.0.0", "version": "3.3.0",
"type": "commonjs", "type": "commonjs",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"repository": "git@gitlab.com:openstapps/eslint-config.git", "repository": "git@gitlab.com:openstapps/eslint-config.git",

View File

@@ -19,15 +19,21 @@
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"noImplicitAny": true, "noImplicitAny": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"noUnusedLocals": true, "noUnusedLocals": false,
"noUnusedParameters": true, "noUnusedParameters": false,
"outDir": "../../../lib/", "outDir": "../../../lib/",
"lib": ["ES2022", "DOM"], "lib": [
"ES2022",
"DOM"
],
"strict": true, "strict": true,
"target": "ES2022" "target": "ES2022"
}, },
"ts-node": { "ts-node": {
"transpileOnly": true "transpileOnly": true
}, },
"exclude": ["../../../app.js", "../../../lib/"] "exclude": [
"../../../app.js",
"../../../lib/"
]
} }

View File

@@ -1,12 +1,21 @@
# @openstapps/minimal-connector # @openstapps/minimal-connector
## 3.3.0
### Patch Changes
- Updated dependencies [688bc5f2]
- @openstapps/api@3.3.0
- @openstapps/core@3.3.0
- @openstapps/logger@3.0.0
## 3.2.0 ## 3.2.0
### Patch Changes ### Patch Changes
- Updated dependencies [912ae422] - Updated dependencies [912ae422]
- @openstapps/core@4.0.0 - @openstapps/core@3.2.0
- @openstapps/api@4.0.0 - @openstapps/api@3.2.0
- @openstapps/logger@3.0.0 - @openstapps/logger@3.0.0
## 3.1.1 ## 3.1.1

View File

@@ -1,7 +1,7 @@
{ {
"name": "@openstapps/minimal-connector", "name": "@openstapps/minimal-connector",
"description": "This is a minimal connector which serves as an example", "description": "This is a minimal connector which serves as an example",
"version": "3.2.0", "version": "3.3.0",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",

View File

@@ -1,13 +1,24 @@
# @openstapps/minimal-plugin # @openstapps/minimal-plugin
## 3.3.0
### Patch Changes
- Updated dependencies [688bc5f2]
- @openstapps/api@3.3.0
- @openstapps/core@3.3.0
- @openstapps/api-plugin@3.3.0
- @openstapps/core-tools@3.3.0
- @openstapps/logger@3.0.0
## 3.2.0 ## 3.2.0
### Patch Changes ### Patch Changes
- Updated dependencies [912ae422] - Updated dependencies [912ae422]
- @openstapps/core@4.0.0 - @openstapps/core@3.2.0
- @openstapps/api@4.0.0 - @openstapps/api@3.2.0
- @openstapps/api-plugin@4.0.0 - @openstapps/api-plugin@3.2.0
- @openstapps/core-tools@3.0.0 - @openstapps/core-tools@3.0.0
- @openstapps/logger@3.0.0 - @openstapps/logger@3.0.0

View File

@@ -1,7 +1,7 @@
{ {
"name": "@openstapps/minimal-plugin", "name": "@openstapps/minimal-plugin",
"description": "Minimal Plugin", "description": "Minimal Plugin",
"version": "3.2.0", "version": "3.3.0",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",

12
flake.lock generated
View File

@@ -5,11 +5,11 @@
"systems": "systems" "systems": "systems"
}, },
"locked": { "locked": {
"lastModified": 1701680307, "lastModified": 1709126324,
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=", "narHash": "sha256-q6EQdSeUZOG26WelxqkmR7kArjgWCdw5sfJVHPH/7j8=",
"owner": "numtide", "owner": "numtide",
"repo": "flake-utils", "repo": "flake-utils",
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725", "rev": "d465f4819400de7c8d874d50b982301f28a84605",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -20,11 +20,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1701626906, "lastModified": 1709747860,
"narHash": "sha256-ugr1QyzzwNk505ICE4VMQzonHQ9QS5W33xF2FXzFQ00=", "narHash": "sha256-RT4zuBy579m+l8VyIQFOR66WXfcs4g1jntZUHjh6eoI=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "0c6d8c783336a59f4c59d4a6daed6ab269c4b361", "rev": "58ae79ea707579c40102ddf62d84b902a987c58b",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -28,6 +28,7 @@
hash = "sha256-9o0nprGcJhudS1LNm+T7Vf0Dwd1RBauYKI+w1FBQ3ZM="; hash = "sha256-9o0nprGcJhudS1LNm+T7Vf0Dwd1RBauYKI+w1FBQ3ZM=";
}; };
}); });
nodejs = prev.nodejs_18;
}) })
]; ];
config = { config = {
@@ -49,21 +50,16 @@
devShell = pkgs.mkShell rec { devShell = pkgs.mkShell rec {
nativeBuildInputs = [androidFhs]; nativeBuildInputs = [androidFhs];
buildInputs = with pkgs; [ buildInputs = with pkgs; [
nodejs-18_x nodejs
nodePackages.pnpm corepack
# tools # tools
curl curl
jq jq
fontMin fontMin
# browsers
firefox
google-chrome
epiphany # Safari-ish browser
cypress cypress
# android # android
jdk17 jdk17
android.androidsdk android.androidsdk
musl
]; ];
ANDROID_JAVA_HOME = "${pkgs.jdk.home}"; ANDROID_JAVA_HOME = "${pkgs.jdk.home}";
ANDROID_SDK_ROOT = "${pkgs.android.androidsdk}/libexec/android-sdk"; ANDROID_SDK_ROOT = "${pkgs.android.androidsdk}/libexec/android-sdk";

View File

@@ -1,5 +1,18 @@
# @openstapps/app # @openstapps/app
## 3.3.0
### Minor Changes
- 688bc5f2: v3.3.0 changes
### Patch Changes
- Updated dependencies [688bc5f2]
- @openstapps/api@3.2.0
- @openstapps/core@3.2.0
- @openstapps/collection-utils@3.0.0
## 3.2.0 ## 3.2.0
### Patch Changes ### Patch Changes

View File

@@ -50,11 +50,11 @@ the config file.
Icon font minification is done automatically, but requires you to Icon font minification is done automatically, but requires you to
follow a few simple rules: follow a few simple rules:
1. Use the tagged template literal for referencing icon names in 1. Use the type-safe proxy for referencing icon names in
TypeScript files and code TypeScript files and code
```ts ```ts
SCIcon`icon_name`; SCIcon.icon_name;
``` ```
2. When using `ion-icon` in HTML, reference either icons that went through 2. When using `ion-icon` in HTML, reference either icons that went through

View File

@@ -13,25 +13,25 @@ The StApps 1.x.x (legacy app, but current app in stores) is written using Ionic
There are (`npm`) scripts defined to get the app running as quickly as possible. Those scripts (shortcuts for docker commands) are called using the syntax `npm run + <script-name>`. So we have the following commands available: There are (`npm`) scripts defined to get the app running as quickly as possible. Those scripts (shortcuts for docker commands) are called using the syntax `npm run + <script-name>`. So we have the following commands available:
``` ```sh
npm run docker:pull npm run docker:pull
``` ```
which pulls the up-to-date image ([Dockerfile](Dockerfile)) which contains all the tools needed for building, serving and deploying the app. which pulls the up-to-date image ([Dockerfile](Dockerfile)) which contains all the tools needed for building, serving and deploying the app.
``` ```sh
npm run docker:enter npm run docker:enter
``` ```
which enters the container on docker builder image, where we can run `npm install` (to install the required npm packages) and `npm build` (to build the app: convert into executable files), but also any other arbitrary commands with the tools available in the docker image. which enters the container on docker builder image, where we can run `npm install` (to install the required npm packages) and `npm build` (to build the app: convert into executable files), but also any other arbitrary commands with the tools available in the docker image.
``` ```sh
npm run docker:build npm run docker:build
``` ```
which runs `npm install` (to install the required npm packages) and `npm build` (to build the app: convert into executable files) in the docker container which runs on the docker builder image. which runs `npm install` (to install the required npm packages) and `npm build` (to build the app: convert into executable files) in the docker container which runs on the docker builder image.
``` ```sh
npm run docker:serve npm run docker:serve
``` ```
@@ -39,7 +39,7 @@ which serves the app for running it in the browser. It basically runs `ionic ser
## How to build and start the app using the default backend? ## How to build and start the app using the default backend?
``` ```sh
npm run build npm run build
npm run start npm run start
``` ```
@@ -86,52 +86,98 @@ addToIonicDB(
You'll need to run _Chromium_ using You'll need to run _Chromium_ using
```shell ```sh
pnpm chromium:no-cors pnpm chromium:no-cors
``` ```
### Help, I can't log in! ### Help, I can't log in!
Login services will often block hosts not coming from the production Login services will often block hosts not coming from the production
server. You can circumvent this locally by using the `:virtual-host` server.
scripts:
```shell #### Web
On the web you can circumvent this locally by using the `:virtual-host` scripts:
```sh
# Start the dev server on mobile.app.uni-frankfurt.de # Start the dev server on mobile.app.uni-frankfurt.de
pnpm start:virtual-host pnpm start:virtual-host
# Run chromium with flags that redirect mobile.app.uni-frankfurt.de to localhost:8100 # Run chromium with flags that redirect mobile.app.uni-frankfurt.de to localhost:8100
pnpm chromium:virtual-host pnpm chromium:virtual-host
``` ```
#### Android
On Android you will need to change the `custom_url_scheme` values
to `de.unifrankfurt.app` in the following files:
- `android/app/src/main/res/values/strings.xml`
- `src/environment/environment.ts`
Then start the app normally as you would
```sh
pnpm run:android
```
**This alone will not make auth work**, only the login flow.
If you need to test login, you have to disable live reload:
```sh
pnpm ionic capacitor run android
```
_**CAUTION:** a remote chrome debugging session can in some
cases hijack the device's ADB connection. If the connection
fails for no obvious reason, close chrome and uninstall the
app, then try again._
#### iOS
On Android you will need to change the `custom_url_scheme` value in
`src/environment/environment.ts` as well as the `CFBundleURLTypes`
in `ios/App/App/Info.plist`.
- make sure to remove any `Info.plist.orig` as capacitor might override
the modified `Info.plist` with that.
- make sure you have a valid device in XCode (Simulator or real device).
After that, run
```sh
pnpm run:ios
```
### Running the app ### Running the app
Install the npm packages needed for running the app (as for any other node project which uses npm): Install the npm packages needed for running the app (as for any other node project which uses npm):
``` ```sh
npm install npm install
``` ```
Check the code for linter issues: Check the code for linter issues:
``` ```sh
npm run lint npm run lint
``` ```
Automatically fix linter issues (those where autofix is possible): Automatically fix linter issues (those where autofix is possible):
``` ```sh
npm run lint:fix npm run lint:fix
``` ```
Build the app (transpile etc.): Build the app (transpile etc.):
``` ```sh
npm run build npm run build
``` ```
Open the app in the browser: Open the app in the browser:
``` ```sh
ionic serve ionic serve
``` ```
@@ -139,7 +185,7 @@ ionic serve
Run the app for testing on an android device (with live reload in the webview / device, when files are changed): Run the app for testing on an android device (with live reload in the webview / device, when files are changed):
``` ```sh
npm run build # if needed npm run build # if needed
npm run resources:android # generate needed resources (icons and splashscreens) npm run resources:android # generate needed resources (icons and splashscreens)
npm run docker:run:android # runs "ionic capacitor run android --livereload --external" on a selected device npm run docker:run:android # runs "ionic capacitor run android --livereload --external" on a selected device
@@ -159,7 +205,7 @@ Besides that, it is possible to monitor processes (and so the processes related
Build the (debug) app for testing on an android device (creates an APK file in the android's build outputs path): Build the (debug) app for testing on an android device (creates an APK file in the android's build outputs path):
``` ```sh
npm run docker:build:android npm run docker:build:android
``` ```
@@ -169,13 +215,13 @@ The mentioned `docker:*:android` npm commands are executed in a docker container
Execute unit tests: Execute unit tests:
``` ```sh
npm test npm test
``` ```
Execute e2e tests: Execute e2e tests:
``` ```sh
npm run e2e npm run e2e
``` ```

View File

@@ -9,6 +9,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies { dependencies {
implementation project(':capacitor-community-screen-brightness')
implementation project(':capacitor-app') implementation project(':capacitor-app')
implementation project(':capacitor-browser') implementation project(':capacitor-browser')
implementation project(':capacitor-clipboard') implementation project(':capacitor-clipboard')
@@ -21,6 +22,7 @@ dependencies {
implementation project(':capacitor-local-notifications') implementation project(':capacitor-local-notifications')
implementation project(':capacitor-network') implementation project(':capacitor-network')
implementation project(':capacitor-preferences') implementation project(':capacitor-preferences')
implementation project(':capacitor-screen-orientation')
implementation project(':capacitor-share') implementation project(':capacitor-share')
implementation project(':capacitor-splash-screen') implementation project(':capacitor-splash-screen')
implementation project(':transistorsoft-capacitor-background-fetch') implementation project(':transistorsoft-capacitor-background-fetch')

View File

@@ -1,4 +1,8 @@
[ [
{
"pkg": "@capacitor-community/screen-brightness",
"classpath": "com.elylucas.capscreenbrightness.ScreenBrightnessPlugin"
},
{ {
"pkg": "@capacitor/app", "pkg": "@capacitor/app",
"classpath": "com.capacitorjs.plugins.app.AppPlugin" "classpath": "com.capacitorjs.plugins.app.AppPlugin"
@@ -47,6 +51,10 @@
"pkg": "@capacitor/preferences", "pkg": "@capacitor/preferences",
"classpath": "com.capacitorjs.plugins.preferences.PreferencesPlugin" "classpath": "com.capacitorjs.plugins.preferences.PreferencesPlugin"
}, },
{
"pkg": "@capacitor/screen-orientation",
"classpath": "com.capacitorjs.plugins.screenorientation.ScreenOrientationPlugin"
},
{ {
"pkg": "@capacitor/share", "pkg": "@capacitor/share",
"classpath": "com.capacitorjs.plugins.share.SharePlugin" "classpath": "com.capacitorjs.plugins.share.SharePlugin"

View File

@@ -1,51 +1,57 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN // DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android' include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../../../node_modules/.pnpm/@capacitor+android@5.5.0_@capacitor+core@5.5.0/node_modules/@capacitor/android/capacitor') project(':capacitor-android').projectDir = new File('../../../node_modules/.pnpm/@capacitor+android@5.7.3_@capacitor+core@5.7.3/node_modules/@capacitor/android/capacitor')
include ':capacitor-community-screen-brightness'
project(':capacitor-community-screen-brightness').projectDir = new File('../../../node_modules/.pnpm/@capacitor-community+screen-brightness@6.0.0_@capacitor+core@5.7.3/node_modules/@capacitor-community/screen-brightness/android')
include ':capacitor-app' include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../../../node_modules/.pnpm/@capacitor+app@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/app/android') project(':capacitor-app').projectDir = new File('../../../node_modules/.pnpm/@capacitor+app@5.0.7_@capacitor+core@5.7.3/node_modules/@capacitor/app/android')
include ':capacitor-browser' include ':capacitor-browser'
project(':capacitor-browser').projectDir = new File('../../../node_modules/.pnpm/@capacitor+browser@5.1.0_@capacitor+core@5.5.0/node_modules/@capacitor/browser/android') project(':capacitor-browser').projectDir = new File('../../../node_modules/.pnpm/@capacitor+browser@5.2.0_@capacitor+core@5.7.3/node_modules/@capacitor/browser/android')
include ':capacitor-clipboard' include ':capacitor-clipboard'
project(':capacitor-clipboard').projectDir = new File('../../../node_modules/.pnpm/@capacitor+clipboard@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/clipboard/android') project(':capacitor-clipboard').projectDir = new File('../../../node_modules/.pnpm/@capacitor+clipboard@5.0.7_@capacitor+core@5.7.3/node_modules/@capacitor/clipboard/android')
include ':capacitor-device' include ':capacitor-device'
project(':capacitor-device').projectDir = new File('../../../node_modules/.pnpm/@capacitor+device@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/device/android') project(':capacitor-device').projectDir = new File('../../../node_modules/.pnpm/@capacitor+device@5.0.7_@capacitor+core@5.7.3/node_modules/@capacitor/device/android')
include ':capacitor-dialog' include ':capacitor-dialog'
project(':capacitor-dialog').projectDir = new File('../../../node_modules/.pnpm/@capacitor+dialog@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/dialog/android') project(':capacitor-dialog').projectDir = new File('../../../node_modules/.pnpm/@capacitor+dialog@5.0.7_@capacitor+core@5.7.3/node_modules/@capacitor/dialog/android')
include ':capacitor-filesystem' include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../../../node_modules/.pnpm/@capacitor+filesystem@5.1.4_@capacitor+core@5.5.0/node_modules/@capacitor/filesystem/android') project(':capacitor-filesystem').projectDir = new File('../../../node_modules/.pnpm/@capacitor+filesystem@5.2.1_@capacitor+core@5.7.3/node_modules/@capacitor/filesystem/android')
include ':capacitor-geolocation' include ':capacitor-geolocation'
project(':capacitor-geolocation').projectDir = new File('../../../node_modules/.pnpm/@capacitor+geolocation@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/geolocation/android') project(':capacitor-geolocation').projectDir = new File('../../../node_modules/.pnpm/@capacitor+geolocation@5.0.7_@capacitor+core@5.7.3/node_modules/@capacitor/geolocation/android')
include ':capacitor-haptics' include ':capacitor-haptics'
project(':capacitor-haptics').projectDir = new File('../../../node_modules/.pnpm/@capacitor+haptics@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/haptics/android') project(':capacitor-haptics').projectDir = new File('../../../node_modules/.pnpm/@capacitor+haptics@5.0.7_@capacitor+core@5.7.3/node_modules/@capacitor/haptics/android')
include ':capacitor-keyboard' include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../../../node_modules/.pnpm/@capacitor+keyboard@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/keyboard/android') project(':capacitor-keyboard').projectDir = new File('../../../node_modules/.pnpm/@capacitor+keyboard@5.0.8_@capacitor+core@5.7.3/node_modules/@capacitor/keyboard/android')
include ':capacitor-local-notifications' include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../../../node_modules/.pnpm/@capacitor+local-notifications@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/local-notifications/android') project(':capacitor-local-notifications').projectDir = new File('../../../node_modules/.pnpm/@capacitor+local-notifications@5.0.7_@capacitor+core@5.7.3/node_modules/@capacitor/local-notifications/android')
include ':capacitor-network' include ':capacitor-network'
project(':capacitor-network').projectDir = new File('../../../node_modules/.pnpm/@capacitor+network@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/network/android') project(':capacitor-network').projectDir = new File('../../../node_modules/.pnpm/@capacitor+network@5.0.7_@capacitor+core@5.7.3/node_modules/@capacitor/network/android')
include ':capacitor-preferences' include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../../../node_modules/.pnpm/@capacitor+preferences@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/preferences/android') project(':capacitor-preferences').projectDir = new File('../../../node_modules/.pnpm/@capacitor+preferences@5.0.7_@capacitor+core@5.7.3/node_modules/@capacitor/preferences/android')
include ':capacitor-screen-orientation'
project(':capacitor-screen-orientation').projectDir = new File('../../../node_modules/.pnpm/@capacitor+screen-orientation@6.0.0_@capacitor+core@5.7.3/node_modules/@capacitor/screen-orientation/android')
include ':capacitor-share' include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../../../node_modules/.pnpm/@capacitor+share@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/share/android') project(':capacitor-share').projectDir = new File('../../../node_modules/.pnpm/@capacitor+share@5.0.7_@capacitor+core@5.7.3/node_modules/@capacitor/share/android')
include ':capacitor-splash-screen' include ':capacitor-splash-screen'
project(':capacitor-splash-screen').projectDir = new File('../../../node_modules/.pnpm/@capacitor+splash-screen@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/splash-screen/android') project(':capacitor-splash-screen').projectDir = new File('../../../node_modules/.pnpm/@capacitor+splash-screen@5.0.7_@capacitor+core@5.7.3/node_modules/@capacitor/splash-screen/android')
include ':transistorsoft-capacitor-background-fetch' include ':transistorsoft-capacitor-background-fetch'
project(':transistorsoft-capacitor-background-fetch').projectDir = new File('../../../node_modules/.pnpm/@transistorsoft+capacitor-background-fetch@5.1.1_@capacitor+core@5.5.0/node_modules/@transistorsoft/capacitor-background-fetch/android') project(':transistorsoft-capacitor-background-fetch').projectDir = new File('../../../node_modules/.pnpm/@transistorsoft+capacitor-background-fetch@5.2.0_@capacitor+core@5.7.3/node_modules/@transistorsoft/capacitor-background-fetch/android')
include ':capacitor-secure-storage-plugin' include ':capacitor-secure-storage-plugin'
project(':capacitor-secure-storage-plugin').projectDir = new File('../../../node_modules/.pnpm/capacitor-secure-storage-plugin@0.9.0_@capacitor+core@5.5.0/node_modules/capacitor-secure-storage-plugin/android') project(':capacitor-secure-storage-plugin').projectDir = new File('../../../node_modules/.pnpm/capacitor-secure-storage-plugin@0.9.0_@capacitor+core@5.7.3/node_modules/capacitor-secure-storage-plugin/android')

View File

@@ -21,10 +21,13 @@
"allowedCommonJsDependencies": [ "allowedCommonJsDependencies": [
"moment", "moment",
"opening_hours", "opening_hours",
"leaflet", "localforage",
"leaflet.markercluster", "i18next",
"localforge", "semver",
"guid-typescript" "suncalc",
"guid-typescript",
"fast-deep-equal",
"maplibre-gl"
], ],
"aot": true, "aot": true,
"assets": [ "assets": [
@@ -32,24 +35,13 @@
"glob": "**/*", "glob": "**/*",
"input": "src/assets", "input": "src/assets",
"output": "assets" "output": "assets"
},
{
"glob": "**/*",
"input": "./node_modules/leaflet/dist/images",
"output": "assets/"
} }
], ],
"styles": [ "styles": [
{
"input": "src/theme/variables.scss",
"inject": true
},
{ {
"input": "src/global.scss", "input": "src/global.scss",
"inject": true "inject": true
}, }
"./node_modules/leaflet/dist/leaflet.css",
"./node_modules/leaflet.markercluster/dist/MarkerCluster.Default.css"
] ]
}, },
"configurations": { "configurations": {
@@ -132,11 +124,6 @@
"glob": "**/*", "glob": "**/*",
"input": "src/assets", "input": "src/assets",
"output": "/assets" "output": "/assets"
},
{
"glob": "**/*",
"input": "./node_modules/leaflet/dist/images",
"output": "assets/"
} }
] ]
} }

View File

@@ -12,9 +12,9 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import type {IconConfig} from './scripts/icon-config';
const config: IconConfig = { /** @type {import('./scripts/icon-config').IconConfig} */
const config = {
inputPath: 'node_modules/material-symbols/material-symbols-rounded.woff2', inputPath: 'node_modules/material-symbols/material-symbols-rounded.woff2',
outputPath: 'src/assets/icons.min.woff2', outputPath: 'src/assets/icons.min.woff2',
htmlGlob: 'src/**/*.html', htmlGlob: 'src/**/*.html',
@@ -38,10 +38,6 @@ const config: IconConfig = {
'work', 'work',
], ],
}, },
codePoints: {
ios_share: 'e6b8',
fact_check: 'f0c5',
},
}; };
export default config; export default config;

View File

@@ -1,4 +1,4 @@
require_relative '../../../../node_modules/.pnpm/@capacitor+ios@5.5.0_@capacitor+core@5.5.0/node_modules/@capacitor/ios/scripts/pods_helpers' require_relative '../../../../node_modules/.pnpm/@capacitor+ios@5.7.3_@capacitor+core@5.7.3/node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '13.0' platform :ios, '13.0'
use_frameworks! use_frameworks!
@@ -9,24 +9,26 @@ use_frameworks!
install! 'cocoapods', :disable_input_output_paths => true install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods def capacitor_pods
pod 'Capacitor', :path => '../../../../node_modules/.pnpm/@capacitor+ios@5.5.0_@capacitor+core@5.5.0/node_modules/@capacitor/ios' pod 'Capacitor', :path => '../../../../node_modules/.pnpm/@capacitor+ios@5.7.3_@capacitor+core@5.7.3/node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../../../node_modules/.pnpm/@capacitor+ios@5.5.0_@capacitor+core@5.5.0/node_modules/@capacitor/ios' pod 'CapacitorCordova', :path => '../../../../node_modules/.pnpm/@capacitor+ios@5.7.3_@capacitor+core@5.7.3/node_modules/@capacitor/ios'
pod 'CapacitorApp', :path => '../../../../node_modules/.pnpm/@capacitor+app@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/app' pod 'CapacitorCommunityScreenBrightness', :path => '../../../../node_modules/.pnpm/@capacitor-community+screen-brightness@6.0.0_@capacitor+core@5.7.3/node_modules/@capacitor-community/screen-brightness'
pod 'CapacitorBrowser', :path => '../../../../node_modules/.pnpm/@capacitor+browser@5.1.0_@capacitor+core@5.5.0/node_modules/@capacitor/browser' pod 'CapacitorApp', :path => '../../../../node_modules/.pnpm/@capacitor+app@5.0.7_@capacitor+core@5.7.3/node_modules/@capacitor/app'
pod 'CapacitorClipboard', :path => '../../../../node_modules/.pnpm/@capacitor+clipboard@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/clipboard' pod 'CapacitorBrowser', :path => '../../../../node_modules/.pnpm/@capacitor+browser@5.2.0_@capacitor+core@5.7.3/node_modules/@capacitor/browser'
pod 'CapacitorDevice', :path => '../../../../node_modules/.pnpm/@capacitor+device@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/device' pod 'CapacitorClipboard', :path => '../../../../node_modules/.pnpm/@capacitor+clipboard@5.0.7_@capacitor+core@5.7.3/node_modules/@capacitor/clipboard'
pod 'CapacitorDialog', :path => '../../../../node_modules/.pnpm/@capacitor+dialog@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/dialog' pod 'CapacitorDevice', :path => '../../../../node_modules/.pnpm/@capacitor+device@5.0.7_@capacitor+core@5.7.3/node_modules/@capacitor/device'
pod 'CapacitorFilesystem', :path => '../../../../node_modules/.pnpm/@capacitor+filesystem@5.1.4_@capacitor+core@5.5.0/node_modules/@capacitor/filesystem' pod 'CapacitorDialog', :path => '../../../../node_modules/.pnpm/@capacitor+dialog@5.0.7_@capacitor+core@5.7.3/node_modules/@capacitor/dialog'
pod 'CapacitorGeolocation', :path => '../../../../node_modules/.pnpm/@capacitor+geolocation@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/geolocation' pod 'CapacitorFilesystem', :path => '../../../../node_modules/.pnpm/@capacitor+filesystem@5.2.1_@capacitor+core@5.7.3/node_modules/@capacitor/filesystem'
pod 'CapacitorHaptics', :path => '../../../../node_modules/.pnpm/@capacitor+haptics@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/haptics' pod 'CapacitorGeolocation', :path => '../../../../node_modules/.pnpm/@capacitor+geolocation@5.0.7_@capacitor+core@5.7.3/node_modules/@capacitor/geolocation'
pod 'CapacitorKeyboard', :path => '../../../../node_modules/.pnpm/@capacitor+keyboard@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/keyboard' pod 'CapacitorHaptics', :path => '../../../../node_modules/.pnpm/@capacitor+haptics@5.0.7_@capacitor+core@5.7.3/node_modules/@capacitor/haptics'
pod 'CapacitorLocalNotifications', :path => '../../../../node_modules/.pnpm/@capacitor+local-notifications@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/local-notifications' pod 'CapacitorKeyboard', :path => '../../../../node_modules/.pnpm/@capacitor+keyboard@5.0.8_@capacitor+core@5.7.3/node_modules/@capacitor/keyboard'
pod 'CapacitorNetwork', :path => '../../../../node_modules/.pnpm/@capacitor+network@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/network' pod 'CapacitorLocalNotifications', :path => '../../../../node_modules/.pnpm/@capacitor+local-notifications@5.0.7_@capacitor+core@5.7.3/node_modules/@capacitor/local-notifications'
pod 'CapacitorPreferences', :path => '../../../../node_modules/.pnpm/@capacitor+preferences@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/preferences' pod 'CapacitorNetwork', :path => '../../../../node_modules/.pnpm/@capacitor+network@5.0.7_@capacitor+core@5.7.3/node_modules/@capacitor/network'
pod 'CapacitorShare', :path => '../../../../node_modules/.pnpm/@capacitor+share@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/share' pod 'CapacitorPreferences', :path => '../../../../node_modules/.pnpm/@capacitor+preferences@5.0.7_@capacitor+core@5.7.3/node_modules/@capacitor/preferences'
pod 'CapacitorSplashScreen', :path => '../../../../node_modules/.pnpm/@capacitor+splash-screen@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/splash-screen' pod 'CapacitorScreenOrientation', :path => '../../../../node_modules/.pnpm/@capacitor+screen-orientation@6.0.0_@capacitor+core@5.7.3/node_modules/@capacitor/screen-orientation'
pod 'TransistorsoftCapacitorBackgroundFetch', :path => '../../../../node_modules/.pnpm/@transistorsoft+capacitor-background-fetch@5.1.1_@capacitor+core@5.5.0/node_modules/@transistorsoft/capacitor-background-fetch' pod 'CapacitorShare', :path => '../../../../node_modules/.pnpm/@capacitor+share@5.0.7_@capacitor+core@5.7.3/node_modules/@capacitor/share'
pod 'CapacitorSecureStoragePlugin', :path => '../../../../node_modules/.pnpm/capacitor-secure-storage-plugin@0.9.0_@capacitor+core@5.5.0/node_modules/capacitor-secure-storage-plugin' pod 'CapacitorSplashScreen', :path => '../../../../node_modules/.pnpm/@capacitor+splash-screen@5.0.7_@capacitor+core@5.7.3/node_modules/@capacitor/splash-screen'
pod 'TransistorsoftCapacitorBackgroundFetch', :path => '../../../../node_modules/.pnpm/@transistorsoft+capacitor-background-fetch@5.2.0_@capacitor+core@5.7.3/node_modules/@transistorsoft/capacitor-background-fetch'
pod 'CapacitorSecureStoragePlugin', :path => '../../../../node_modules/.pnpm/capacitor-secure-storage-plugin@0.9.0_@capacitor+core@5.7.3/node_modules/capacitor-secure-storage-plugin'
pod 'CordovaPlugins', :path => '../capacitor-cordova-ios-plugins' pod 'CordovaPlugins', :path => '../capacitor-cordova-ios-plugins'
end end

View File

@@ -1,7 +1,7 @@
{ {
"name": "@openstapps/app", "name": "@openstapps/app",
"description": "The generic app tailored to fulfill needs of German universities, written using Ionic Framework.", "description": "The generic app tailored to fulfill needs of German universities, written using Ionic Framework.",
"version": "3.2.0", "version": "3.3.0",
"private": true, "private": true,
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"author": "Karl-Philipp Wulfert <krlwlfrt@gmail.com>", "author": "Karl-Philipp Wulfert <krlwlfrt@gmail.com>",
@@ -21,7 +21,7 @@
"build:prod": "ng build --configuration=production", "build:prod": "ng build --configuration=production",
"build:stats": "ng build --configuration=production --stats-json", "build:stats": "ng build --configuration=production --stats-json",
"changelog": "conventional-changelog -p angular -i src/assets/about/CHANGELOG.md -s -r 0", "changelog": "conventional-changelog -p angular -i src/assets/about/CHANGELOG.md -s -r 0",
"check-icons": "ts-node scripts/check-icon-correctness.ts", "check-icons": "node scripts/check-icon-correctness.mjs",
"chromium:no-cors": "chromium --disable-web-security --user-data-dir=\".browser-data/chromium\"", "chromium:no-cors": "chromium --disable-web-security --user-data-dir=\".browser-data/chromium\"",
"chromium:virtual-host": "chromium --host-resolver-rules=\"MAP mobile.app.uni-frankfurt.de:* localhost:8100\" --ignore-certificate-errors", "chromium:virtual-host": "chromium --host-resolver-rules=\"MAP mobile.app.uni-frankfurt.de:* localhost:8100\" --ignore-certificate-errors",
"cypress:open": "cypress open", "cypress:open": "cypress open",
@@ -35,10 +35,10 @@
"e2e": "ng e2e", "e2e": "ng e2e",
"format": "prettier . -c", "format": "prettier . -c",
"format:fix": "prettier --write .", "format:fix": "prettier --write .",
"licenses": "license-checker --json > src/assets/about/licenses.json && ts-node ./scripts/accumulate-licenses.ts && git add src/assets/about/licenses.json", "licenses": "license-checker --json > src/assets/about/licenses.json && node ./scripts/accumulate-licenses.mjs && git add src/assets/about/licenses.json",
"lint": "ng lint && stylelint \"**/*.scss\"", "lint": "ng lint && stylelint \"**/*.scss\"",
"lint:fix": "eslint --fix -c .eslintrc.json --ignore-path .eslintignore --ext .ts,.html src/ && stylelint --fix \"**/*.scss\"", "lint:fix": "eslint --fix -c .eslintrc.json --ignore-path .eslintignore --ext .ts,.html src/ && stylelint --fix \"**/*.scss\"",
"minify-icons": "ts-node-esm scripts/minify-icon-font.ts", "minify-icons": "node scripts/minify-icon-font.mjs",
"postinstall": "jetify && echo \"skipping jetify in production mode\"", "postinstall": "jetify && echo \"skipping jetify in production mode\"",
"preview": "http-server www --p 8101 -o", "preview": "http-server www --p 8101 -o",
"push": "git push && git push origin \"v$npm_package_version\"", "push": "git push && git push origin \"v$npm_package_version\"",
@@ -59,10 +59,9 @@
"@angular/forms": "17.3.0", "@angular/forms": "17.3.0",
"@angular/platform-browser": "17.3.0", "@angular/platform-browser": "17.3.0",
"@angular/router": "17.3.0", "@angular/router": "17.3.0",
"@asymmetrik/ngx-leaflet": "17.0.0",
"@asymmetrik/ngx-leaflet-markercluster": "17.0.0",
"@awesome-cordova-plugins/calendar": "6.6.0", "@awesome-cordova-plugins/calendar": "6.6.0",
"@awesome-cordova-plugins/core": "6.6.0", "@awesome-cordova-plugins/core": "6.6.0",
"@capacitor-community/screen-brightness": "6.0.0",
"@capacitor/app": "5.0.7", "@capacitor/app": "5.0.7",
"@capacitor/browser": "5.2.0", "@capacitor/browser": "5.2.0",
"@capacitor/clipboard": "5.0.7", "@capacitor/clipboard": "5.0.7",
@@ -76,11 +75,13 @@
"@capacitor/local-notifications": "5.0.7", "@capacitor/local-notifications": "5.0.7",
"@capacitor/network": "5.0.7", "@capacitor/network": "5.0.7",
"@capacitor/preferences": "5.0.7", "@capacitor/preferences": "5.0.7",
"@capacitor/screen-orientation": "6.0.0",
"@capacitor/share": "5.0.7", "@capacitor/share": "5.0.7",
"@capacitor/splash-screen": "5.0.7", "@capacitor/splash-screen": "5.0.7",
"@ionic-native/core": "5.36.0", "@ionic-native/core": "5.36.0",
"@ionic/angular": "7.8.0", "@ionic/angular": "7.8.0",
"@ionic/storage-angular": "4.0.0", "@ionic/storage-angular": "4.0.0",
"@maplibre/ngx-maplibre-gl": "17.4.1",
"@ngx-translate/core": "15.0.0", "@ngx-translate/core": "15.0.0",
"@ngx-translate/http-loader": "8.0.0", "@ngx-translate/http-loader": "8.0.0",
"@openid/appauth": "1.3.1", "@openid/appauth": "1.3.1",
@@ -97,15 +98,15 @@
"geojson": "0.5.0", "geojson": "0.5.0",
"ionic-appauth": "0.9.0", "ionic-appauth": "0.9.0",
"jsonpath-plus": "6.0.1", "jsonpath-plus": "6.0.1",
"leaflet": "1.9.4", "maplibre-gl": "4.0.2",
"leaflet.markercluster": "1.5.3", "material-symbols": "0.17.1",
"material-symbols": "0.17.0",
"moment": "2.30.1", "moment": "2.30.1",
"ngx-date-fns": "11.0.0", "ngx-date-fns": "11.0.0",
"ngx-logger": "5.0.12", "ngx-logger": "5.0.12",
"ngx-markdown": "17.1.1", "ngx-markdown": "17.1.1",
"ngx-moment": "6.0.2", "ngx-moment": "6.0.2",
"opening_hours": "3.8.0", "opening_hours": "3.8.0",
"pmtiles": "3.0.3",
"rxjs": "7.8.1", "rxjs": "7.8.1",
"semver": "7.6.0", "semver": "7.6.0",
"swiper": "8.4.5", "swiper": "8.4.5",
@@ -147,8 +148,6 @@
"@types/karma": "6.3.8", "@types/karma": "6.3.8",
"@types/karma-coverage": "2.0.3", "@types/karma-coverage": "2.0.3",
"@types/karma-jasmine": "4.0.5", "@types/karma-jasmine": "4.0.5",
"@types/leaflet": "1.9.8",
"@types/leaflet.markercluster": "1.5.4",
"@types/node": "18.15.3", "@types/node": "18.15.3",
"@types/semver": "7.5.8", "@types/semver": "7.5.8",
"@typescript-eslint/eslint-plugin": "7.2.0", "@typescript-eslint/eslint-plugin": "7.2.0",
@@ -175,7 +174,7 @@
"karma-junit-reporter": "2.0.1", "karma-junit-reporter": "2.0.1",
"karma-mocha-reporter": "2.2.5", "karma-mocha-reporter": "2.2.5",
"license-checker": "25.0.1", "license-checker": "25.0.1",
"stylelint": "16.2.1", "stylelint": "16.3.1",
"stylelint-config-clean-order": "5.4.1", "stylelint-config-clean-order": "5.4.1",
"stylelint-config-prettier-scss": "1.0.0", "stylelint-config-prettier-scss": "1.0.0",
"stylelint-config-recommended-scss": "14.0.0", "stylelint-config-recommended-scss": "14.0.0",

View File

@@ -12,33 +12,34 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import fs from 'fs'; import {readFileSync, writeFileSync} from 'fs';
import {omit} from '../src/app/_helpers/collections/omit'; import {omit, pickBy} from '@openstapps/collection-utils';
import {pickBy} from '../src/app/_helpers/collections/pick';
/** /**
* accumulate and transform licenses based on two license files * accumulate and transform licenses based on two license files
* @param {string} path
* @param {string} additionalLicensesPath
*/ */
function accumulateFile(path: string, additionalLicensesPath: string) { function accumulateFile(path, additionalLicensesPath) {
const packageJson = JSON.parse(fs.readFileSync('./package.json').toString()); const packageJson = JSON.parse(readFileSync('./package.json').toString());
const dependencies = packageJson.dependencies; const dependencies = packageJson.dependencies;
console.log(`Accumulating licenses from ${path}`); console.log(`Accumulating licenses from ${path}`);
fs.writeFileSync( writeFileSync(
path, path,
JSON.stringify( JSON.stringify(
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
Object.entries<any>({ Object.entries({
...pickBy(JSON.parse(fs.readFileSync(path).toString()), (_, key: string) => { ...pickBy(JSON.parse(readFileSync(path).toString()), (_, key) => {
const parts = key.split('@'); const parts = /** @type {string} */ (key).split('@');
return dependencies[parts.slice(0, -1).join('@')] === parts[parts.length - 1]; return dependencies[parts.slice(0, -1).join('@')] === parts[parts.length - 1];
}), }),
...JSON.parse(fs.readFileSync(additionalLicensesPath).toString()), ...JSON.parse(readFileSync(additionalLicensesPath).toString()),
}) })
.map(([key, value]) => ({ .map(([key, value]) => ({
licenseText: value.licenseFile && fs.readFileSync(value.licenseFile, 'utf8'), licenseText: value.licenseFile && readFileSync(value.licenseFile, 'utf8'),
name: key, name: key,
...omit(value, 'licenseFile', 'path'), ...omit(value, 'licenseFile', 'path'),
})) }))

View File

@@ -12,18 +12,20 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import fontkit, {Font} from 'fontkit'; import {openSync} from 'fontkit';
import config from '../icons.config'; import config from '../icons.config.mjs';
import {existsSync} from 'fs'; import {existsSync} from 'fs';
import {getUsedIconsHtml, getUsedIconsTS} from './gather-used-icons'; import {getUsedIconsHtml, getUsedIconsTS} from './gather-used-icons.mjs';
import {fetchCodePointMap} from './get-code-points.mjs';
const commandName = '"npm run minify-icons"'; const commandName = '"npm run minify-icons"';
const originalFont = fontkit.openSync(config.inputPath);
if (!existsSync(config.outputPath)) { if (!existsSync(config.outputPath)) {
console.error(`Minified font not found. Run ${commandName} first.`); console.error(`Minified font not found. Run ${commandName} first.`);
process.exit(-1); process.exit(-1);
} }
const modifiedFont = fontkit.openSync(config.outputPath);
/** @type {import('fontkit').Font} */
const modifiedFont = openSync(config.outputPath);
let success = true; let success = true;
@@ -48,25 +50,16 @@ async function checkAll() {
} }
/** /**
* * @param {Record<string, string[]>} icons
*/ */
function check(icons: Record<string, string[]>) { async function check(icons) {
for (const [purpose, iconSet] of Object.entries(icons)) { const codePoints = await fetchCodePointMap();
for (const icon of iconSet) {
if (!hasIcon(originalFont, icon)) { for (const icon of Object.values(icons).flat()) {
success = false; const codePoint = codePoints.get(icon);
console.error(`${purpose}: ${icon} does not exist. Typo?`); if (!codePoint) throw new Error(`"${icon}" is not a valid icon`);
} else if (!hasIcon(modifiedFont, icon)) { if (!modifiedFont.getGlyph(Number.parseInt(codePoint, 16))) {
success = false; throw new Error(`"${icon}" (code point ${codePoint}) is missing`);
console.error(`${purpose}: ${icon} not found in minified font. Run ${commandName} to regenerate it.`);
}
} }
} }
} }
/**
*
*/
function hasIcon(font: Font, icon: string) {
return font.layout(icon).glyphs.some(it => it.isLigature);
}

View File

@@ -14,34 +14,39 @@
*/ */
import {glob} from 'glob'; import {glob} from 'glob';
import {readFileSync} from 'fs'; import {readFileSync} from 'fs';
import {matchPropertyContent, matchTagProperties} from '../src/app/util/ion-icon/icon-match'; import {
matchPropertyAccess,
matchPropertyContent,
matchTagProperties,
} from '../src/app/util/ion-icon/icon-match.mjs';
/** /**
* * @returns {Promise<Record<string, string[]>>}
*/ */
export async function getUsedIconsHtml(pattern = 'src/**/*.html'): Promise<Record<string, string[]>> { export async function getUsedIconsHtml(pattern = 'src/**/*.html') {
return Object.fromEntries( return Object.fromEntries(
(await glob(pattern)) (await glob(pattern))
.map(file => [ .map(file => [
file, file,
(readFileSync(file, 'utf8') readFileSync(file, 'utf8')
.match(matchTagProperties('ion-icon')) .match(matchTagProperties('ion-icon'))
?.flatMap(match => { ?.flatMap(match => {
return match.match(matchPropertyContent(['name', 'md', 'ios'])); return match.match(matchPropertyContent(['name', 'md', 'ios']));
}) })
.filter(it => !!it) as string[]) || [], .filter(it => !!it) || [],
]) ])
.filter(([, values]) => values.length > 0), .filter(([, values]) => values && values.length > 0),
); );
} }
/** /**
* * @returns {Promise<Record<string, string[]>>}
*/ */
export async function getUsedIconsTS(pattern = 'src/**/*.ts'): Promise<Record<string, string[]>> { export async function getUsedIconsTS(pattern = 'src/**/*.ts') {
const regex = matchPropertyAccess('SCIcon');
return Object.fromEntries( return Object.fromEntries(
(await glob(pattern)) (await glob(pattern))
.map(file => [file, readFileSync(file, 'utf8').match(/(?<=Icon`)[\w-]+(?=`)/g) || []]) .map(file => [file, readFileSync(file, 'utf8').match(regex) || []])
.filter(([, values]) => values.length > 0), .filter(([, values]) => values && values.length > 0),
); );
} }

View File

@@ -0,0 +1,23 @@
const url =
'https://raw.githubusercontent.com/google/material-design-icons/master/' +
'variablefont/MaterialSymbolsRounded%5BFILL%2CGRAD%2Copsz%2Cwght%5D.codepoints';
export async function fetchCodePointMap() {
const icons = await fetch(url)
.then(it => it.text())
.then(it => new Map(it.split('\n').map(it => /** @type {[string, string]} */ (it.split(' ')))));
if (icons.size < 100) throw new Error(`Code point map is very small, is the URL incorrect? ${url}`);
return icons;
}
/**
* @param {string[]} icons
*/
export async function getCodePoints(icons) {
const codePoints = await fetchCodePointMap();
return icons.map(icon => {
const code = codePoints.get(icon);
if (!code) throw new Error(`Code point for icon ${icon} not found`);
return code;
});
}

View File

@@ -0,0 +1,8 @@
import {networkInterfaces} from 'os';
console.log(
Object.entries(networkInterfaces())
.map(([, info]) => info)
.flat()
.find(info => info && !info.internal && info.family === 'IPv4')?.address,
);

View File

@@ -19,5 +19,4 @@ export interface IconConfig {
inputPath: string; inputPath: string;
outputPath: string; outputPath: string;
additionalIcons?: {[purpose: string]: string[]}; additionalIcons?: {[purpose: string]: string[]};
codePoints?: {[name: string]: string};
} }

View File

@@ -14,16 +14,17 @@
*/ */
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import fontkit from 'fontkit';
import {exec} from 'child_process'; import {exec} from 'child_process';
import config from '../icons.config'; import config from '../icons.config.mjs';
import {statSync} from 'fs'; import {statSync} from 'fs';
import {getUsedIconsHtml, getUsedIconsTS} from './gather-used-icons'; import {getUsedIconsHtml, getUsedIconsTS} from './gather-used-icons.mjs';
import {getCodePoints} from './get-code-points.mjs';
/** /**
* * @param {string[] | string} command
* @returns {Promise<string>}
*/ */
async function run(command: string[] | string): Promise<string> { async function run(command) {
const fullCommand = Array.isArray(command) ? command.join(' ') : command; const fullCommand = Array.isArray(command) ? command.join(' ') : command;
console.log(`>> ${fullCommand}`); console.log(`>> ${fullCommand}`);
@@ -44,7 +45,8 @@ async function run(command: string[] | string): Promise<string> {
* *
*/ */
async function minifyIconFont() { async function minifyIconFont() {
const icons = new Set<string>(); /** @type {Set<string>} */
const icons = new Set();
for (const iconSet of [ for (const iconSet of [
...Object.values(config.additionalIcons || []), ...Object.values(config.additionalIcons || []),
@@ -56,35 +58,7 @@ async function minifyIconFont() {
} }
} }
console.log('Icons used:', [...icons.values()].sort()); const glyphs = ['5f-7a', '30-39', ...(await getCodePoints([...icons]))].sort();
const font = fontkit.openSync(config.inputPath);
const glyphs: string[] = ['5f-7a', '30-39'];
for (const icon of icons) {
const iconGlyphs = font.layout(icon).glyphs;
if (iconGlyphs.length === 0) {
console.error(`${icon} not found in font. Typo?`);
process.exit(-1);
}
const codePoints = iconGlyphs
.flatMap(it => font.stringsForGlyph(it.id))
.flatMap(it => [...it])
.map(it => it.codePointAt(0)!.toString(16));
if (codePoints.length === 0) {
if (config.codePoints?.[icon]) {
glyphs.push(config.codePoints[icon]);
} else {
console.log();
console.error(`${icon} code point could not be determined. Add it to config.codePoints.`);
process.exit(-1);
}
}
glyphs.push(...codePoints);
}
glyphs.sort();
console.log( console.log(
await run([ await run([
@@ -114,8 +88,10 @@ minifyIconFont();
/** /**
* Bytes to respective units * Bytes to respective units
* @param {number} value
* @returns {string}
*/ */
function toByteUnit(value: number): string { function toByteUnit(value) {
if (value < 1024) { if (value < 1024) {
return `${value}B`; return `${value}B`;
} else if (value < 1024 * 1024) { } else if (value < 1024 * 1024) {

View File

@@ -30,7 +30,6 @@ import {environment} from '../environments/environment';
import {AppRoutingModule} from './app-routing.module'; import {AppRoutingModule} from './app-routing.module';
import {AppComponent} from './app.component'; import {AppComponent} from './app.component';
import {CatalogModule} from './modules/catalog/catalog.module'; import {CatalogModule} from './modules/catalog/catalog.module';
import {ConfigModule} from './modules/config/config.module';
import {ConfigProvider} from './modules/config/config.provider'; import {ConfigProvider} from './modules/config/config.provider';
import {DashboardModule} from './modules/dashboard/dashboard.module'; import {DashboardModule} from './modules/dashboard/dashboard.module';
import {DataModule} from './modules/data/data.module'; import {DataModule} from './modules/data/data.module';
@@ -70,6 +69,8 @@ import {setDefaultOptions} from 'date-fns';
import {DateFnsConfigurationService} from 'ngx-date-fns'; import {DateFnsConfigurationService} from 'ngx-date-fns';
import {Capacitor} from '@capacitor/core'; import {Capacitor} from '@capacitor/core';
import {SplashScreen} from '@capacitor/splash-screen'; import {SplashScreen} from '@capacitor/splash-screen';
import maplibregl from 'maplibre-gl';
import {Protocol} from 'pmtiles';
registerLocaleData(localeDe); registerLocaleData(localeDe);
@@ -91,6 +92,7 @@ export function initializerFactory(
) { ) {
return async () => { return async () => {
try { try {
maplibregl.addProtocol('pmtiles', new Protocol().tile);
initLogger(logger); initLogger(logger);
await storageProvider.init(); await storageProvider.init();
await configProvider.init(); await configProvider.init();
@@ -151,7 +153,6 @@ export function createTranslateLoader(http: HttpClient) {
BrowserAnimationsModule, BrowserAnimationsModule,
CatalogModule, CatalogModule,
CommonModule, CommonModule,
ConfigModule,
DashboardModule, DashboardModule,
DataModule, DataModule,
HebisModule, HebisModule,

View File

@@ -12,24 +12,6 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
ion-content > div {
height: 100%;
}
cdk-virtual-scroll-viewport {
width: 100%;
height: 100%;
}
:host ::ng-deep {
.cdk-virtual-scroll-content-wrapper {
width: 100%;
}
}
.virtual-scroll-expander {
clear: both;
}
.supertext-icon { .supertext-icon {
height: 14px; height: 14px;

View File

@@ -49,7 +49,7 @@
@if (content.type === 'router link') { @if (content.type === 'router link') {
<ion-item [routerLink]="content.link"> <ion-item [routerLink]="content.link">
@if (content.icon) { @if (content.icon) {
<ion-icon [name]="content.icon" slot="start"></ion-icon> <ion-icon [name]="$any(content.icon)" slot="start"></ion-icon>
} }
<ion-label>{{ 'title' | translateSimple: content }}</ion-label> <ion-label>{{ 'title' | translateSimple: content }}</ion-label>
</ion-item> </ion-item>

View File

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

View File

@@ -15,7 +15,6 @@
ion-segment-button { ion-segment-button {
max-width: 100%; max-width: 100%;
text-transform: none;
} }
.margin-top { .margin-top {

View File

@@ -1,27 +0,0 @@
/*
* Copyright (C) 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 {NgModule} from '@angular/core';
import {DataModule} from '../data/data.module';
import {StorageModule} from '../storage/storage.module';
import {ConfigProvider} from './config.provider';
/**
* TODO
*/
@NgModule({
imports: [StorageModule, DataModule],
providers: [ConfigProvider],
})
export class ConfigModule {}

View File

@@ -24,13 +24,17 @@ import {
} from './errors'; } from './errors';
import {NGXLogger} from 'ngx-logger'; import {NGXLogger} from 'ngx-logger';
import {sampleIndexResponse} from '../../_helpers/data/sample-configuration'; import {sampleIndexResponse} from '../../_helpers/data/sample-configuration';
import {BehaviorSubject} from 'rxjs';
import {InternetConnectionService} from '../../util/internet-connection.service';
describe('ConfigProvider', () => { describe('ConfigProvider', () => {
let internetConnectionServiceMock: {offline$: BehaviorSubject<boolean>};
let configProvider: ConfigProvider; let configProvider: ConfigProvider;
let storageProviderSpy: jasmine.SpyObj<StorageProvider>; let storageProviderSpy: jasmine.SpyObj<StorageProvider>;
let ngxLogger: jasmine.SpyObj<NGXLogger>; let ngxLogger: jasmine.SpyObj<NGXLogger>;
beforeEach(() => { beforeEach(() => {
internetConnectionServiceMock = {offline$: new BehaviorSubject<boolean>(false)};
storageProviderSpy = jasmine.createSpyObj('StorageProvider', ['init', 'get', 'has', 'put']); storageProviderSpy = jasmine.createSpyObj('StorageProvider', ['init', 'get', 'has', 'put']);
const webHttpClientMethodSpy = jasmine.createSpyObj('StAppsWebHttpClient', ['request']); const webHttpClientMethodSpy = jasmine.createSpyObj('StAppsWebHttpClient', ['request']);
ngxLogger = jasmine.createSpyObj('NGXLogger', ['log', 'error', 'warn']); ngxLogger = jasmine.createSpyObj('NGXLogger', ['log', 'error', 'warn']);
@@ -51,6 +55,10 @@ describe('ConfigProvider', () => {
provide: NGXLogger, provide: NGXLogger,
useValue: ngxLogger, useValue: ngxLogger,
}, },
{
provide: InternetConnectionService,
useValue: internetConnectionServiceMock,
},
], ],
}); });
@@ -75,6 +83,22 @@ describe('ConfigProvider', () => {
expect(error).toEqual(new ConfigFetchError()); expect(error).toEqual(new ConfigFetchError());
}); });
it('should throw device offline error when offline', async () => {
// eslint-disable-next-line unicorn/error-message
let error = new Error('');
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
internetConnectionServiceMock.offline$ = new BehaviorSubject<boolean>(true);
try {
await configProvider.fetch();
} catch (error_) {
error = error_ as Error;
expect(error).toBeInstanceOf(ConfigFetchError);
expect(error.message).toContain('Device is offline.');
}
});
it('should init from remote and saved config not available', async () => { it('should init from remote and saved config not available', async () => {
storageProviderSpy.has.and.returnValue(Promise.resolve(false)); storageProviderSpy.has.and.returnValue(Promise.resolve(false));
spyOn(configProvider.client, 'handshake').and.returnValue(Promise.resolve(sampleIndexResponse)); spyOn(configProvider.client, 'handshake').and.returnValue(Promise.resolve(sampleIndexResponse));
@@ -104,9 +128,9 @@ describe('ConfigProvider', () => {
it('should throw error on wrong config version in storage', async () => { it('should throw error on wrong config version in storage', async () => {
storageProviderSpy.has.and.returnValue(Promise.resolve(true)); storageProviderSpy.has.and.returnValue(Promise.resolve(true));
const wrongConfig = JSON.parse(JSON.stringify(sampleIndexResponse)); const wrongConfig = structuredClone(sampleIndexResponse);
wrongConfig.backend.SCVersion = '0.1.0'; wrongConfig.backend.SCVersion = '0.1.0';
storageProviderSpy.get.and.returnValue(wrongConfig); storageProviderSpy.get.and.returnValue(Promise.resolve(wrongConfig));
spyOn(configProvider.client, 'handshake').and.returnValue(Promise.resolve(sampleIndexResponse)); spyOn(configProvider.client, 'handshake').and.returnValue(Promise.resolve(sampleIndexResponse));
await configProvider.init(); await configProvider.init();

View File

@@ -27,6 +27,8 @@ import {
SavedConfigNotAvailable, SavedConfigNotAvailable,
WrongConfigVersionInStorage, WrongConfigVersionInStorage,
} from './errors'; } from './errors';
import {InternetConnectionService} from '../../util/internet-connection.service';
import {firstValueFrom} from 'rxjs';
/** /**
* Key to store config in storage module * Key to store config in storage module
@@ -72,7 +74,9 @@ export class ConfigProvider {
private readonly storageProvider: StorageProvider, private readonly storageProvider: StorageProvider,
swHttpClient: StAppsWebHttpClient, swHttpClient: StAppsWebHttpClient,
private readonly logger: NGXLogger, private readonly logger: NGXLogger,
private readonly internetConnectionService: InternetConnectionService,
) { ) {
console.log('config init');
this.client = new Client(swHttpClient, environment.backend_url, environment.backend_version); this.client = new Client(swHttpClient, environment.backend_url, environment.backend_version);
} }
@@ -81,9 +85,15 @@ export class ConfigProvider {
*/ */
async fetch(): Promise<SCIndexResponse> { async fetch(): Promise<SCIndexResponse> {
try { try {
return await this.client.handshake(this.scVersion); const isOffline = await firstValueFrom(this.internetConnectionService.offline$);
} catch { if (isOffline) {
throw new ConfigFetchError(); throw new Error('Device is offline.');
} else {
return await this.client.handshake(this.scVersion);
}
} catch (error) {
const error_ = error instanceof Error ? new ConfigFetchError(error.message) : new ConfigFetchError();
throw error_;
} }
} }

View File

@@ -19,8 +19,10 @@ import {AppError} from '../../_helpers/errors';
* Error that is thrown when fetching from backend fails * Error that is thrown when fetching from backend fails
*/ */
export class ConfigFetchError extends AppError { export class ConfigFetchError extends AppError {
constructor() { constructor(reason?: string) {
super('ConfigFetchError', 'App configuration could not be fetched!'); const defaultMessage = 'App configuration could not be fetched!';
const message = reason ? `${defaultMessage} ${reason}` : defaultMessage;
super('ConfigFetchError', message);
} }
} }

View File

@@ -26,6 +26,7 @@ import {AddEventStates, AddEventStatesMap} from './add-event-action-chip.config'
import {EditEventSelectionComponent} from '../edit-event-selection.component'; import {EditEventSelectionComponent} from '../edit-event-selection.component';
import {AddEventReviewModalComponent} from '../../../calendar/add-event-review-modal.component'; import {AddEventReviewModalComponent} from '../../../calendar/add-event-review-modal.component';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {MaterialSymbol} from 'material-symbols';
/** /**
* Shows a horizontal list of action chips * Shows a horizontal list of action chips
@@ -55,7 +56,7 @@ export class AddEventActionChipComponent {
/** /**
* Icon * Icon
*/ */
icon: string; icon: MaterialSymbol;
/** /**
* Current state of icon fill * Current state of icon fill

View File

@@ -24,28 +24,28 @@ export enum AddEventStates {
export const AddEventStatesMap = { export const AddEventStatesMap = {
[AddEventStates.ADDED_ALL]: { [AddEventStates.ADDED_ALL]: {
icon: SCIcon`event_available`, icon: SCIcon.event_available,
fill: true, fill: true,
label: 'data.chips.add_events.ADDED_ALL', label: 'data.chips.add_events.ADDED_ALL',
disabled: false, disabled: false,
color: 'success', color: 'success',
}, },
[AddEventStates.ADDED_SOME]: { [AddEventStates.ADDED_SOME]: {
icon: SCIcon`event`, icon: SCIcon.event,
fill: true, fill: true,
label: 'data.chips.add_events.ADDED_SOME', label: 'data.chips.add_events.ADDED_SOME',
disabled: false, disabled: false,
color: 'success', color: 'success',
}, },
[AddEventStates.REMOVED_ALL]: { [AddEventStates.REMOVED_ALL]: {
icon: SCIcon`calendar_today`, icon: SCIcon.calendar_today,
fill: false, fill: false,
label: 'data.chips.add_events.REMOVED_ALL', label: 'data.chips.add_events.REMOVED_ALL',
disabled: false, disabled: false,
color: 'primary', color: 'primary',
}, },
[AddEventStates.UNAVAILABLE]: { [AddEventStates.UNAVAILABLE]: {
icon: SCIcon`event_busy`, icon: SCIcon.event_busy,
fill: false, fill: false,
label: 'data.chips.add_events.UNAVAILABLE', label: 'data.chips.add_events.UNAVAILABLE',
disabled: true, disabled: true,

View File

@@ -14,37 +14,38 @@
*/ */
import {SCThingType} from '@openstapps/core'; import {SCThingType} from '@openstapps/core';
import {SCIcon} from '../../util/ion-icon/icon'; import {SCIcon} from '../../util/ion-icon/icon';
import {MaterialSymbol} from 'material-symbols';
export const DataIcons: Record<SCThingType, string> = { export const DataIcons = {
'academic event': SCIcon`school`, 'academic event': SCIcon.school,
'assessment': SCIcon`fact_check`, 'assessment': SCIcon.fact_check,
'article': SCIcon`article`, 'article': SCIcon.article,
'book': SCIcon`book`, 'book': SCIcon.book,
'building': SCIcon`location_city`, 'building': SCIcon.location_city,
'certification': SCIcon`contract`, 'certification': SCIcon.contract,
'catalog': SCIcon`inventory_2`, 'catalog': SCIcon.inventory_2,
'contact point': SCIcon`contact_page`, 'contact point': SCIcon.contact_page,
'course of study': SCIcon`school`, 'course of study': SCIcon.school,
'date series': SCIcon`event`, 'date series': SCIcon.event,
'dish': SCIcon`lunch_dining`, 'dish': SCIcon.lunch_dining,
'favorite': SCIcon`favorite`, 'favorite': SCIcon.favorite,
'floor': SCIcon`foundation`, 'floor': SCIcon.foundation,
'id card': SCIcon`badge`, 'id card': SCIcon.badge,
'message': SCIcon`newspaper`, 'message': SCIcon.newspaper,
'organization': SCIcon`business_center`, 'organization': SCIcon.business_center,
'periodical': SCIcon`feed`, 'periodical': SCIcon.feed,
'person': SCIcon`person`, 'person': SCIcon.person,
'point of interest': SCIcon`pin_drop`, 'point of interest': SCIcon.pin_drop,
'publication event': SCIcon`campaign`, 'publication event': SCIcon.campaign,
'room': SCIcon`meeting_room`, 'room': SCIcon.meeting_room,
'semester': SCIcon`date_range`, 'semester': SCIcon.date_range,
'setting': SCIcon`settings`, 'setting': SCIcon.settings,
'sport course': SCIcon`sports_soccer`, 'sport course': SCIcon.sports_soccer,
'study module': SCIcon`view_module`, 'study module': SCIcon.view_module,
'ticket': SCIcon`confirmation_number`, 'ticket': SCIcon.confirmation_number,
'todo': SCIcon`task`, 'todo': SCIcon.task,
'tour': SCIcon`tour`, 'tour': SCIcon.tour,
'video': SCIcon`movie`, 'video': SCIcon.movie,
'diff': SCIcon`difference`, 'diff': SCIcon.difference,
'job posting': SCIcon`work`, 'job posting': SCIcon.work,
}; } satisfies Record<SCThingType, MaterialSymbol>;

View File

@@ -31,7 +31,7 @@ export class DataIconPipe implements PipeTransform {
/** /**
* Provide the icon name from the data type * Provide the icon name from the data type
*/ */
transform(type: SCThingType): string { transform(type: SCThingType) {
return this.typeIconMap[type]; return this.typeIconMap[type];
} }
} }

View File

@@ -17,7 +17,6 @@ import {CommonModule} from '@angular/common';
import {HttpClientModule} from '@angular/common/http'; import {HttpClientModule} from '@angular/common/http';
import {NgModule} from '@angular/core'; import {NgModule} from '@angular/core';
import {FormsModule} from '@angular/forms'; import {FormsModule} from '@angular/forms';
import {LeafletModule} from '@asymmetrik/ngx-leaflet';
import {IonicModule, Platform} from '@ionic/angular'; import {IonicModule, Platform} from '@ionic/angular';
import {TranslateModule} from '@ngx-translate/core'; import {TranslateModule} from '@ngx-translate/core';
import {MarkdownModule} from 'ngx-markdown'; import {MarkdownModule} from 'ngx-markdown';
@@ -30,7 +29,7 @@ import {UtilModule} from '../../util/util.module';
import {CalendarService} from '../calendar/calendar.service'; import {CalendarService} from '../calendar/calendar.service';
import {ScheduleProvider} from '../calendar/schedule.provider'; import {ScheduleProvider} from '../calendar/schedule.provider';
import {GeoNavigationDirective} from '../map/geo-navigation.directive'; import {GeoNavigationDirective} from '../map/geo-navigation.directive';
import {MapWidgetComponent} from '../map/widget/map-widget.component'; import {MapWidgetComponent} from '../map/map-widget.component';
import {MenuModule} from '../menu/menu.module'; import {MenuModule} from '../menu/menu.module';
import {SettingsProvider} from '../settings/settings.provider'; import {SettingsProvider} from '../settings/settings.provider';
import {StorageModule} from '../storage/storage.module'; import {StorageModule} from '../storage/storage.module';
@@ -142,7 +141,6 @@ import {ShareButtonComponent} from './elements/share-button.component';
FoodDataListComponent, FoodDataListComponent,
LocateActionChipComponent, LocateActionChipComponent,
LongInlineTextComponent, LongInlineTextComponent,
MapWidgetComponent,
MessageDetailContentComponent, MessageDetailContentComponent,
MessageListItemComponent, MessageListItemComponent,
JobPostingDetailContentComponent, JobPostingDetailContentComponent,
@@ -187,9 +185,9 @@ import {ShareButtonComponent} from './elements/share-button.component';
CommonModule, CommonModule,
DataRoutingModule, DataRoutingModule,
FormsModule, FormsModule,
MapWidgetComponent,
HttpClientModule, HttpClientModule,
IonicModule.forRoot(), IonicModule.forRoot(),
LeafletModule,
MarkdownModule.forRoot(), MarkdownModule.forRoot(),
MenuModule, MenuModule,
IonIconModule, IonIconModule,

View File

@@ -14,6 +14,10 @@
*/ */
@import '../../../../theme/util/mixins'; @import '../../../../theme/util/mixins';
:host {
display: contents;
}
stapps-origin-detail { stapps-origin-detail {
// css hack to make the element stick to the bottom of the scroll container even // css hack to make the element stick to the bottom of the scroll container even
// when the content is not filling it // when the content is not filling it
@@ -49,13 +53,10 @@ stapps-origin-detail {
background: var(--ion-color-primary); background: var(--ion-color-primary);
} }
// Firefox doesn't support this yet... & > .expand-when-space,
@supports selector(:has(*)) { &:has(> .expand-when-space) {
& > .expand-when-space, flex: 1;
&:has(> .expand-when-space) { height: unset;
flex: 1;
height: unset;
}
} }
} }
} }

View File

@@ -17,7 +17,6 @@ ion-content > div {
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
min-height: 100%;
} }
ion-title { ion-title {

View File

@@ -13,10 +13,11 @@
~ this program. If not, see <https://www.gnu.org/licenses/>. ~ this program. If not, see <https://www.gnu.org/licenses/>.
--> -->
<ion-button (click)="toggle($event)" color="medium" size="small" fill="clear"> <ion-button (click)="toggle($event)" fill="clear" shape="round">
<ion-icon <ion-icon
slot="icon-only" slot="icon-only"
[fill]="(isFavorite$ | async) ?? false" [size]="24"
[fill]="(isFavorite$ | async) || false"
[class.selected]="isFavorite$ | async" [class.selected]="isFavorite$ | async"
name="grade" name="grade"
></ion-icon> ></ion-icon>

View File

@@ -13,22 +13,25 @@
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
:host { ion-button {
ion-button { --background-activated: currentcolor;
--border-radius: 50%; --background-hover: currentcolor;
--background-focused: currentcolor;
width: 50px; aspect-ratio: 1;
height: 50px; color: inherit;
} }
.selected { .selected {
// TODO color: #fbc02d;
color: #fbc02d; }
}
@media (hover: hover) { @media (hover: hover) {
ion-button:hover ::ng-deep stapps-icon { ion-button:hover ::ng-deep stapps-icon {
--fill: 1; --fill: 1 !important;
}
} }
} }
::ng-deep ion-item stapps-favorite-button ion-button {
color: var(--ion-color-medium) !important;
}

View File

@@ -12,7 +12,7 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {Component, Input} from '@angular/core'; import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
import {SCIndexableThings} from '@openstapps/core'; import {SCIndexableThings} from '@openstapps/core';
import {FavoritesService} from '../../favorites/favorites.service'; import {FavoritesService} from '../../favorites/favorites.service';
import {Observable} from 'rxjs'; import {Observable} from 'rxjs';
@@ -25,6 +25,7 @@ import {map, take} from 'rxjs/operators';
selector: 'stapps-favorite-button', selector: 'stapps-favorite-button',
templateUrl: './favorite-button.component.html', templateUrl: './favorite-button.component.html',
styleUrls: ['./favorite-button.component.scss'], styleUrls: ['./favorite-button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class FavoriteButtonComponent { export class FavoriteButtonComponent {
/** /**

View File

@@ -21,6 +21,7 @@ import {SCThingUserOrigin, SCThingRemoteOrigin} from '@openstapps/core';
@Component({ @Component({
selector: 'stapps-origin-detail', selector: 'stapps-origin-detail',
templateUrl: 'origin-detail.html', templateUrl: 'origin-detail.html',
styleUrl: 'origin-detail.scss',
}) })
export class OriginDetailComponent { export class OriginDetailComponent {
/** /**

View File

@@ -14,71 +14,61 @@
--> -->
@if (origin.type === 'user') { @if (origin.type === 'user') {
<ion-card> <h3>
<ion-card-header {{ 'data.types.origin.TITLE' | translate | sentencecase }}:
>{{ 'data.types.origin.TITLE' | translate | titlecase }}: {{ 'data.types.origin.USER' | translate | sentencecase }}
{{ 'data.types.origin.USER' | translate | titlecase }}</ion-card-header </h3>
> <p>
<ion-card-content> {{ 'data.types.origin.detail.CREATED' | translate | sentencecase }}:
<p> {{ origin.created | amDateFormat: 'll' }}
{{ 'data.types.origin.detail.CREATED' | translate | titlecase }}: </p>
{{ origin.created | amDateFormat: 'll' }} @if (origin.updated) {
</p> <p>
@if (origin.updated) { {{ 'data.types.origin.detail.UPDATED' | translate | sentencecase }}:
<p> {{ origin.updated | amDateFormat: 'll' }}
{{ 'data.types.origin.detail.UPDATED' | translate | titlecase }}: </p>
{{ origin.updated | amDateFormat: 'll' }} }
</p> @if (origin.modified) {
} <p>
@if (origin.modified) { {{ 'data.types.origin.detail.MODIFIED' | translate | sentencecase }}:
<p> {{ origin.modified | amDateFormat: 'll' }}
{{ 'data.types.origin.detail.MODIFIED' | translate | titlecase }}: </p>
{{ origin.modified | amDateFormat: 'll' }} }
</p> @if (origin.maintainer) {
} <p>
@if (origin.maintainer) { {{ 'data.types.origin.detail.MAINTAINER' | translate | sentencecase }}:
<p> <a [routerLink]="['/data-detail', origin.maintainer.uid]">{{ origin.maintainer.name }}</a>
{{ 'data.types.origin.detail.MAINTAINER' | translate }}: </p>
<a [routerLink]="['/data-detail', origin.maintainer.uid]">{{ origin.maintainer.name }}</a> }
</p>
}
</ion-card-content>
</ion-card>
} }
@if (origin.type === 'remote') { @if (origin.type === 'remote') {
<ion-card> <h3>
<ion-card-header {{ 'data.types.origin.TITLE' | translate | sentencecase }}:
>{{ 'data.types.origin.TITLE' | translate | titlecase }}: {{ 'data.types.origin.REMOTE' | translate | sentencecase }}
{{ 'data.types.origin.REMOTE' | translate | titlecase }}</ion-card-header </h3>
> <p>
<ion-card-content> {{ 'data.types.origin.detail.INDEXED' | translate | sentencecase }}:
<p> {{ origin.indexed | amDateFormat: 'll' }}
{{ 'data.types.origin.detail.INDEXED' | translate | titlecase }}: </p>
{{ origin.indexed | amDateFormat: 'll' }} @if (origin.modified) {
</p> <p>
@if (origin.modified) { {{ 'data.types.origin.detail.MODIFIED' | translate | sentencecase }}:
<p> {{ origin.modified | amDateFormat: 'll' }}
{{ 'data.types.origin.detail.MODIFIED' | translate | titlecase }}: </p>
{{ origin.modified | amDateFormat: 'll' }} }
</p> @if (origin.name) {
} <p>{{ 'data.types.origin.detail.MAINTAINER' | translate | sentencecase }}: {{ origin.name }}</p>
@if (origin.name) { }
<p>{{ 'data.types.origin.detail.MAINTAINER' | translate }}: {{ origin.name }}</p> @if (origin.maintainer) {
} <p>
@if (origin.maintainer) { {{ 'data.types.origin.detail.MAINTAINER' | translate | sentencecase }}:
<p> <a [routerLink]="['/data-detail', origin.maintainer.uid]">{{ origin.maintainer.name }}</a>
{{ 'data.types.origin.detail.MAINTAINER' | translate | titlecase }}: </p>
<a [routerLink]="['/data-detail', origin.maintainer.uid]">{{ origin.maintainer.name }}</a> }
</p> @if (origin.responsibleEntity) {
} <p>
@if (origin.responsibleEntity) { {{ 'data.types.origin.detail.RESPONSIBLE' | translate | sentencecase }}:
<p> <a [routerLink]="['/data-detail', origin.responsibleEntity.uid]">{{ origin.responsibleEntity.name }}</a>
{{ 'data.types.origin.detail.RESPONSIBLE' | translate | titlecase }}: </p>
<a [routerLink]="['/data-detail', origin.responsibleEntity.uid]">{{ }
origin.responsibleEntity.name
}}</a>
</p>
}
</ion-card-content>
</ion-card>
} }

View File

@@ -0,0 +1,15 @@
:host {
padding: var(--spacing-lg);
padding-top: 0;
}
h3 {
font-weight: bold;
}
h3,
p {
margin: 0;
font-size: 0.8em;
opacity: 0.8;
}

View File

@@ -18,8 +18,8 @@ import {SCThings} from '@openstapps/core';
import {SCIcon} from '../../../util/ion-icon/icon'; import {SCIcon} from '../../../util/ion-icon/icon';
const AccordionButtonState = { const AccordionButtonState = {
collapsed: SCIcon`expand_more`, collapsed: SCIcon.expand_more,
expanded: SCIcon`expand_less`, expanded: SCIcon.expand_less,
}; };
@Component({ @Component({
@@ -35,7 +35,8 @@ export class TitleCardComponent implements OnInit, OnChanges {
@ViewChild('accordionTextArea') accordionTextArea: ElementRef; @ViewChild('accordionTextArea') accordionTextArea: ElementRef;
buttonState = AccordionButtonState.collapsed; buttonState: (typeof AccordionButtonState)[keyof typeof AccordionButtonState] =
AccordionButtonState.collapsed;
buttonShown = true; buttonShown = true;

View File

@@ -38,7 +38,6 @@ ion-toolbar:first-of-type {
ion-button { ion-button {
width: 50%; width: 50%;
margin: 0; margin: 0;
text-transform: none;
} }
} }
} }

View File

@@ -68,5 +68,5 @@
</ion-card> </ion-card>
} }
@if (item.inPlace && item.inPlace.geo) { @if (item.inPlace && item.inPlace.geo) {
<stapps-map-widget [place]="item.inPlace"></stapps-map-widget> <stapps-map-widget [place]="$any(item.inPlace)"></stapps-map-widget>
} }

View File

@@ -17,7 +17,6 @@ import {PositionService} from '../../../map/position.service';
import {filter, Observable} from 'rxjs'; import {filter, Observable} from 'rxjs';
import {hasValidLocation, isSCFloor, PlaceTypes, PlaceTypesWithDistance} from './place-types'; import {hasValidLocation, isSCFloor, PlaceTypes, PlaceTypesWithDistance} from './place-types';
import {map} from 'rxjs/operators'; import {map} from 'rxjs/operators';
import {LatLng, geoJSON} from 'leaflet';
import {trigger, transition, style, animate} from '@angular/animations'; import {trigger, transition, style, animate} from '@angular/animations';
/** /**
@@ -39,13 +38,14 @@ export class PlaceListItemComponent {
@Input() set item(item: PlaceTypes) { @Input() set item(item: PlaceTypes) {
this._item = item; this._item = item;
if (!isSCFloor(item) && hasValidLocation(item)) { if (!isSCFloor(item) && hasValidLocation(item)) {
this.distance = this.positionService.watchCurrentLocation().pipe( this.distance = this.positionService.geoLocation.pipe(
map(position => map(
new LatLng(position.latitude, position.longitude).distanceTo( position =>
geoJSON(item.geo.point).getBounds().getCenter(), Math.hypot(
), position.coords.latitude - item.geo.point.coordinates[1],
position.coords.longitude - item.geo.point.coordinates[0],
) * 111_139,
), ),
filter(it => it !== undefined), filter(it => it !== undefined),
); );
} }

View File

@@ -12,7 +12,6 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import moment, {Moment} from 'moment';
import {AfterViewInit, Component, DestroyRef, inject, Input} from '@angular/core'; import {AfterViewInit, Component, DestroyRef, inject, Input} from '@angular/core';
import {SCDish, SCISO8601Date, SCPlace} from '@openstapps/core'; import {SCDish, SCISO8601Date, SCPlace} from '@openstapps/core';
import {PlaceMensaService} from './place-mensa-service'; import {PlaceMensaService} from './place-mensa-service';
@@ -53,11 +52,6 @@ export class PlaceMensaDetailComponent implements AfterViewInit {
*/ */
selectedDay: string; selectedDay: string;
/**
* First day to display menu items for
*/
startingDay: Moment;
destroy$ = inject(DestroyRef); destroy$ = inject(DestroyRef);
constructor( constructor(
@@ -65,9 +59,7 @@ export class PlaceMensaDetailComponent implements AfterViewInit {
protected router: Router, protected router: Router,
readonly routerOutlet: IonRouterOutlet, readonly routerOutlet: IonRouterOutlet,
private readonly dataRoutingService: DataRoutingService, private readonly dataRoutingService: DataRoutingService,
) { ) {}
this.startingDay = moment().startOf('day');
}
ngAfterViewInit() { ngAfterViewInit() {
if (!this.openAsModal) { if (!this.openAsModal) {

View File

@@ -13,10 +13,6 @@
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
ion-segment-button {
text-transform: none;
}
ion-segment { ion-segment {
overflow: auto; overflow: auto;
justify-content: space-between; justify-content: space-between;

View File

@@ -6,7 +6,6 @@ import {MomentModule} from 'ngx-moment';
import {DataModule} from '../data/data.module'; import {DataModule} from '../data/data.module';
import {UtilModule} from '../../util/util.module'; import {UtilModule} from '../../util/util.module';
import {IonIconModule} from '../../util/ion-icon/ion-icon.module'; import {IonIconModule} from '../../util/ion-icon/ion-icon.module';
import {ConfigProvider} from '../config/config.provider';
import {ThingTranslateModule} from '../../translation/thing-translate.module'; import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {RouterModule, Routes} from '@angular/router'; import {RouterModule, Routes} from '@angular/router';
import {JobsPageComponent} from './page/jobs-page.component'; import {JobsPageComponent} from './page/jobs-page.component';
@@ -26,6 +25,5 @@ const jobsRoutes: Routes = [{path: 'jobs', component: JobsPageComponent}];
DataModule, DataModule,
UtilModule, UtilModule,
], ],
providers: [ConfigProvider],
}) })
export class JobModule {} export class JobModule {}

View File

@@ -11,7 +11,7 @@ export class LibraryAccountPageComponent {
constructor(private readonly libraryAccountService: LibraryAccountService) {} constructor(private readonly libraryAccountService: LibraryAccountService) {}
async ionViewWillEnter(): Promise<void> { async ionViewWillEnter(): Promise<void> {
const patron = await this.libraryAccountService.getProfile(); const patron = await this.libraryAccountService.getPatron();
this.name = patron?.name; this.name = patron?.name;
} }
} }

View File

@@ -39,6 +39,8 @@ export class CheckedOutPageComponent {
async fetchItems() { async fetchItems() {
try { try {
// Prepare patron (status) for the items
await this.libraryAccountService.getPatron();
this.checkedOutItems = undefined; this.checkedOutItems = undefined;
this.checkedOutItems = await this.libraryAccountService.getFilteredItems([PAIADocumentStatus.Held]); this.checkedOutItems = await this.libraryAccountService.getFilteredItems([PAIADocumentStatus.Held]);
} catch { } catch {

View File

@@ -27,7 +27,7 @@
@for (checkedOutItem of checkedOutItems; track checkedOutItem) { @for (checkedOutItem of checkedOutItems; track checkedOutItem) {
<stapps-paia-item <stapps-paia-item
[item]="checkedOutItem" [item]="checkedOutItem"
[propertiesToShow]="['label', 'renewals', 'endtime']" [propertiesToShow]="['label', 'renewals', 'duedate']"
(documentAction)="onDocumentAction($event)" (documentAction)="onDocumentAction($event)"
listName="checked_out" listName="checked_out"
> >

View File

@@ -14,7 +14,8 @@
*/ */
import {Component, EventEmitter, Input, Output} from '@angular/core'; import {Component, EventEmitter, Input, Output} from '@angular/core';
import {DocumentAction, PAIADocument} from '../../../types'; import {DocumentAction, PAIADocument, PAIADocumentStatus} from '../../../types';
import {LibraryAccountService} from '../../library-account.service';
@Component({ @Component({
selector: 'stapps-paia-item', selector: 'stapps-paia-item',
@@ -22,7 +23,21 @@ import {DocumentAction, PAIADocument} from '../../../types';
styleUrls: ['./paiaitem.scss'], styleUrls: ['./paiaitem.scss'],
}) })
export class PAIAItemComponent { export class PAIAItemComponent {
@Input() item: PAIADocument; private _item: PAIADocument;
renewable: boolean;
constructor(private readonly libraryAccountService: LibraryAccountService) {}
@Input()
set item(value: PAIADocument) {
this._item = value;
void this.setRenewable();
}
get item(): PAIADocument {
return this._item;
}
@Input() @Input()
propertiesToShow: (keyof PAIADocument)[]; propertiesToShow: (keyof PAIADocument)[];
@@ -36,4 +51,9 @@ export class PAIAItemComponent {
async onClick(action: DocumentAction['action']) { async onClick(action: DocumentAction['action']) {
this.documentAction.emit({doc: this.item, action}); this.documentAction.emit({doc: this.item, action});
} }
private async setRenewable() {
const isActive = await this.libraryAccountService.isActivePatron();
this.renewable = isActive && Number(this.item.status) === PAIADocumentStatus.Held;
}
} }

View File

@@ -23,7 +23,7 @@
@if (item[property]) { @if (item[property]) {
<p> <p>
{{ 'library.account.pages' + '.' + listName + '.' + 'labels' + '.' + property | translate }}: {{ 'library.account.pages' + '.' + listName + '.' + 'labels' + '.' + property | translate }}:
@if (!['endtime', 'duedate'].includes(property)) { @if (!['starttime', 'duedate'].includes(property)) {
{{ item[property] }} {{ item[property] }}
} @else { } @else {
{{ $any(item[property]) | amDateFormat: 'll' }} {{ $any(item[property]) | amDateFormat: 'll' }}
@@ -40,7 +40,7 @@
<!-- >--> <!-- >-->
<!-- {{ 'library.account.actions.cancel.header' | translate }}</ion-button--> <!-- {{ 'library.account.actions.cancel.header' | translate }}</ion-button-->
<!-- >--> <!-- >-->
@if (item.canrenew) { @if (renewable && item.canrenew) {
<ion-button color="primary" (click)="onClick('renew')"> <ion-button color="primary" (click)="onClick('renew')">
{{ 'library.account.actions.renew.header' | translate }}</ion-button {{ 'library.account.actions.renew.header' | translate }}</ion-button
> >

View File

@@ -45,6 +45,8 @@ export class HoldsPageComponent {
? [PAIADocumentStatus.Reserved] ? [PAIADocumentStatus.Reserved]
: [PAIADocumentStatus.Ordered, PAIADocumentStatus.Provided]; : [PAIADocumentStatus.Ordered, PAIADocumentStatus.Provided];
try { try {
// Prepare patron (status) for the items
await this.libraryAccountService.getPatron();
this.paiaDocuments = await this.libraryAccountService.getFilteredItems(itemsStatus); this.paiaDocuments = await this.libraryAccountService.getFilteredItems(itemsStatus);
} catch { } catch {
await this.libraryAccountService.handleError(); await this.libraryAccountService.handleError();

View File

@@ -58,7 +58,7 @@
@for (hold of paiaDocuments; track hold) { @for (hold of paiaDocuments; track hold) {
<stapps-paia-item <stapps-paia-item
[item]="hold" [item]="hold"
[propertiesToShow]="['label']" [propertiesToShow]="['label', 'starttime', 'storage', 'queue']"
(documentAction)="onDocumentAction($event)" (documentAction)="onDocumentAction($event)"
listName="holds" listName="holds"
> >

View File

@@ -20,7 +20,15 @@ import {
SCFeatureConfiguration, SCFeatureConfiguration,
SCFeatureConfigurationExtern, SCFeatureConfigurationExtern,
} from '@openstapps/core'; } from '@openstapps/core';
import {DocumentAction, PAIADocument, PAIADocumentStatus, PAIAFees, PAIAItems, PAIAPatron} from '../types'; import {
DocumentAction,
PAIADocument,
PAIADocumentStatus,
PAIAFees,
PAIAItems,
PAIAPatron,
PAIAPatronStatus,
} from '../types';
import {HebisDataProvider} from '../../hebis/hebis-data.provider'; import {HebisDataProvider} from '../../hebis/hebis-data.provider';
import {PAIATokenResponse} from '../../auth/paia/paia-token-response'; import {PAIATokenResponse} from '../../auth/paia/paia-token-response';
import {AuthHelperService} from '../../auth/auth-helper.service'; import {AuthHelperService} from '../../auth/auth-helper.service';
@@ -43,6 +51,11 @@ export class LibraryAccountService {
*/ */
authType: SCAuthorizationProviderType; authType: SCAuthorizationProviderType;
/**
* Account (Patron) status
*/
private status?: PAIAPatronStatus;
constructor( constructor(
protected requestor: Requestor = new JQueryRequestor(), protected requestor: Requestor = new JQueryRequestor(),
private readonly hebisDataProvider: HebisDataProvider, private readonly hebisDataProvider: HebisDataProvider,
@@ -60,12 +73,23 @@ export class LibraryAccountService {
this.authType = config.authProvider as SCAuthorizationProviderType; this.authType = config.authProvider as SCAuthorizationProviderType;
} }
async getProfile() { async getPatron() {
const patron = ((await this.getValidToken()) as PAIATokenResponse).patron; const patronId = ((await this.getValidToken()) as PAIATokenResponse).patron;
return { const patron = {
...(await this.performRequest<PAIAPatron>(`${this.baseUrl}/{patron}`)), ...(await this.performRequest<PAIAPatron>(`${this.baseUrl}/{patron}`)),
id: patron, id: patronId,
} as PAIAPatron; } as PAIAPatron;
// Refresh the status
this.status = Number(patron.status);
return patron;
}
async getPatronStatus() {
return this.status ?? (this.status = Number((await this.getPatron()).status) as PAIAPatronStatus);
}
async isActivePatron() {
return (await this.getPatronStatus()) === PAIAPatronStatus.Active;
} }
async getItems() { async getItems() {

View File

@@ -25,15 +25,19 @@ import {PAIAPatron} from '../../types';
export class ProfilePageComponent { export class ProfilePageComponent {
patron?: PAIAPatron; patron?: PAIAPatron;
propertiesToShow: (keyof PAIAPatron)[] = ['id', 'name', 'email', 'address', 'expires', 'note']; propertiesToShow: (keyof PAIAPatron)[] = ['id', 'name', 'email', 'address', 'expires'];
constructor(private readonly libraryAccountService: LibraryAccountService) {} constructor(private readonly libraryAccountService: LibraryAccountService) {}
async ionViewWillEnter(): Promise<void> { async ionViewWillEnter(): Promise<void> {
try { try {
this.patron = await this.libraryAccountService.getProfile(); this.patron = await this.libraryAccountService.getPatron();
} catch { } catch {
await this.libraryAccountService.handleError(); await this.libraryAccountService.handleError();
} }
} }
isUnlimitedExpiry(date = ''): boolean {
return new Date(date).getFullYear() === 9999;
}
} }

View File

@@ -36,11 +36,11 @@
@if (!['expires'].includes(property)) { @if (!['expires'].includes(property)) {
{{ patron[property] }} {{ patron[property] }}
} @else { } @else {
@if (patron[property] === '9999-12-31') { @if (isUnlimitedExpiry(patron['expires'])) {
{{ 'library.account.pages.profile.values.unlimited' | translate }} {{ 'library.account.pages.profile.values.unlimited' | translate }}
} @else { } @else {
{{ 'library.account.pages.profile.values.expires' | translate }}:&nbsp;{{ {{ 'library.account.pages.profile.values.expires' | translate }}:&nbsp;{{
patron[property] | amDateFormat: 'll' patron['expires'] | amDateFormat: 'll'
}} }}
} }
} }

View File

@@ -19,11 +19,19 @@ export interface PAIAPatron {
email?: string; email?: string;
address?: string; address?: string;
expires?: string; expires?: string;
status?: string; status?: PAIAPatronStatus;
type?: string; type?: string;
note?: string; note?: string;
} }
export enum PAIAPatronStatus {
Active = 0,
Inactive = 1,
InactiveExpired = 2,
InactiveOutstandingFees = 3,
InactiveExpiredOutstandingFees = 4,
}
/* /*
* Document representing a library item received from the HeBIS PAIA service * Document representing a library item received from the HeBIS PAIA service
* TODO: would be good to standardize the items of HeBIS PAIA to match the official PAIA documentation * TODO: would be good to standardize the items of HeBIS PAIA to match the official PAIA documentation
@@ -39,7 +47,7 @@ export interface PAIADocument {
queue?: string; queue?: string;
renewals?: string; renewals?: string;
reminder?: string; reminder?: string;
endtime?: string; starttime?: string;
duedate?: string; duedate?: string;
cancancel?: boolean; cancancel?: boolean;
canrenew?: boolean; canrenew?: boolean;

View File

@@ -0,0 +1,51 @@
import {Pipe, PipeTransform} from '@angular/core';
import {MapService} from '@maplibre/ngx-maplibre-gl';
import {Feature, Point} from 'geojson';
import {MapGeoJSONFeature, type GeoJSONSource} from 'maplibre-gl';
import {combineLatest, distinctUntilChanged, map, mergeMap, from, Observable, ReplaySubject} from 'rxjs';
import {SCFeatureProperties} from './feature-collection.pipe';
@Pipe({
name: 'mglClusterLeaves',
standalone: true,
pure: true,
})
export class MglClusterLeavesPipe implements PipeTransform {
source = new ReplaySubject<string>(1);
feature = new ReplaySubject<MapGeoJSONFeature>(1);
limit = new ReplaySubject<number>(1);
offset = new ReplaySubject<number>(1);
leaves: Observable<Feature<Point, SCFeatureProperties>[]> = combineLatest([
this.source.pipe(
distinctUntilChanged(),
map(source => this.mapService.getSource(source) as GeoJSONSource),
),
this.feature.pipe(distinctUntilChanged(it => it.properties.cluster_id)),
this.limit.pipe(distinctUntilChanged()),
this.offset.pipe(distinctUntilChanged()),
]).pipe(
mergeMap(([source, feature, limit, offset]) =>
from(source.getClusterLeaves(feature.properties.cluster_id, limit, offset)),
),
);
constructor(private mapService: MapService) {}
transform(
source: string,
feature: MapGeoJSONFeature,
limit = 0,
offset = 0,
): Observable<Feature<Point, SCFeatureProperties>[]> {
// MapLibre triggers change detection when the map moves, so this is to prevent flicker
this.source.next(source);
this.feature.next(feature);
this.limit.next(limit);
this.offset.next(offset);
return this.leaves;
}
}

View File

@@ -0,0 +1,38 @@
import {animate, style, transition, trigger} from '@angular/animations';
import {AsyncPipe} from '@angular/common';
import {ChangeDetectionStrategy, Component, Input, inject} from '@angular/core';
import {IonicModule} from '@ionic/angular';
import {MapService} from '@maplibre/ngx-maplibre-gl';
import {map, delay, Subject, race, mergeWith} from 'rxjs';
import {IonIconModule} from 'src/app/util/ion-icon/ion-icon.module';
@Component({
selector: 'stapps-map-attribution',
templateUrl: './attribution.html',
styleUrl: './attribution.scss',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [IonicModule, IonIconModule, AsyncPipe],
animations: [
trigger('fade', [
transition(':enter', [
style({opacity: 0, scale: '0.8 1'}),
animate('0.2s ease', style({opacity: 1, scale: 1})),
]),
transition(':leave', [
style({opacity: 1, scale: 1}),
animate('0.2s ease', style({opacity: 0, scale: '0.8 1'})),
]),
]),
],
})
export class AttributionComponent {
@Input() direction: 'left' | 'right' = 'right';
manualVisible = new Subject<void>();
hideAttribution = race(
this.manualVisible,
inject(MapService).mapCreated$.pipe(delay(5000), mergeWith(this.manualVisible)),
).pipe(map((_, i) => i % 2 === 0));
}

View File

@@ -0,0 +1,8 @@
@if ((hideAttribution | async) === null) {
<ion-button [attr.direction]="direction" @fade class="attribution" color="light"
>© OpenStreetMap</ion-button
>
}
<ion-button class="info" shape="round" color="light" (click)="this.manualVisible.next()">
<ion-icon slot="icon-only" name="info"></ion-icon>
</ion-button>

View File

@@ -0,0 +1,36 @@
:host {
position: relative;
}
ion-button {
height: 28px;
min-height: 28px;
margin: 0;
font-size: 10px;
}
ion-button.info {
--padding-start: 4px;
--padding-end: 4px;
}
ion-button.attribution {
position: absolute;
top: -3px;
&[direction='right'] {
--border-radius: 0 14px 14px 0;
--padding-start: 14px;
left: 16px;
transform-origin: 0 0;
}
&[direction='left'] {
--border-radius: 14px 0 0 14px;
--padding-end: 14px;
right: 16px;
transform-origin: 100% 0;
}
}

View File

@@ -0,0 +1,40 @@
import {AsyncPipe} from '@angular/common';
import {ChangeDetectionStrategy, Component} from '@angular/core';
import {IonicModule} from '@ionic/angular';
import {MapService} from '@maplibre/ngx-maplibre-gl';
import {MapEventType} from 'maplibre-gl';
import {map, mergeMap, fromEventPattern, merge} from 'rxjs';
import {IonIconModule} from 'src/app/util/ion-icon/ion-icon.module';
@Component({
selector: 'stapps-compass-control',
templateUrl: './compass-control.html',
styleUrl: './compass-control.scss',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [AsyncPipe, IonicModule, IonIconModule],
})
export class CompassControlComponent {
transform = this.mapService.mapCreated$.pipe(
mergeMap(() =>
merge(
fromEventPattern<MapEventType['rotate']>(
handler => this.mapService.mapInstance.on('rotate', handler),
handler => this.mapService.mapInstance.off('rotate', handler),
),
fromEventPattern<MapEventType['pitch']>(
handler => this.mapService.mapInstance.on('pitch', handler),
handler => this.mapService.mapInstance.off('pitch', handler),
),
),
),
map(event => {
const pitch = event.target.transform.pitch;
const angle = event.target.transform.angle;
return `rotateX(${pitch}deg) rotateZ(${angle}rad)`;
}),
);
constructor(readonly mapService: MapService) {}
}

View File

@@ -0,0 +1,7 @@
<ion-fab-button (click)="mapService.mapInstance.resetNorthPitch()" size="small" color="light">
<svg [style.transform]="transform | async" width="24" height="24" viewBox="0 0 32 32">
>
<path d="m8.5 15 6.56-13.13a1.05 1.05 180 0 1 1.88 0L23.5 15 16 12.5Z" />
<path d="m8.5 15 6.56-13.13a1.05 1.05 180 0 1 1.88 0L23.5 15 16 12.5Z" transform="rotate(180 16 16)" />
</svg>
</ion-fab-button>

View File

@@ -0,0 +1,11 @@
path {
stroke: none;
&:first-child {
fill: var(--ion-color-primary);
}
&:last-child {
fill: var(--ion-color-medium);
}
}

View File

@@ -0,0 +1,91 @@
import {AsyncPipe} from '@angular/common';
import {
AfterContentInit,
ChangeDetectionStrategy,
Component,
ElementRef,
Input,
OnDestroy,
ViewChild,
} from '@angular/core';
import {IonicModule} from '@ionic/angular';
import {MapService} from '@maplibre/ngx-maplibre-gl';
import {FitBoundsOptions, GeolocateControl, GeolocateControlOptions} from 'maplibre-gl';
import {Map as MapLibre} from 'maplibre-gl';
import {BehaviorSubject} from 'rxjs';
import {IonIconModule} from 'src/app/util/ion-icon/ion-icon.module';
type WatchState = InstanceType<typeof GeolocateControl>['_watchState'];
class CustomGeolocateControl extends GeolocateControl {
constructor(
public _container: HTMLElement,
watchState: BehaviorSubject<WatchState>,
options: GeolocateControlOptions,
) {
super(options);
Object.defineProperty(this, '_watchState', {
get() {
return watchState.value;
},
set(value: WatchState) {
watchState.next(value);
},
});
}
override onAdd(map: MapLibre): HTMLElement {
const container = this._container;
this._container = document.createElement('div');
this._map = map;
this._setupUI(true);
this._container = container;
return this._container;
}
override onRemove() {}
}
@Component({
selector: 'stapps-geolocate-control',
templateUrl: './geolocate-control.html',
styleUrl: './geolocate-control.scss',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [AsyncPipe, IonicModule, IonIconModule],
})
export class GeolocateControlComponent implements AfterContentInit, OnDestroy {
@Input() position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
@Input() positionOptions?: PositionOptions;
@Input() fitBoundsOptions?: FitBoundsOptions;
@Input() trackUserLocation?: boolean;
@Input() showUserLocation?: boolean;
@ViewChild('content', {static: true}) content: ElementRef;
watchState = new BehaviorSubject<WatchState>('OFF');
control: CustomGeolocateControl;
constructor(private mapService: MapService) {}
ngAfterContentInit() {
this.control = new CustomGeolocateControl(this.content.nativeElement, this.watchState, {
positionOptions: this.positionOptions,
fitBoundsOptions: this.fitBoundsOptions,
trackUserLocation: this.trackUserLocation,
showUserLocation: this.showUserLocation,
});
this.mapService.mapCreated$.subscribe(() => {
this.mapService.addControl(this.control, this.position);
});
}
ngOnDestroy(): void {
this.mapService.removeControl(this.control);
}
}

View File

@@ -0,0 +1,18 @@
<div #content class="maplibregl-ctrl">
<ion-fab-button color="light" (click)="control.trigger()">
@switch (watchState | async) {
@case ('ACTIVE_LOCK') {
<ion-icon name="my_location" color="primary"></ion-icon>
}
@case ('BACKGROUND') {
<ion-icon name="location_searching"></ion-icon>
}
@case ('WAITING_ACTIVE') {
<ion-icon name="location_searching"></ion-icon>
}
@default {
<ion-icon name="location_disabled"></ion-icon>
}
}
</ion-fab-button>
</div>

View File

@@ -0,0 +1,86 @@
import {ChangeDetectionStrategy, Component, Input, Optional} from '@angular/core';
import {GeoJSONSourceComponent, LayerComponent, MapService} from '@maplibre/ngx-maplibre-gl';
import {FeatureCollection, Polygon} from 'geojson';
import {SCFeatureProperties} from '../feature-collection.pipe';
import {
FillLayerSpecification,
LineLayerSpecification,
MapLayerMouseEvent,
SymbolLayerSpecification,
} from 'maplibre-gl';
import {DataRoutingService} from '../../data/data-routing.service';
import {MapDataProvider} from '../map-data.provider';
import {fromEvent, map, startWith, Observable} from 'rxjs';
import {AsyncPipe} from '@angular/common';
/**
* Get a CCS variable value
*/
function getCssVariable(color: string) {
return getComputedStyle(document.documentElement).getPropertyValue(color);
}
@Component({
selector: 'stapps-building-markers',
templateUrl: './building-markers.html',
styleUrl: './building-markers.scss',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [GeoJSONSourceComponent, LayerComponent, AsyncPipe],
})
export class BuildingMarkersComponent {
accentColor = getCssVariable('--ion-color-primary');
haloColor = fromEvent<MediaQueryListEvent>(
window.matchMedia('(prefers-color-scheme: dark)'),
'change',
).pipe(
map(() => getCssVariable('--ion-background-color')),
startWith(getCssVariable('--ion-background-color')),
);
buildingPaint: LineLayerSpecification['paint'] = {
'line-color': this.accentColor,
'line-width': 2,
};
buildingFillPaint: FillLayerSpecification['paint'] = {
'fill-color': `${this.accentColor}22`,
};
buildingLabelLayout: SymbolLayerSpecification['layout'] = {
'text-field': '{name}',
'text-font': ['barlow-700-normal'],
'text-max-width': 8,
'text-size': 13,
};
buildingLabelPaint: Observable<SymbolLayerSpecification['paint']> = this.haloColor.pipe(
map(haloColor => ({
'text-color': this.accentColor,
'text-halo-color': haloColor,
'text-halo-width': 1,
})),
);
@Input({required: true}) data: FeatureCollection<Polygon, SCFeatureProperties>;
constructor(
@Optional() readonly dataProvider: MapDataProvider | null,
readonly dataRoutingService: DataRoutingService,
readonly mapService: MapService,
) {}
async featureClick(event: MapLayerMouseEvent) {
if (this.dataProvider === null) return;
if (event.originalEvent.target !== event.target._canvas) return;
const feature = event.features?.[0];
if (!feature) return;
const item = this.dataProvider.current.value?.data.find(it => it.uid === feature.properties.uid);
if (item === undefined) return;
this.dataRoutingService.emitChildEvent(item);
}
}

View File

@@ -0,0 +1,26 @@
<mgl-geojson-source id="polygons" [data]="data"></mgl-geojson-source>
<mgl-layer
id="stapps-building-fill"
type="fill"
source="polygons"
before="poi_label"
[paint]="buildingFillPaint"
(layerClick)="featureClick($event)"
(layerMouseEnter)="dataProvider !== null && mapService.changeCanvasCursor('pointer')"
(layerMouseLeave)="dataProvider !== null && mapService.changeCanvasCursor('grab')"
></mgl-layer>
<mgl-layer
id="stapps-building-outline"
type="line"
source="polygons"
before="poi_label"
[paint]="buildingPaint"
></mgl-layer>
<mgl-layer
id="stapps-building-label"
type="symbol"
source="polygons"
after="poi_label"
[paint]="(buildingLabelPaint | async) || {}"
[layout]="buildingLabelLayout"
></mgl-layer>

View File

@@ -0,0 +1,43 @@
import {ChangeDetectionStrategy, Component, HostBinding, Input, OnInit, Optional} from '@angular/core';
import {IonicModule} from '@ionic/angular';
import {IonIconModule} from 'src/app/util/ion-icon/ion-icon.module';
import {MapIconDirective} from '../map-icon.directive';
import {Feature, Point} from 'geojson';
import {SCFeatureProperties} from '../feature-collection.pipe';
import {MapDataProvider} from '../map-data.provider';
import {DataRoutingService} from '../../data/data-routing.service';
import {AddWordBreakOpportunitiesPipe} from '../../../util/word-break-opportunities.pipe';
@Component({
selector: 'stapps-poi-marker',
templateUrl: './poi-marker.html',
styleUrl: './poi-marker.scss',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [IonicModule, IonIconModule, MapIconDirective, AddWordBreakOpportunitiesPipe],
})
export class PoiMarkerComponent implements OnInit {
@Input({required: true}) feature: Feature<Point, SCFeatureProperties>;
@HostBinding('disabled') disabled = this.dataProvider === null;
fontSize = 0;
constructor(
@Optional() readonly dataProvider: MapDataProvider | null,
readonly dataRoutingService: DataRoutingService,
) {}
async featureClick() {
if (this.dataProvider === null) return;
const item = this.dataProvider.current.value?.data.find(it => it.uid === this.feature.properties.uid);
if (item === undefined) return;
this.dataRoutingService.emitChildEvent(item);
}
ngOnInit() {
this.fontSize = Math.max(10, 12 - Math.max(0, this.feature.properties.name.length - 16));
}
}

View File

@@ -0,0 +1,9 @@
<ion-button shape="round" (click)="featureClick()">
<ion-icon
slot="start"
[size]="16"
[fill]="true"
[name]="feature.properties.category | stappsMapIcon"
></ion-icon>
<ion-label>{{ feature.properties.name | addWordBreakOpportunities }}</ion-label>
</ion-button>

View File

@@ -0,0 +1,38 @@
ion-button {
--padding-top: 0;
--padding-bottom: 0;
--padding-start: var(--spacing-md);
--padding-end: var(--spacing-sm);
max-width: 120px;
min-height: 0;
margin: 0;
margin-block-end: 4px;
font: inherit;
font-size: 0.9em;
font-weight: bold;
&::part(native) {
height: 32px;
line-height: 1.2;
}
}
ion-icon {
flex-shrink: 0;
}
ion-label {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
padding: 4px 0;
text-align: left;
overflow-wrap: normal;
white-space: wrap;
-webkit-line-clamp: 2;
}

View File

@@ -0,0 +1,43 @@
import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
import {MapIconDirective} from '../map-icon.directive';
import {FeatureCollection, Point} from 'geojson';
import {SCFeatureProperties} from '../feature-collection.pipe';
import {animate, style, transition, trigger} from '@angular/animations';
import {MglClusterLeavesPipe} from '../cluster-leaves.pipe';
import {
ClusterPointDirective,
GeoJSONSourceComponent,
MarkersForClustersComponent,
PointDirective,
} from '@maplibre/ngx-maplibre-gl';
import {AsyncPipe} from '@angular/common';
import {PoiMarkerComponent} from './poi-marker.component';
@Component({
selector: 'stapps-poi-markers',
templateUrl: './poi-markers.html',
styleUrl: './poi-markers.scss',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MapIconDirective,
MglClusterLeavesPipe,
GeoJSONSourceComponent,
MarkersForClustersComponent,
AsyncPipe,
ClusterPointDirective,
PointDirective,
PoiMarkerComponent,
],
animations: [
trigger('fade', [
transition(':enter', [style({opacity: 0}), animate('200ms', style({opacity: 1}))]),
transition(':leave', [style({opacity: 1}), animate('200ms', style({opacity: 0}))]),
]),
],
})
export class PoiMarkersComponent {
@Input({required: true}) data: FeatureCollection<Point, SCFeatureProperties>;
@Input() clusterPreviewCount = 3;
}

View File

@@ -0,0 +1,33 @@
<mgl-geojson-source
id="pois"
[cluster]="true"
[clusterMaxZoom]="20"
[clusterRadius]="100"
[data]="data"
></mgl-geojson-source>
<mgl-markers-for-clusters source="pois">
<ng-template mglPoint let-feature>
<div class="marker">
<stapps-poi-marker @fade [feature]="feature"></stapps-poi-marker>
</div>
</ng-template>
<ng-template mglClusterPoint let-feature>
<div class="marker">
@if (
'pois'
| mglClusterLeaves
: feature
: clusterPreviewCount - (feature.properties.point_count > clusterPreviewCount ? 1 : 0)
| async;
as leaves
) {
@for (feature of leaves; track feature.id) {
<stapps-poi-marker @fade [feature]="feature"></stapps-poi-marker>
}
@if (feature.properties.point_count > leaves.length) {
<div @fade class="ellipsis">+{{ feature.properties.point_count - leaves.length }}</div>
}
}
</div>
</ng-template>
</mgl-markers-for-clusters>

View File

@@ -0,0 +1,24 @@
.ellipsis {
display: flex;
align-items: center;
justify-content: flex-start;
width: fit-content;
padding-inline: var(--spacing-md);
font-size: 0.8em;
color: var(--ion-color-primary-contrast);
opacity: 0.8;
background: var(--ion-color-primary);
border-radius: 15px;
}
button:not(:only-child) {
margin-bottom: 2px;
}
.marker {
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,81 @@
import {Pipe, PipeTransform} from '@angular/core';
import {SCThing, SCThings} from '@openstapps/core';
import {Feature, FeatureCollection, Point, Polygon} from 'geojson';
/**
* Very simple hash function
*
* MapLibre cannot use strings as feature ids because of
* vector tile spec limitations
*/
function simpleHash(value: string): number {
let hash = 0;
for (let i = 0; i < value.length; i++) {
hash = Math.trunc((hash << 5) - hash + value.codePointAt(i)!);
}
return hash >>> 0;
}
/**
* Finds the best name for a thing to display on the map
*/
function findBestName(thing: SCThing, targetLength = 14): string {
if (!thing.alternateNames || thing.name.length <= targetLength) return thing.name;
return thing.alternateNames.reduce(
(accumulator, current) =>
accumulator.length <= targetLength || accumulator.length <= current.length ? accumulator : current,
thing.name,
);
}
export interface SCFeatureProperties {
name: string;
category?: string;
uid: string;
}
@Pipe({
name: 'thingPoiFeatureCollection',
standalone: true,
pure: true,
})
export class ThingPoiFeatureCollectionPipe implements PipeTransform {
transform(things: SCThings[]): FeatureCollection<Point, SCFeatureProperties> {
return {
type: 'FeatureCollection',
features: things
.filter(thing => 'geo' in thing && thing.geo.polygon === undefined)
.map<Feature<Point, SCFeatureProperties>>(thing => ({
type: 'Feature',
properties: {
name: findBestName(thing),
category: 'categories' in thing ? thing.categories[0] : undefined,
uid: thing.uid,
},
geometry: (thing as Extract<SCThings, {geo: object}>).geo.point,
id: simpleHash(thing.uid),
})),
};
}
}
@Pipe({
name: 'thingPolygonFeatureCollection',
standalone: true,
pure: true,
})
export class ThingPolygonFeatureCollectionPipe implements PipeTransform {
transform(things: SCThings[]): FeatureCollection<Polygon, SCFeatureProperties> {
return {
type: 'FeatureCollection',
features: things
.filter(thing => 'geo' in thing && thing.geo.polygon !== undefined)
.map<Feature<Polygon, SCFeatureProperties>>(thing => ({
type: 'Feature',
geometry: (thing as Extract<SCThings, {geo: object}>).geo.polygon!,
properties: {uid: thing.uid, name: findBestName(thing)},
id: simpleHash(thing.uid),
})),
};
}
}

View File

@@ -0,0 +1,36 @@
import {Directive, HostListener} from '@angular/core';
import {Map, MapMouseEvent, MapStyleDataEvent} from 'maplibre-gl';
@Directive({
selector: 'mgl-map[auto-3d]',
standalone: true,
})
export class MapAuto3dDirective {
@HostListener('styleData', ['$event'])
styleData(event: MapStyleDataEvent) {
this.updatePitch(event.target);
}
@HostListener('pitchEvt', ['$event'])
pitch(event: MapMouseEvent) {
this.updatePitch(event.target);
}
updatePitch(map: Map) {
if (map.getPitch() === 0) {
const layer = map.getLayer('building-3d');
if (layer && layer?.visibility !== 'none') {
layer.visibility = 'none';
map.setPaintProperty('building', 'fill-opacity', 1);
map.setLayerZoomRange('building', 13, 24);
}
} else {
const layer = map.getLayer('building-3d');
if (layer && layer?.visibility !== 'visible') {
layer.visibility = 'visible';
map.setPaintProperty('building', 'fill-opacity', ['interpolate', ['linear'], ['zoom'], 15, 1, 16, 0]);
map.setLayerZoomRange('building', 13, 16);
}
}
}
}

View File

@@ -0,0 +1,59 @@
import {Injectable} from '@angular/core';
import {DataProvider} from '../data/data.provider';
import {SCGeoFilter, SCSearchRequest, SCSearchResponse} from '@openstapps/core';
import {BehaviorSubject} from 'rxjs';
@Injectable()
export class MapDataProvider extends DataProvider {
readonly current = new BehaviorSubject<SCSearchResponse | undefined>(undefined);
readonly currentBounds = new BehaviorSubject<
[[minLon: number, maxLat: number], [maxLon: number, minLat: number]] | undefined
>(undefined);
override async search(query: SCSearchRequest): Promise<SCSearchResponse> {
if (query.query && this.currentBounds.value !== undefined) {
const boundsFilter: SCGeoFilter = {
type: 'geo',
arguments: {
field: 'geo',
shape: {
type: 'envelope',
coordinates: this.currentBounds.value,
},
},
};
query.filter = query.filter
? {
type: 'boolean',
arguments: {
operation: 'and',
filters: [query.filter, boundsFilter],
},
}
: boundsFilter;
}
if (query.from === 0 || this.current.value === undefined) {
this.current.next(
await super.search({
...query,
size: undefined,
}),
);
}
if (query.from === undefined || query.size === undefined) {
return this.current.value!;
}
return {
...this.current.value!,
data: this.current.value!.data.slice(query.from, query.from + query.size),
pagination: {
...this.current.value!.pagination,
offset: query.from,
count: Math.min(query.size, this.current.value!.data.length - query.from),
},
};
}
}

View File

@@ -0,0 +1,37 @@
import {SCThings, SCPlace} from '@openstapps/core';
import {SCIcon} from '../../util/ion-icon/icon';
import {Pipe, PipeTransform} from '@angular/core';
import {MaterialSymbol} from 'material-symbols';
const mapIcons: Record<Extract<SCThings, SCPlace>['categories'][number], MaterialSymbol> = {
'cafe': SCIcon.local_cafe,
'learn': SCIcon.school,
'canteen': SCIcon.restaurant,
'computer': SCIcon.computer,
'education': SCIcon.school,
'laboratory': SCIcon.science,
'library': SCIcon.local_library,
'lounge': SCIcon.weekend,
'office': SCIcon.meeting_room,
'restaurant': SCIcon.restaurant,
'restroom': SCIcon.wc,
'student canteen': SCIcon.restaurant,
'student union': SCIcon.groups,
'validator': SCIcon.badge,
'card charger': SCIcon.credit_card,
'printer': SCIcon.print,
'disabled access': SCIcon.accessible,
};
const defaultIcon = SCIcon.not_listed_location;
@Pipe({
name: 'stappsMapIcon',
standalone: true,
pure: true,
})
export class MapIconDirective implements PipeTransform {
transform(value: keyof typeof mapIcons | string | undefined): MaterialSymbol {
return mapIcons[value as keyof typeof mapIcons] ?? defaultIcon;
}
}

Some files were not shown because too many files have changed in this diff Show More