From c1c9a92ec900403218b887fdebfe5132b232e1e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jovan=20Kruni=C4=87?= Date: Tue, 13 Jul 2021 07:57:09 +0000 Subject: [PATCH] feat: add map module --- angular.json | 19 +- config.xml | 1 + package-lock.json | 75 +++ package.json | 18 +- src/app/app.module.ts | 5 +- src/app/modules/data/data.module.ts | 69 +-- .../data/detail/data-detail-content.html | 15 + src/app/modules/data/detail/data-detail.html | 18 - src/app/modules/data/list/data-list-item.scss | 14 + .../data/list/data-list.component.spec.ts | 8 + src/app/modules/data/list/search-page.html | 6 +- .../place/place-detail-content.component.ts | 3 + .../types/place/place-detail-content.html | 10 +- .../types/place/place-detail-content.scss | 4 + .../types/place/place-list-item.component.ts | 49 +- .../data/types/place/place-list-item.html | 10 +- .../modules/map/item/map-item.component.html | 28 ++ .../modules/map/item/map-item.component.scss | 8 + .../modules/map/item/map-item.component.ts | 40 ++ src/app/modules/map/map.module.ts | 97 ++++ src/app/modules/map/map.provider.spec.ts | 48 ++ src/app/modules/map/map.provider.ts | 232 +++++++++ .../modules/map/page/map-page.component.ts | 472 ++++++++++++++++++ src/app/modules/map/page/map-page.html | 105 ++++ src/app/modules/map/page/map-page.scss | 119 +++++ .../page/modals/map-list-modal.component.ts | 60 +++ src/app/modules/map/page/modals/map-list.html | 13 + src/app/modules/map/page/modals/map-list.scss | 0 .../page/modals/map-single-modal.component.ts | 33 ++ .../modules/map/page/modals/map-single.html | 13 + .../modules/map/page/modals/map-single.scss | 0 src/app/modules/map/position.service.spec.ts | 103 ++++ src/app/modules/map/position.service.ts | 163 ++++++ .../map/widget/map-widget.component.ts | 81 +++ src/app/modules/map/widget/map-widget.html | 16 + src/app/modules/map/widget/map-widget.scss | 12 + .../menu/context/context-menu.component.ts | 11 +- .../modules/menu/context/context-menu.html | 7 +- .../modules/menu/context/context-menu.scss | 7 - .../menu/context/context-menu.service.ts | 106 +++- .../custom-ionicons/navigate-straight.svg | 56 +++ src/assets/i18n/de.json | 38 +- src/assets/i18n/en.json | 38 +- src/theme/variables.scss | 1 + 44 files changed, 2138 insertions(+), 93 deletions(-) create mode 100644 src/app/modules/data/types/place/place-detail-content.scss create mode 100644 src/app/modules/map/item/map-item.component.html create mode 100644 src/app/modules/map/item/map-item.component.scss create mode 100644 src/app/modules/map/item/map-item.component.ts create mode 100644 src/app/modules/map/map.module.ts create mode 100644 src/app/modules/map/map.provider.spec.ts create mode 100644 src/app/modules/map/map.provider.ts create mode 100644 src/app/modules/map/page/map-page.component.ts create mode 100644 src/app/modules/map/page/map-page.html create mode 100644 src/app/modules/map/page/map-page.scss create mode 100644 src/app/modules/map/page/modals/map-list-modal.component.ts create mode 100644 src/app/modules/map/page/modals/map-list.html create mode 100644 src/app/modules/map/page/modals/map-list.scss create mode 100644 src/app/modules/map/page/modals/map-single-modal.component.ts create mode 100644 src/app/modules/map/page/modals/map-single.html create mode 100644 src/app/modules/map/page/modals/map-single.scss create mode 100644 src/app/modules/map/position.service.spec.ts create mode 100644 src/app/modules/map/position.service.ts create mode 100644 src/app/modules/map/widget/map-widget.component.ts create mode 100644 src/app/modules/map/widget/map-widget.html create mode 100644 src/app/modules/map/widget/map-widget.scss delete mode 100644 src/app/modules/menu/context/context-menu.scss create mode 100644 src/assets/custom-ionicons/navigate-straight.svg diff --git a/angular.json b/angular.json index 6f6f944a..3cd011a9 100644 --- a/angular.json +++ b/angular.json @@ -31,6 +31,16 @@ "input": "node_modules/ionicons/dist/ionicons/svg", "output": "./svg" }, + { + "glob": "**/*.svg", + "input": "src/assets/custom-ionicons", + "output": "./svg" + }, + { + "glob": "**/*", + "input": "./node_modules/leaflet/dist/images", + "output": "assets/" + }, { "glob": "**/*.svg", "input": "src/assets/custom-ion-icons", @@ -43,7 +53,9 @@ }, { "input": "src/global.scss" - } + }, + "./node_modules/leaflet/dist/leaflet.css", + "./node_modules/leaflet.markercluster/dist/MarkerCluster.Default.css" ], "scripts": [] }, @@ -158,6 +170,11 @@ "glob": "**/*", "input": "src/assets", "output": "/assets" + }, + { + "glob": "**/*", + "input": "./node_modules/leaflet/dist/images", + "output": "assets/" } ] }, diff --git a/config.xml b/config.xml index 141bc0f9..5258032d 100644 --- a/config.xml +++ b/config.xml @@ -31,4 +31,5 @@ + diff --git a/package-lock.json b/package-lock.json index 51d76f57..e877887c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -814,6 +814,16 @@ "resolved": "https://registry.npmjs.org/@angular/router/-/router-9.1.12.tgz", "integrity": "sha512-+qCaXa9y0nsRhzjAYBqmGoQ2YkrdXgftZwuFDf6t4qEi30EXa0oS97KrlFq0M5GKdLIDGrbUm9PcdHSTOI+ZhA==" }, + "@asymmetrik/ngx-leaflet": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@asymmetrik/ngx-leaflet/-/ngx-leaflet-5.0.2.tgz", + "integrity": "sha512-y4+U9nUnukdTTO2Z6fCt0V85U605bveERNIzrovjKELkHbxz70TvJSvCU7iSSc5ZwS5ZJI1a24SiBJ5sGt9WMQ==" + }, + "@asymmetrik/ngx-leaflet-markercluster": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@asymmetrik/ngx-leaflet-markercluster/-/ngx-leaflet-markercluster-2.1.1.tgz", + "integrity": "sha512-ngsPNpVNrWpTaNqiHnepdJJjYmV0OH3Q7ubAH8qcxMtlH1Imqjp3lF7sdq4Qa8z3mrbJQSxFwLNCsKHbEFk6ow==" + }, "@babel/code-frame": { "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.14.5.tgz", @@ -2266,6 +2276,21 @@ "@types/cordova": "^0.0.34" } }, + "@ionic-native/diagnostic": { + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/@ionic-native/diagnostic/-/diagnostic-5.32.0.tgz", + "integrity": "sha512-1DRub6i8hNhHYy5HWlun0CjhmE1cwZZWyLB9ptkwLkZdfFEazwj0eKjz3BmpCs6wy593smNn/x9G0lCwvLCZlA==", + "requires": { + "@types/cordova": "^0.0.34" + }, + "dependencies": { + "@types/cordova": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/@types/cordova/-/cordova-0.0.34.tgz", + "integrity": "sha1-6nrd907Ow9dimCegw54smt3HPQQ=" + } + } + }, "@ionic-native/geolocation": { "version": "5.29.0", "resolved": "https://registry.npmjs.org/@ionic-native/geolocation/-/geolocation-5.29.0.tgz", @@ -3207,6 +3232,24 @@ "@types/node": "*" } }, + "@types/leaflet": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.4.4.tgz", + "integrity": "sha512-CROxHvsRDFyR1OQKZv/WJsCVFv8Wj6wFF/FOI/yiGwX7GMivyvF8Ks5AT3/JYk269pGiE43wP9JOgcr7EK2eUw==", + "dev": true, + "requires": { + "@types/geojson": "*" + } + }, + "@types/leaflet.markercluster": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.0.3.tgz", + "integrity": "sha512-rz4xQcsD3Ha9TcX4nMba9wpNe7HPQ03Hvo8Osi3SLpfaDCydHMoTquOG1IsjQ2aFm/LIHz4Uo4hYoeLv7q082w==", + "dev": true, + "requires": { + "@types/leaflet": "*" + } + }, "@types/lodash": { "version": "4.14.170", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.170.tgz", @@ -7227,6 +7270,23 @@ } } }, + "cordova.plugins.diagnostic": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/cordova.plugins.diagnostic/-/cordova.plugins.diagnostic-6.0.3.tgz", + "integrity": "sha512-Pj3pPHYtZEVHQyDmDcUKI78arzcpmIod+5DnOAtVvAvOa5AErUlPou4/MPAv08cw/REYsJ8Siy6YIWkdMnMaFA==", + "requires": { + "colors": "^1.1.2", + "elementtree": "^0.1.6", + "minimist": "1.2.0" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" + } + } + }, "core-js": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.5.tgz", @@ -10007,6 +10067,11 @@ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true }, + "geojson": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/geojson/-/geojson-0.5.0.tgz", + "integrity": "sha1-PNbJY5m+ZbVu5VWWEW/pGRznAcA=" + }, "get-assigned-identifiers": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/get-assigned-identifiers/-/get-assigned-identifiers-1.2.0.tgz", @@ -12648,6 +12713,16 @@ "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", "dev": true }, + "leaflet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.4.0.tgz", + "integrity": "sha512-x9j9tGY1+PDLN9pcWTx9/y6C5nezoTMB8BLK5jTakx+H7bPlnbCHfi9Hjg+Qt36sgDz/cb9lrSpNQXmk45Tvhw==" + }, + "leaflet.markercluster": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.4.1.tgz", + "integrity": "sha512-ZSEpE/EFApR0bJ1w/dUGwTSUvWlpalKqIzkaYdYB7jaftQA/Y2Jav+eT4CMtEYFj+ZK4mswP13Q2acnPBnhGOw==" + }, "less": { "version": "3.11.3", "resolved": "https://registry.npmjs.org/less/-/less-3.11.3.tgz", diff --git a/package.json b/package.json index a4d3e02c..a811ed6b 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "docker:serve": "sudo docker run -p 8100:8100 -p 35729:35729 -p 53703:53703 -v $PWD:/app -it registry.gitlab.com/openstapps/app bash -c \"npm start\"", "documentation": "compodoc -p tsconfig.json -d docs", "lint": "ng lint", - "lint:fix": "ng lint:fix", + "lint-fix": "eslint --fix -c .eslintrc.json --ignore-path .eslintignore --ext .ts,.html src/", "ng": "ng", "postversion": "npm run changelog", "pree2e": "webdriver-manager clean && webdriver-manager update --gecko false --versions.chrome $(google-chrome --product-version)", @@ -47,8 +47,11 @@ "@angular/platform-browser": "9.1.12", "@angular/platform-browser-dynamic": "9.1.12", "@angular/router": "9.1.12", + "@asymmetrik/ngx-leaflet": "5.0.2", + "@asymmetrik/ngx-leaflet-markercluster": "2.1.1", "@capacitor/core": "2.4.6", "@ionic-native/core": "5.29.0", + "@ionic-native/diagnostic": "5.32.0", "@ionic-native/geolocation": "5.29.0", "@ionic-native/network": "5.31.1", "@ionic-native/splash-screen": "5.29.0", @@ -65,6 +68,7 @@ "cordova-ios": "6.2.0", "cordova-plugin-androidx-adapter": "1.1.3", "cordova-plugin-device": "2.0.3", + "cordova.plugins.diagnostic": "6.0.3", "cordova-plugin-geolocation": "4.1.0", "cordova-plugin-ionic-keyboard": "2.2.0", "cordova-plugin-ionic-webview": "5.0.0", @@ -74,6 +78,9 @@ "core-js": "2.6.5", "deepmerge": "3.3.0", "form-data": "2.5.0", + "geojson": "0.5.0", + "leaflet": "1.4.0", + "leaflet.markercluster": "1.4.1", "lodash-es": "4.17.21", "moment": "2.29.1", "ngx-logger": "4.1.9", @@ -103,6 +110,8 @@ "@types/form-data": "2.5.0", "@types/jasmine": "3.3.12", "@types/jasminewd2": "2.0.6", + "@types/leaflet": "1.4.4", + "@types/leaflet.markercluster": "1.0.3", "@types/lodash-es": "4.17.4", "@types/node": "14.14.37", "@typescript-eslint/eslint-plugin": "4.3.0", @@ -134,11 +143,16 @@ "cordova-plugin-whitelist": {}, "cordova-plugin-device": {}, "cordova-plugin-splashscreen": {}, - "cordova-plugin-ionic-webview": {}, + "cordova-plugin-ionic-webview": { + "ANDROID_SUPPORT_ANNOTATIONS_VERSION": "27.+" + }, "cordova-plugin-ionic-keyboard": {}, "cordova-plugin-geolocation": { "GEOLOCATION_USAGE_DESCRIPTION": "The app will use your location to provide features for navigation or distances information.", "GPS_REQUIRED": "true" + }, + "cordova.plugins.diagnostic": { + "ANDROIDX_VERSION": "1.+" } }, "platforms": [ diff --git a/src/app/app.module.ts b/src/app/app.module.ts index aa3182de..60455fb9 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -23,6 +23,7 @@ import localeDe from '@angular/common/locales/de'; import {APP_INITIALIZER, NgModule, Provider} from '@angular/core'; import {BrowserModule} from '@angular/platform-browser'; import {RouteReuseStrategy} from '@angular/router'; +import {Diagnostic} from '@ionic-native/diagnostic/ngx'; import {SplashScreen} from '@ionic-native/splash-screen/ngx'; import {StatusBar} from '@ionic-native/status-bar/ngx'; import {IonicModule, IonicRouteStrategy} from '@ionic/angular'; @@ -41,6 +42,7 @@ import {AppComponent} from './app.component'; import {ConfigModule} from './modules/config/config.module'; import {ConfigProvider} from './modules/config/config.provider'; import {DataModule} from './modules/data/data.module'; +import {MapModule} from './modules/map/map.module'; import {MenuModule} from './modules/menu/menu.module'; import {NewsModule} from './modules/news/news.module'; import {SettingsModule} from './modules/settings/settings.module'; @@ -48,7 +50,6 @@ import {SettingsProvider} from './modules/settings/settings.provider'; import {StorageModule} from './modules/storage/storage.module'; import {ThingTranslateModule} from './translation/thing-translate.module'; import {fakeBackendProvider} from './_helpers/fake-backend.interceptor'; - import {initLogger} from './_helpers/ts-logger'; registerLocaleData(localeDe); @@ -105,6 +106,7 @@ export function createTranslateLoader(http: HttpClient) { const providers: Provider[] = [ StatusBar, SplashScreen, + Diagnostic, { provide: RouteReuseStrategy, useClass: IonicRouteStrategy, @@ -134,6 +136,7 @@ const providers: Provider[] = [ ConfigModule, DataModule, IonicModule.forRoot(), + MapModule, MenuModule, NewsModule, SettingsModule, diff --git a/src/app/modules/data/data.module.ts b/src/app/modules/data/data.module.ts index ca107e49..cd51cdd1 100644 --- a/src/app/modules/data/data.module.ts +++ b/src/app/modules/data/data.module.ts @@ -36,44 +36,46 @@ import {DataProvider} from './data.provider'; import {DataDetailContentComponent} from './detail/data-detail-content.component'; import {DataDetailComponent} from './detail/data-detail.component'; import {AddressDetailComponent} from './elements/address-detail.component'; -import {LongInlineTextComponent} from './elements/long-inline-text.component'; import {OffersDetailComponent} from './elements/offers-detail.component'; import {OffersInListComponent} from './elements/offers-in-list.component'; import {OriginDetailComponent} from './elements/origin-detail.component'; import {OriginInListComponent} from './elements/origin-in-list.component'; import {SimpleCardComponent} from './elements/simple-card.component'; -import {SkeletonListItemComponent} from './elements/skeleton-list-item.component'; -import {SkeletonSegmentComponent} from './elements/skeleton-segment-button.component'; -import {SkeletonSimpleCardComponent} from './elements/skeleton-simple-card.component'; -import {DataListItemComponent} from './list/data-list-item.component'; import {DataListComponent} from './list/data-list.component'; import {FoodDataListComponent} from './list/food-data-list.component'; import {SearchPageComponent} from './list/search-page.component'; import {StAppsWebHttpClient} from './stapps-web-http-client.provider'; import {ArticleDetailContentComponent} from './types/article/article-detail-content.component'; -import {ArticleListItemComponent} from './types/article/article-list-item.component'; import {CatalogDetailContentComponent} from './types/catalog/catalog-detail-content.component'; -import {CatalogListItemComponent} from './types/catalog/catalog-list-item.component'; import {DateSeriesDetailContentComponent} from './types/date-series/date-series-detail-content.component'; -import {DateSeriesListItemComponent} from './types/date-series/date-series-list-item.component'; import {DishDetailContentComponent} from './types/dish/dish-detail-content.component'; -import {DishListItemComponent} from './types/dish/dish-list-item.component'; import {EventDetailContentComponent} from './types/event/event-detail-content.component'; import {EventListItemComponent} from './types/event/event-list-item.component'; import {FavoriteDetailContentComponent} from './types/favorite/favorite-detail-content.component'; -import {FavoriteListItemComponent} from './types/favorite/favorite-list-item.component'; import {MessageDetailContentComponent} from './types/message/message-detail-content.component'; -import {MessageListItemComponent} from './types/message/message-list-item.component'; import {OrganizationDetailContentComponent} from './types/organization/organization-detail-content.component'; -import {OrganizationListItemComponent} from './types/organization/organization-list-item.component'; import {PersonDetailContentComponent} from './types/person/person-detail-content.component'; -import {PersonListItemComponent} from './types/person/person-list-item.component'; import {PlaceDetailContentComponent} from './types/place/place-detail-content.component'; import {PlaceListItemComponent} from './types/place/place-list-item.component'; import {PlaceMensaDetailComponent} from './types/place/special/mensa/place-mensa-detail.component'; import {SemesterDetailContentComponent} from './types/semester/semester-detail-content.component'; -import {SemesterListItemComponent} from './types/semester/semester-list-item.component'; import {VideoDetailContentComponent} from './types/video/video-detail-content.component'; +import {MapWidgetComponent} from '../map/widget/map-widget.component'; +import {LeafletModule} from '@asymmetrik/ngx-leaflet'; +import {ArticleListItemComponent} from './types/article/article-list-item.component'; +import {SkeletonSimpleCardComponent} from './elements/skeleton-simple-card.component'; +import {CatalogListItemComponent} from './types/catalog/catalog-list-item.component'; +import {DataListItemComponent} from './list/data-list-item.component'; +import {DateSeriesListItemComponent} from './types/date-series/date-series-list-item.component'; +import {DishListItemComponent} from './types/dish/dish-list-item.component'; +import {FavoriteListItemComponent} from './types/favorite/favorite-list-item.component'; +import {LongInlineTextComponent} from './elements/long-inline-text.component'; +import {MessageListItemComponent} from './types/message/message-list-item.component'; +import {OrganizationListItemComponent} from './types/organization/organization-list-item.component'; +import {PersonListItemComponent} from './types/person/person-list-item.component'; +import {SemesterListItemComponent} from './types/semester/semester-list-item.component'; +import {SkeletonListItemComponent} from './elements/skeleton-list-item.component'; +import {SkeletonSegmentComponent} from './elements/skeleton-segment-button.component'; import {VideoListItemComponent} from './types/video/video-list-item.component'; /** @@ -81,17 +83,19 @@ import {VideoListItemComponent} from './types/video/video-list-item.component'; */ @NgModule({ declarations: [ - ActionChipListComponent, - AddEventActionChipComponent, AddEventPopoverComponent, + OffersDetailComponent, + OffersInListComponent, AddressDetailComponent, ArticleDetailContentComponent, ArticleListItemComponent, + SimpleCardComponent, + SkeletonSimpleCardComponent, CatalogDetailContentComponent, CatalogListItemComponent, DataDetailComponent, DataDetailContentComponent, - DataIconPipe, + FoodDataListComponent, DataListComponent, DataListItemComponent, DateSeriesDetailContentComponent, @@ -102,13 +106,10 @@ import {VideoListItemComponent} from './types/video/video-list-item.component'; EventListItemComponent, FavoriteDetailContentComponent, FavoriteListItemComponent, - FoodDataListComponent, - LocateActionChipComponent, LongInlineTextComponent, + MapWidgetComponent, MessageDetailContentComponent, MessageListItemComponent, - OffersDetailComponent, - OffersInListComponent, OrganizationDetailContentComponent, OrganizationListItemComponent, OriginDetailComponent, @@ -121,20 +122,22 @@ import {VideoListItemComponent} from './types/video/video-list-item.component'; SearchPageComponent, SemesterDetailContentComponent, SemesterListItemComponent, - SimpleCardComponent, SkeletonListItemComponent, SkeletonSegmentComponent, - SkeletonSimpleCardComponent, VideoDetailContentComponent, VideoListItemComponent, + DataIconPipe, + ActionChipListComponent, + AddEventActionChipComponent, + LocateActionChipComponent, ], - entryComponents: [DataListComponent], imports: [ - CommonModule, - DataRoutingModule, - FormsModule, - HttpClientModule, IonicModule.forRoot(), + CommonModule, + FormsModule, + DataRoutingModule, + HttpClientModule, + LeafletModule, MarkdownModule.forRoot(), MenuModule, MomentModule.forRoot({ @@ -148,5 +151,15 @@ import {VideoListItemComponent} from './types/video/video-list-item.component'; ThingTranslateModule.forChild(), ], providers: [DataProvider, DataFacetsProvider, Network, StAppsWebHttpClient], + exports: [ + DataListComponent, + DataListItemComponent, + DataDetailComponent, + SkeletonSimpleCardComponent, + SkeletonListItemComponent, + DataIconPipe, + PlaceListItemComponent, + DataDetailContentComponent, + ], }) export class DataModule {} diff --git a/src/app/modules/data/detail/data-detail-content.html b/src/app/modules/data/detail/data-detail-content.html index 78e0ac98..f0e4f9b9 100644 --- a/src/app/modules/data/detail/data-detail-content.html +++ b/src/app/modules/data/detail/data-detail-content.html @@ -1,3 +1,18 @@ + + + + + + + +
+

{{ item.name }}

+ {{ item.type }} +
+
+
+
+
- - - - - - - -
-

{{ item.name }}

- {{ item.type }} -
-
-
-
-
diff --git a/src/app/modules/data/list/data-list-item.scss b/src/app/modules/data/list/data-list-item.scss index b16f7766..61c0234b 100644 --- a/src/app/modules/data/list/data-list-item.scss +++ b/src/app/modules/data/list/data-list-item.scss @@ -15,4 +15,18 @@ padding-top: 0; padding-bottom: 0; } + + ion-note { + ul { + margin: 0; + padding: 0; + li { + list-style-type: none; + display: inline; + } + li:not(:first-child):before { + content: " • "; + } + } + } } diff --git a/src/app/modules/data/list/data-list.component.spec.ts b/src/app/modules/data/list/data-list.component.spec.ts index db9f8b74..73df3039 100644 --- a/src/app/modules/data/list/data-list.component.spec.ts +++ b/src/app/modules/data/list/data-list.component.spec.ts @@ -2,15 +2,23 @@ import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {DataListComponent} from './data-list.component'; import {TranslateModule} from '@ngx-translate/core'; +import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; +import {ConfigProvider} from '../../config/config.provider'; describe('DataListComponent', () => { let component: DataListComponent; let fixture: ComponentFixture; + let configProviderMock: jasmine.SpyObj; beforeEach(async(() => { + configProviderMock = jasmine.createSpyObj('ConfigProvider', { + getValue: () => Promise.resolve({lat: 123, lng: 123}), + }); TestBed.configureTestingModule({ declarations: [DataListComponent], imports: [TranslateModule.forRoot()], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + providers: [{provide: ConfigProvider, useValue: configProviderMock}], }).compileComponents(); })); diff --git a/src/app/modules/data/list/search-page.html b/src/app/modules/data/list/search-page.html index d83c8653..72aaeda0 100644 --- a/src/app/modules/data/list/search-page.html +++ b/src/app/modules/data/list/search-page.html @@ -1,4 +1,4 @@ - + @@ -13,7 +13,9 @@ + showClearButton="always" + > + diff --git a/src/app/modules/data/types/place/place-detail-content.component.ts b/src/app/modules/data/types/place/place-detail-content.component.ts index 9eb993d2..83c1c4ad 100644 --- a/src/app/modules/data/types/place/place-detail-content.component.ts +++ b/src/app/modules/data/types/place/place-detail-content.component.ts @@ -27,6 +27,7 @@ import {DataProvider} from '../../data.provider'; */ @Component({ providers: [DataProvider], + styleUrls: ['place-detail-content.scss'], selector: 'stapps-place-detail-content', templateUrl: 'place-detail-content.html', }) @@ -41,7 +42,9 @@ export class PlaceDetailContentComponent { * * @param item TODO */ + // tslint:disable-next-line:completed-docs prefer-function-over-method hasCategories(item: SCThings): item is SCThings & {categories: string[]} { + // tslint:disable-next-line:completed-docs return typeof (item as {categories: string[]}).categories !== 'undefined'; } diff --git a/src/app/modules/data/types/place/place-detail-content.html b/src/app/modules/data/types/place/place-detail-content.html index 9c2e22fb..65fe3ceb 100644 --- a/src/app/modules/data/types/place/place-detail-content.html +++ b/src/app/modules/data/types/place/place-detail-content.html @@ -1,6 +1,5 @@ @@ -26,8 +25,9 @@ }} - + + + + + diff --git a/src/app/modules/data/types/place/place-detail-content.scss b/src/app/modules/data/types/place/place-detail-content.scss new file mode 100644 index 00000000..efd2774c --- /dev/null +++ b/src/app/modules/data/types/place/place-detail-content.scss @@ -0,0 +1,4 @@ +ion-card.map-widget { + height: 300px; + width: auto; +} diff --git a/src/app/modules/data/types/place/place-list-item.component.ts b/src/app/modules/data/types/place/place-list-item.component.ts index 1ad87fca..443bcec9 100644 --- a/src/app/modules/data/types/place/place-list-item.component.ts +++ b/src/app/modules/data/types/place/place-list-item.component.ts @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 StApps + * Copyright (C) 2019-2021 StApps * This program is free software: you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the Free * Software Foundation, version 3. @@ -14,18 +14,55 @@ */ import {Component, Input} from '@angular/core'; import {SCBuilding, SCFloor, SCPointOfInterest, SCRoom} from '@openstapps/core'; -import {DataListItemComponent} from '../../list/data-list-item.component'; +import {PositionService} from '../../../map/position.service'; + +type placeTypes = (SCBuilding | SCRoom | SCPointOfInterest | SCFloor) & { + distance?: number; +}; /** - * TODO + * Provide information if a place is a floor + * + * @param place A place to check + */ +function isSCFloor(place: placeTypes): place is SCFloor { + return place.type === 'floor'; +} + +/** + * Shows a place as a list item */ @Component({ selector: 'stapps-place-list-item', templateUrl: 'place-list-item.html', }) -export class PlaceListItemComponent extends DataListItemComponent { +export class PlaceListItemComponent { /** - * TODO + * Item getter */ - @Input() item: SCBuilding | SCRoom | SCPointOfInterest | SCFloor; + get item(): placeTypes { + return this._item; + } + + /** + * An item to show (setter is used as there were issues assigning the distance to the right place in a list) + */ + @Input() set item(item: placeTypes) { + this._item = item; + if (!isSCFloor(item)) { + this.distance = this.positionService.getDistance(item.geo.point); + } + } + + /** + * An item to show + */ + private _item: placeTypes; + + /** + * Distance in meters + */ + distance?: number; + + constructor(private positionService: PositionService) {} } diff --git a/src/app/modules/data/types/place/place-list-item.html b/src/app/modules/data/types/place/place-list-item.html index 17c91308..7b9e7af1 100644 --- a/src/app/modules/data/types/place/place-list-item.html +++ b/src/app/modules/data/types/place/place-list-item.html @@ -6,7 +6,15 @@

{{ 'description' | thingTranslate: item }}

- {{ 'type' | thingTranslate: item }} + +
    +
  • {{ 'type' | thingTranslate: item }}
  • +
  • + {{ distance | numberLocalized: '1.0-0' }} m +
  • +
+
diff --git a/src/app/modules/map/item/map-item.component.html b/src/app/modules/map/item/map-item.component.html new file mode 100644 index 00000000..9311e93b --- /dev/null +++ b/src/app/modules/map/item/map-item.component.html @@ -0,0 +1,28 @@ + + + + + + + + + + + + {{ item.inPlace.name }}, + {{ address.streetAddress }}, {{ address.addressLocality }} + + + + + More  + + + + + diff --git a/src/app/modules/map/item/map-item.component.scss b/src/app/modules/map/item/map-item.component.scss new file mode 100644 index 00000000..e8941db6 --- /dev/null +++ b/src/app/modules/map/item/map-item.component.scss @@ -0,0 +1,8 @@ +ion-col:nth-child(2) { + display: flex; + justify-content: flex-end; + + ion-button { + margin-top: auto; + } +} diff --git a/src/app/modules/map/item/map-item.component.ts b/src/app/modules/map/item/map-item.component.ts new file mode 100644 index 00000000..bdaff559 --- /dev/null +++ b/src/app/modules/map/item/map-item.component.ts @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2021 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +import {Component, EventEmitter, Input, Output} from '@angular/core'; +import {SCPlace} from '@openstapps/core'; + +@Component({ + selector: 'stapps-map-item', + templateUrl: './map-item.component.html', + styleUrls: ['./map-item.component.scss'], +}) +export class MapItemComponent { + /** + * An item to show + */ + @Input() item: SCPlace; + + /** + * An item to show + */ + @Output() showDetails = new EventEmitter(); + + /** + * Emit event to signalize to show more information + */ + showMore() { + this.showDetails.emit(this.item); + } +} diff --git a/src/app/modules/map/map.module.ts b/src/app/modules/map/map.module.ts new file mode 100644 index 00000000..a7bd5060 --- /dev/null +++ b/src/app/modules/map/map.module.ts @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2019-2021 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +import {CommonModule} from '@angular/common'; +import {APP_INITIALIZER, NgModule} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {RouterModule, Routes} from '@angular/router'; +import {LeafletModule} from '@asymmetrik/ngx-leaflet'; +import {LeafletMarkerClusterModule} from '@asymmetrik/ngx-leaflet-markercluster'; +import {Geolocation} from '@ionic-native/geolocation/ngx'; +import {IonicModule} from '@ionic/angular'; +import {TranslateModule} from '@ngx-translate/core'; +import {Polygon} from 'geojson'; +import {ThingTranslateModule} from '../../translation/thing-translate.module'; +import {ConfigProvider} from '../config/config.provider'; +import {DataFacetsProvider} from '../data/data-facets.provider'; +import {DataModule} from '../data/data.module'; +import {DataProvider} from '../data/data.provider'; +import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider'; +import {MenuModule} from '../menu/menu.module'; +import {MapProvider} from './map.provider'; +import {MapPageComponent} from './page/map-page.component'; +import {MapListModalComponent} from './page/modals/map-list-modal.component'; +import {MapSingleModalComponent} from './page/modals/map-single-modal.component'; +import {MapItemComponent} from './item/map-item.component'; + +/** + * Initializes the default area to show in advance (before components are initialized) + * + * @param configProvider An instance of the ConfigProvider to read the campus polygon from + * @param mapProvider An instance of the MapProvider to set the default polygon (area to show on the map) + */ +export function initMapConfigFactory( + configProvider: ConfigProvider, + mapProvider: MapProvider, +) { + return async () => { + mapProvider.defaultPolygon = (await configProvider.getValue( + 'campusPolygon', + )) as Polygon; + }; +} + +const mapRoutes: Routes = [ + {path: 'map', component: MapPageComponent}, + {path: 'map/:uid', component: MapPageComponent}, +]; + +/** + * Module containing map related stuff + */ +@NgModule({ + declarations: [ + MapPageComponent, + MapListModalComponent, + MapSingleModalComponent, + MapItemComponent, + ], + exports: [], + imports: [ + CommonModule, + IonicModule.forRoot(), + LeafletModule, + LeafletMarkerClusterModule, + RouterModule.forChild(mapRoutes), + TranslateModule.forChild(), + MenuModule, + DataModule, + FormsModule, + ThingTranslateModule, + ], + providers: [ + Geolocation, + MapProvider, + DataProvider, + DataFacetsProvider, + StAppsWebHttpClient, + { + provide: APP_INITIALIZER, + multi: true, + deps: [ConfigProvider, MapProvider], + useFactory: initMapConfigFactory, + }, + ], +}) +export class MapModule {} diff --git a/src/app/modules/map/map.provider.spec.ts b/src/app/modules/map/map.provider.spec.ts new file mode 100644 index 00000000..ce9aa0f3 --- /dev/null +++ b/src/app/modules/map/map.provider.spec.ts @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2019-2021 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +import {TestBed} from '@angular/core/testing'; + +import {Geolocation} from '@ionic-native/geolocation/ngx'; +import {Diagnostic} from '@ionic-native/diagnostic/ngx'; +import {MapProvider} from './map.provider'; +import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider'; +import {HttpClientModule} from '@angular/common/http'; +import {StorageProvider} from '../storage/storage.provider'; +import {MapModule} from './map.module'; +import {StorageModule} from '../storage/storage.module'; +import {LoggerConfig, LoggerModule, NGXLogger} from 'ngx-logger'; + +describe('MapProvider', () => { + let provider: MapProvider; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [MapModule, HttpClientModule, StorageModule, LoggerModule], + providers: [ + Geolocation, + Diagnostic, + StAppsWebHttpClient, + StorageProvider, + NGXLogger, + LoggerConfig, + ], + }); + provider = TestBed.inject(MapProvider); + }); + + it('should be created', () => { + expect(provider).toBeTruthy(); + }); +}); diff --git a/src/app/modules/map/map.provider.ts b/src/app/modules/map/map.provider.ts new file mode 100644 index 00000000..80595d1f --- /dev/null +++ b/src/app/modules/map/map.provider.ts @@ -0,0 +1,232 @@ +/* + * Copyright (C) 2019-2021 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +import {ElementRef, Injectable} from '@angular/core'; +import { + SCSearchFilter, + SCSearchQuery, + SCSearchResponse, + SCThingType, + SCUuid, +} from '@openstapps/core'; +import {Point, Polygon} from 'geojson'; +import {divIcon, geoJSON, icon, LatLng, Map, marker, Marker} from 'leaflet'; +import {DataProvider} from '../data/data.provider'; +import {MapPosition, PositionService} from './position.service'; +import Timeout = NodeJS.Timeout; + +/** + * Provides methods for presenting the map + */ +@Injectable({ + providedIn: 'root', +}) +export class MapProvider { + /** + * Area to show when the map is initialized (shown for the first time) + */ + defaultPolygon: Polygon; + + /** + * Provide a point marker for a leaflet map + * + * @param point Point to get marker for + */ + static getPointMarker(point: Point) { + return marker(geoJSON(point).getBounds().getCenter(), { + icon: icon({ + // tslint:disable-next-line:no-magic-numbers + iconAnchor: [13, 41], + // tslint:disable-next-line:no-magic-numbers + iconSize: [25, 41], + iconUrl: '../assets/marker-icon.png', + shadowUrl: '../assets/marker-shadow.png', + }), + }); + } + + /** + * Provide a position marker for a leaflet map + * + * @param position Current position + * @param className CSS class name + * @param iconSize Size of the position icon + */ + static getPositionMarker( + position: MapPosition, + className: string, + iconSize: number, + ) { + return new Marker(new LatLng(position.latitude, position.longitude), { + icon: divIcon({ + className: className, + html: + typeof position.heading !== 'undefined' + ? ` + ` + : '', + iconSize: [iconSize, iconSize], + }), + zIndexOffset: 1000, + }); + } + + /** + * Fixes the issue of missing tiles when map renders before its container element + * + * @param map The initialized map + * @param element The element containing the map + * @param interval Interval to clear when map's appearance is corrected + */ + static invalidateWhenRendered = ( + map: Map, + element: ElementRef, + interval: Timeout, + ) => { + if (element.nativeElement.offsetWidth === 0) { + return; + } + + // map's container is ready + map.invalidateSize(); + // stop repeating when it's rendered and invalidateSize done + clearInterval(interval); + }; + + constructor( + private dataProvider: DataProvider, + private positionService: PositionService, + ) {} + + /** + * Provide the specific place by its UID + * + * @param uid UUID of the place to look for + */ + async searchPlace(uid: SCUuid): Promise { + const uidFilter: SCSearchFilter = { + arguments: { + field: 'uid', + value: uid, + }, + type: 'value', + }; + + return this.dataProvider.search({filter: uidFilter}); + } + + /** + * Provide places (buildings and canteens) + * + * @param contextFilter Additional contextual filter (e.g. from the context menu) + * @param queryText Query (text) of the search query + */ + async searchPlaces( + contextFilter?: SCSearchFilter, + queryText?: string, + ): Promise { + const buildingFilter: SCSearchFilter = { + arguments: { + field: 'type', + value: SCThingType.Building, + }, + type: 'value', + }; + + const mensaFilter: SCSearchFilter = { + arguments: { + filters: [ + { + arguments: { + field: 'categories', + value: 'canteen', + }, + type: 'value', + }, + { + arguments: { + field: 'categories', + value: 'student canteen', + }, + type: 'value', + }, + { + arguments: { + field: 'categories', + value: 'cafe', + }, + type: 'value', + }, + { + arguments: { + field: 'categories', + value: 'restaurant', + }, + type: 'value', + }, + ], + operation: 'or', + }, + type: 'boolean', + }; + + // initial filter for the places + const baseFilter: SCSearchFilter = { + arguments: { + operation: 'or', + filters: [buildingFilter, mensaFilter], + }, + type: 'boolean', + }; + + let filter = baseFilter; + + if (typeof contextFilter !== 'undefined') { + filter = { + arguments: { + operation: 'and', + filters: [baseFilter, contextFilter], + }, + type: 'boolean', + }; + } + + const query: SCSearchQuery = { + filter, + }; + + if (queryText && queryText.length > 0) { + query.query = queryText; + } + + if (this.positionService.position) { + query.sort = [ + { + type: 'distance', + order: 'asc', + arguments: { + field: 'geo.point.coordinates', + position: [ + this.positionService.position.longitude, + this.positionService.position.latitude, + ], + }, + }, + ]; + } + + return this.dataProvider.search(query); + } +} diff --git a/src/app/modules/map/page/map-page.component.ts b/src/app/modules/map/page/map-page.component.ts new file mode 100644 index 00000000..cad8e498 --- /dev/null +++ b/src/app/modules/map/page/map-page.component.ts @@ -0,0 +1,472 @@ +/* + * Copyright (C) 2019-2021 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +import {Location} from '@angular/common'; +import { + ChangeDetectorRef, + Component, + ElementRef, + ViewChild, +} from '@angular/core'; +import {ActivatedRoute, Router} from '@angular/router'; +import {AlertController, ModalController, Platform} from '@ionic/angular'; +import {TranslateService} from '@ngx-translate/core'; +import { + SCBuilding, + SCPlace, + SCRoom, + SCSearchFilter, + SCUuid, +} from '@openstapps/core'; +import { + featureGroup, + geoJSON, + LatLng, + Layer, + Map, + MapOptions, + Marker, + tileLayer, +} from 'leaflet'; +import {Subscription} from 'rxjs'; +import {DataRoutingService} from '../../data/data-routing.service'; +import {ContextMenuService} from '../../menu/context/context-menu.service'; +import {MapProvider} from '../map.provider'; +import { + LocationStatus, + MapPosition, + PositionService, +} from '../position.service'; +import {MapListModalComponent} from './modals/map-list-modal.component'; +import {MapSingleModalComponent} from './modals/map-single-modal.component'; +import Timeout = NodeJS.Timeout; + +/** + * The main page of the map + */ +@Component({ + styleUrls: ['./map-page.scss'], + templateUrl: './map-page.html', + providers: [ContextMenuService], +}) +export class MapPageComponent { + /** + * Default map zoom level + */ + DEFAULT_ZOOM = 16; + + /** + * Distance to the shown place + */ + distance?: number; + + /** + * Api query filter + */ + filterQuery?: SCSearchFilter; + + /** + * Places to show + */ + items: SCPlace[] = []; + + /** + * Leaflet (map) layers to show items on (not the position) + */ + layers: Layer[] = []; + + /** + * Location settings on the user's device + */ + locationStatus: LocationStatus = {enabled: undefined, allowed: undefined}; + + /** + * The leaflet map + */ + map: Map; + + /** + * Container element of the map + */ + @ViewChild('mapContainer') mapContainer: ElementRef; + + /** + * Map layers to show as marker clusters + */ + markerClusterData: Layer[] = []; + + /** + * Options how to show the marker clusters + */ + markerClusterOptions = { + // don't show rectangles containing the markers in a cluster + showCoverageOnHover: false, + }; + + /** + * Maximal map zoom level + */ + MAX_ZOOM = 18; + + /** + * Modal for additional information on places or for a list of places + */ + modal: HTMLIonModalElement; + + /** + * Options of the leaflet map + */ + options: MapOptions; + + /** + * Position of the user on the map + */ + position: MapPosition | null; + + /** + * Marker showing the position of the user on the map + */ + positionMarker: Marker; + + /** + * Search value from search bar + */ + queryText: string; + + /** + * Subscriptions used by the page + */ + subscriptions: Subscription[] = []; + + constructor( + private translateService: TranslateService, + private router: Router, + private mapProvider: MapProvider, + public location: Location, + private ref: ChangeDetectorRef, + private readonly contextMenuService: ContextMenuService, + private alertController: AlertController, + private route: ActivatedRoute, + private modalController: ModalController, + private dataRoutingService: DataRoutingService, + private positionService: PositionService, + private platform: Platform, + ) { + // initialize the options + this.options = { + center: geoJSON(this.mapProvider.defaultPolygon).getBounds().getCenter(), + layers: [ + tileLayer( + 'https://osm.server.uni-frankfurt.de/tiles/roads/x={x}&y={y}&z={z}', + { + attribution: + '© OpenStreetMap contributors', + maxZoom: this.MAX_ZOOM, + }, + ), + ], + zoom: this.DEFAULT_ZOOM, + zoomControl: false, + }; + } + + /** + * Animate to coordinates + * + * @param latLng Coordinates to animate to + */ + private focus(latLng?: LatLng) { + if (typeof latLng !== 'undefined') { + this.map.flyTo(latLng, this.MAX_ZOOM); + + return; + } + } + + /** + * Add places to the map + * + * @param items Places to add to the map + * @param clean Remove everything from the map first + * @param focus Animate to the item(s) + */ + addToMap(items: SCPlace[], clean = false, focus = false) { + if (clean) { + this.removeAll(); + } + const addSCPlace = (place: SCPlace): Layer | Marker => { + if (typeof place.geo.polygon !== 'undefined') { + const polygonLayer = geoJSON(place.geo.polygon, { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + bubblingMouseEvents: false, + }).getLayers()[0]; + + return polygonLayer.on('click', this.showItem.bind(this, place.uid)); + } + + const markerLayer = MapProvider.getPointMarker(place.geo.point); + + return markerLayer.on('click', this.showItem.bind(this, place.uid)); + }; + + items.map(thing => { + // IMPORTANT: change this to support inPlace.geo when there is a need to show floors (the building of the floor) + if (typeof thing.geo !== 'undefined') { + this.layers.push(addSCPlace(thing as SCPlace)); + } + }); + + this.markerClusterData = this.layers; + + if (!focus || this.items.length === 0) { + return; + } + + if (this.items.length === 1) { + this.focus(geoJSON(this.items[0].geo.point).getBounds().getCenter()); + + return; + } + + const groupedLayers = featureGroup(this.layers); + this.map.flyToBounds(groupedLayers.getBounds()); + } + + /** + * Fetches items with set query configuration + * + * @param animate Should the fly animation be used + */ + async fetchAndUpdateItems(animate?: boolean): Promise { + try { + const result = await this.mapProvider.searchPlaces( + this.filterQuery, + this.queryText, + ); + // override items with results + this.items = result.data as SCPlace[]; + this.addToMap(result.data as Array, true, animate); + // update filter options if result contains facets + if (typeof result.facets !== 'undefined') { + this.contextMenuService.updateContextFilter(result.facets); + } + } catch (error) { + const alert: HTMLIonAlertElement = await this.alertController.create({ + buttons: ['Dismiss'], + header: 'Error', + subHeader: error.message, + }); + + await alert.present(); + } + } + + /** + * Subscribe to needed observables and get the location status when user is entering the page + */ + async ionViewWillEnter() { + this.subscriptions.push( + this.dataRoutingService.itemSelectListener().subscribe(async item => { + // in case the list item is clicked + if (this.items.length > 1) { + await this.modal.dismiss(); + await this.showItem(item.uid); + } + }), + this.positionService.watchCurrentLocation({maximumAge: 3000}).subscribe({ + next: (position: MapPosition) => { + this.position = position; + this.positionMarker = MapProvider.getPositionMarker( + position, + 'stapps-device-location', + 30, + ); + }, + error: error => { + if (error.code === 1) { + this.locationStatus.allowed = false; + } + // eslint-disable-next-line unicorn/no-null + this.position = null; + }, + }), + ); + + // get detailed location status (diagnostics only supports devices) + if (this.platform.is('cordova')) { + this.locationStatus = await this.positionService.getLocationStatus(); + } + } + + /** + * Unsubscribe from all subscriptions when user leaves page + */ + ionViewWillLeave() { + for (const sub of this.subscriptions) { + sub.unsubscribe(); + } + } + + /** + * What happens when the leaflet map is ready (note: doesn't mean that tiles are loaded) + */ + async onMapReady(map: Map) { + this.map = map; + const interval: Timeout = setInterval(() => + MapProvider.invalidateWhenRendered(map, this.mapContainer, interval), + ); + + const uid = this.route.snapshot.paramMap.get('uid'); + const response = await (uid !== null + ? this.mapProvider.searchPlace(uid) + : this.mapProvider.searchPlaces()); + + if (response.data.length === 0) { + return; + } + + this.items = response.data as SCBuilding[]; + this.addToMap(this.items, true, uid !== null); + this.contextMenuService.updateContextFilter(response.facets); + + this.subscriptions.push( + this.contextMenuService.filterQueryChanged$.subscribe(query => { + this.filterQuery = query; + this.fetchAndUpdateItems(true); + }), + ); + + this.distance = this.positionService.getDistance(this.items[0].geo.point); + } + + /** + * What happens when position button is clicked + */ + async onPositionClick() { + if (this.position) { + this.focus(this.positionMarker.getLatLng()); + + return; + } + + this.translateService + .get(['map.page.geolocation', 'app.errors.UNKNOWN']) + .subscribe(async translations => { + const [location, unknownError] = [ + translations['map.page.geolocation'], + translations['app.errors.UNKNOWN'], + ]; + const {enabled, allowed} = this.locationStatus; + await ( + await this.alertController.create({ + header: location.TITLE, + subHeader: location.SUBTITLE, + message: `${ + enabled === false + ? location.NOT_ENABLED + : allowed === false + ? location.NOT_ALLOWED + : unknownError + }.`, + buttons: ['OK'], + }) + ).present(); + }); + } + + /** + * Remove all of the layers + */ + removeAll() { + for (const layer of this.layers) { + this.map.removeLayer(layer); + } + + this.layers = []; + } + + /** + * Resets the map = fetch all the items based on the filters (and go to component's base location) + */ + async resetView() { + this.location.go('/map'); + await this.fetchAndUpdateItems(); + + this.ref.detectChanges(); + } + + /** + * Search event of search bar + */ + searchStringChanged(queryText?: string) { + this.queryText = queryText || ''; + void this.fetchAndUpdateItems(true); + } + + /** + * Show an single place + * + * @param uid Uuid of the place + */ + async showItem(uid: SCUuid) { + const response = await this.mapProvider.searchPlace(uid); + this.items = response.data as SCPlace[]; + this.distance = this.positionService.getDistance(this.items[0].geo.point); + this.addToMap(this.items, true); + this.ref.detectChanges(); + const url = this.router.createUrlTree(['/map/', uid]).toString(); + this.location.go(url); + // center the selected place + this.focus(geoJSON(this.items[0].geo.point).getBounds().getCenter()); + } + + /** + * Show a single place + */ + async showItemModal(item: SCPlace) { + const placeWithoutGeo = {...item, geo: undefined}; + this.modal = await this.modalController.create({ + component: MapSingleModalComponent, + swipeToClose: true, + componentProps: { + item: placeWithoutGeo, + dismissAction: () => { + this.modal.dismiss(); + }, + }, + }); + + await this.modal.present(); + } + + /** + * Show the list of shown places + */ + async showListModal() { + this.modal = await this.modalController.create({ + component: MapListModalComponent, + swipeToClose: true, + componentProps: { + filterQuery: this.filterQuery, + queryText: this.queryText, + dismissAction: () => { + this.modal.dismiss(); + }, + }, + }); + + await this.modal.present(); + } +} diff --git a/src/app/modules/map/page/map-page.html b/src/app/modules/map/page/map-page.html new file mode 100644 index 00000000..d9dba5bb --- /dev/null +++ b/src/app/modules/map/page/map-page.html @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ +   {{ + 'map.page.buttons.SHOW_LIST' | translate + }} + + + + + + + +
+ +
+
+ +   {{ + 'map.page.buttons.SHOW_LIST' | translate + }} + + + + + + + +
+
diff --git a/src/app/modules/map/page/map-page.scss b/src/app/modules/map/page/map-page.scss new file mode 100644 index 00000000..85f7d912 --- /dev/null +++ b/src/app/modules/map/page/map-page.scss @@ -0,0 +1,119 @@ +ion-header { + ion-toolbar { + --background: transparent; + --ion-color-base: transparent; + + ion-buttons { + ion-back-button::part(native), ion-menu-button::part(native) { + box-shadow: var(--map-box-shadow); + //padding: 2px; + } + } + } + ion-searchbar { + --background: white; + // important for iOS + --box-shadow: var(--map-box-shadow); + } +} + +ion-content { + // fixes the unexpected issue that the content is not fullscreen (behind the header) + position: absolute; + div.map-container { + width: 100%; + height: 100%; + } +} + +ion-back-button, ion-menu-button { + --background: white; + --background-hover: whitesmoke; + --background-focused: whitesmoke; +} + +::ng-deep { + .stapps-location { + ion-icon { + color: #fd435c; + width: 100%; + height: 100%; + } + } + + .stapps-device-location { + ion-icon { + color: #4387fd; + width: 100%; + height: 100%; + } + } + + div.floating-content { + display: grid; + position: absolute; + bottom: 15px; + z-index: 1000; + width: 100%; + padding: 0 20px; + justify-content: center; + + div.map-buttons { + display: flex; + justify-content: flex-end; + } + + stapps-map-item { + width: 550px; + position: center; + justify-self: center; + margin: 2px; + } + } +} + +div.floating-buttons { + z-index: 1000; + position: absolute; + bottom: 15px; + right: 10px; +} + +div.map-buttons { + + ion-button { + margin: 4px; + // important for iOS + --box-shadow: var(--map-box-shadow); + align-self: flex-end; + } + + ion-button::part(native) { + background: white; + } + + ion-button::part(native):hover, ion-button::part(native):focus { + background: whitesmoke; + } +} + +div.map-buttons.above { + min-width: 70%; + display: none; +} + +@media (max-width: 667px) { + div.map-buttons.above { + display: flex; + } + div.floating-content { + justify-content: normal; + stapps-map-item { + width: 100%; + } + } + + div.map-buttons.floating-buttons { + display: none; + } +} diff --git a/src/app/modules/map/page/modals/map-list-modal.component.ts b/src/app/modules/map/page/modals/map-list-modal.component.ts new file mode 100644 index 00000000..b90a8625 --- /dev/null +++ b/src/app/modules/map/page/modals/map-list-modal.component.ts @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2021 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +import {Component, Input, OnInit} from '@angular/core'; +import {SCPlace, SCSearchFilter} from '@openstapps/core'; +import {MapProvider} from '../../map.provider'; + +/** + * Modal showing a provided list of places + */ +@Component({ + selector: 'map-list-modal', + templateUrl: 'map-list.html', + styleUrls: ['map-list.scss'], +}) +export class MapListModalComponent implements OnInit { + /** + * Action when close is pressed + */ + @Input() dismissAction: () => void; + + /** + * Used for creating the search for the shown list + */ + @Input() filterQuery?: SCSearchFilter; + + /** + * Places to show in the list + */ + items: SCPlace[]; + + /** + * Used for creating the search for the shown list + */ + @Input() queryText?: string; + + constructor(private mapProvider: MapProvider) {} + + /** + * Populate the list with the results from the search + */ + ngOnInit() { + this.mapProvider + .searchPlaces(this.filterQuery, this.queryText) + .then(result => { + this.items = result.data as SCPlace[]; + }); + } +} diff --git a/src/app/modules/map/page/modals/map-list.html b/src/app/modules/map/page/modals/map-list.html new file mode 100644 index 00000000..37563f8c --- /dev/null +++ b/src/app/modules/map/page/modals/map-list.html @@ -0,0 +1,13 @@ + + + {{ 'map.modals.list.TITLE' | translate }} + + {{ + 'app.ui.CLOSE' | translate + }} + + + + + + diff --git a/src/app/modules/map/page/modals/map-list.scss b/src/app/modules/map/page/modals/map-list.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/modules/map/page/modals/map-single-modal.component.ts b/src/app/modules/map/page/modals/map-single-modal.component.ts new file mode 100644 index 00000000..2fb44fb1 --- /dev/null +++ b/src/app/modules/map/page/modals/map-single-modal.component.ts @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2021 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +import {Component, Input} from '@angular/core'; +import {SCPlace} from '@openstapps/core'; + +@Component({ + selector: 'app-map-single-modal', + templateUrl: './map-single.html', + styleUrls: ['./map-single.scss'], +}) +export class MapSingleModalComponent { + /** + * Action when close is pressed + */ + @Input() dismissAction: () => void; + + /** + * The item to be shown + */ + @Input() item: SCPlace; +} diff --git a/src/app/modules/map/page/modals/map-single.html b/src/app/modules/map/page/modals/map-single.html new file mode 100644 index 00000000..7af7389b --- /dev/null +++ b/src/app/modules/map/page/modals/map-single.html @@ -0,0 +1,13 @@ + + + {{ 'map.modals.single.TITLE' | translate }} + + {{ + 'app.ui.CLOSE' | translate + }} + + + + + + diff --git a/src/app/modules/map/page/modals/map-single.scss b/src/app/modules/map/page/modals/map-single.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/modules/map/position.service.spec.ts b/src/app/modules/map/position.service.spec.ts new file mode 100644 index 00000000..eade654f --- /dev/null +++ b/src/app/modules/map/position.service.spec.ts @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2019-2021 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +import {TestBed} from '@angular/core/testing'; +import {MapModule} from './map.module'; +import {Geolocation, Geoposition} from '@ionic-native/geolocation/ngx'; +import {defer} from 'rxjs'; +import {HttpClientModule} from '@angular/common/http'; +import {StorageModule} from '../storage/storage.module'; +import {MapPosition, PositionService} from './position.service'; +import {Diagnostic} from '@ionic-native/diagnostic/ngx'; +import {ConfigProvider} from '../config/config.provider'; +import { + LoggerConfig, + LoggerModule, + NGXLogger, + NGXMapperService, +} from 'ngx-logger'; + +/** + * For faking a promise resolve + */ +function fakeAsyncResponse(data: T) { + return defer(() => Promise.resolve(data)); +} + +describe('PositionService', () => { + let geolocation: Geolocation; + let positionService: PositionService; + + const sampleMapPosition: MapPosition = { + heading: 123, + latitude: 34.12, + longitude: 12.34, + }; + const samplePosition: Geoposition = { + coords: { + ...sampleMapPosition, + accuracy: 1, + altitude: 123, + altitudeAccuracy: 1, + speed: 1, + }, + timestamp: 1_565_275_805_901, + } as Geoposition; + + beforeEach(async () => { + const configProvider = { + getValue: () => { + Promise.resolve(); + }, + }; + + TestBed.configureTestingModule({ + imports: [MapModule, HttpClientModule, StorageModule, LoggerModule], + providers: [ + Geolocation, + Diagnostic, + LoggerConfig, + NGXLogger, + NGXMapperService, + { + provider: ConfigProvider, + useValue: configProvider, + }, + ], + }); + positionService = TestBed.inject(PositionService); + geolocation = TestBed.inject(Geolocation); + spyOn(geolocation, 'getCurrentPosition').and.returnValue( + Promise.resolve(samplePosition), + ); + + spyOn(geolocation, 'watchPosition').and.callFake(() => { + return fakeAsyncResponse(samplePosition); + }); + }); + + it('should provide the current location of the device', async () => { + expect(await positionService.getCurrentLocation()).toEqual( + sampleMapPosition, + ); + }); + + it('should continuously provide (watch) location of the device', async () => { + expect( + positionService + .watchCurrentLocation() + .subscribe(result => expect(result).toEqual(sampleMapPosition)), + ); + }); +}); diff --git a/src/app/modules/map/position.service.ts b/src/app/modules/map/position.service.ts new file mode 100644 index 00000000..0a1e6166 --- /dev/null +++ b/src/app/modules/map/position.service.ts @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2021 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +import {Injectable} from '@angular/core'; +import {Diagnostic} from '@ionic-native/diagnostic/ngx'; +import { + Geolocation, + GeolocationOptions, + Geoposition, + PositionError, +} from '@ionic-native/geolocation/ngx'; +import {Point} from 'geojson'; +import {geoJSON, LatLng} from 'leaflet'; +import {Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; + +/** + * Check if provided position object is a position error + * + * @param position A position object to be checked + */ +function isPositionError( + position: Geoposition | PositionError, +): position is PositionError { + return ( + typeof (position as PositionError).code !== 'undefined' && + typeof (position as PositionError).message !== 'undefined' + ); +} + +export interface Coordinates { + /** + * Geographic latitude from a device + */ + latitude: number; + /** + * Geographic longitude from a device + */ + longitude: number; +} + +export interface MapPosition extends Coordinates { + /** + * Where is the device pointed + */ + heading?: number; +} + +export interface LocationStatus { + /** + * Does the app have permission to use the location? + */ + allowed: boolean | undefined; + /** + * Is location enabled in the OS + */ + enabled: boolean | undefined; +} + +@Injectable({ + providedIn: 'root', +}) +export class PositionService { + /** + * Current location status + */ + locationStatus: LocationStatus; + + /** + * Current position + */ + position?: MapPosition; + + constructor( + private geolocation: Geolocation, + private diagnostic: Diagnostic, + ) {} + + /** + * Gets current coordinates information of the device + * + * @param options Options which define which data should be provided (e.g. how accurate or how old) + */ + async getCurrentLocation(options?: GeolocationOptions): Promise { + const geoPosition = await this.geolocation.getCurrentPosition(options); + + this.position = { + heading: + Number.isNaN(geoPosition.coords.heading) || + geoPosition.coords.heading == undefined + ? undefined + : geoPosition.coords.heading, + latitude: geoPosition.coords.latitude, + longitude: geoPosition.coords.longitude, + }; + + return this.position; + } + + /** + * Provides distance from users position + * + * @param point Point to which distance should be calculated + */ + getDistance(point: Point): number | undefined { + if (typeof this.position === 'undefined') { + return undefined; + } + + return new LatLng( + this.position.latitude, + this.position.longitude, + ).distanceTo(geoJSON(point).getBounds().getCenter()); + } + + /** + * Provides the information about the availability of the location service (ONLY ON DEVICES / when cordova exists) + */ + async getLocationStatus(): Promise { + const enabled = await this.diagnostic.isLocationEnabled(); + const allowed = await this.diagnostic.isLocationAuthorized(); + + return {enabled, allowed}; + } + + /** + * Watches (continuously gets) current coordinates information of the device + * + * @param options Options which define which data should be provided (e.g. how accurate or how old) + */ + watchCurrentLocation(options?: GeolocationOptions): Observable { + return this.geolocation.watchPosition(options).pipe( + map(geoPosition => { + if (isPositionError(geoPosition)) { + throw geoPosition; + } + + this.position = { + heading: + Number.isNaN(geoPosition.coords.heading) || + geoPosition.coords.heading == undefined + ? undefined + : geoPosition.coords.heading, + latitude: geoPosition.coords.latitude, + longitude: geoPosition.coords.longitude, + }; + + return this.position; + }), + ); + } +} diff --git a/src/app/modules/map/widget/map-widget.component.ts b/src/app/modules/map/widget/map-widget.component.ts new file mode 100644 index 00000000..d575bdc6 --- /dev/null +++ b/src/app/modules/map/widget/map-widget.component.ts @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2020-2021 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +import {Component, ElementRef, Input, OnInit} from '@angular/core'; +import {SCPlace} from '@openstapps/core'; +import {geoJSON, Map, MapOptions, tileLayer} from 'leaflet'; +import {MapProvider} from '../map.provider'; +import Timeout = NodeJS.Timeout; + +/** + * The map widget (needs a container with explicit size) + */ +@Component({ + selector: 'stapps-map-widget', + styleUrls: ['./map-widget.scss'], + templateUrl: './map-widget.html', +}) +export class MapWidgetComponent implements OnInit { + /** + * A leaflet map showed + */ + map: Map; + + /** + * Options of the leaflet map + */ + options: MapOptions; + + /** + * A place to show on the map + */ + @Input() place: SCPlace; + + constructor(private element: ElementRef) {} + + /** + * Prepare the map + */ + ngOnInit() { + const markerLayer = MapProvider.getPointMarker(this.place.geo.point); + this.options = { + center: geoJSON(this.place.geo.polygon || this.place.geo.point) + .getBounds() + .getCenter(), + layers: [ + tileLayer( + 'https://osm.server.uni-frankfurt.de/tiles/roads/x={x}&y={y}&z={z}', + { + attribution: + '© OpenStreetMap contributors', + maxZoom: 18, + }, + ), + markerLayer, + ], + zoom: 16, + zoomControl: false, + }; + } + + /** + * What happens when the leaflet map is ready (note: doesn't mean that tiles are loaded) + */ + onMapReady(map: Map) { + this.map = map; + const interval: Timeout = setInterval(() => + MapProvider.invalidateWhenRendered(map, this.element, interval), + ); + } +} diff --git a/src/app/modules/map/widget/map-widget.html b/src/app/modules/map/widget/map-widget.html new file mode 100644 index 00000000..dcb86689 --- /dev/null +++ b/src/app/modules/map/widget/map-widget.html @@ -0,0 +1,16 @@ +
+
+ + + +
diff --git a/src/app/modules/map/widget/map-widget.scss b/src/app/modules/map/widget/map-widget.scss new file mode 100644 index 00000000..97624a3f --- /dev/null +++ b/src/app/modules/map/widget/map-widget.scss @@ -0,0 +1,12 @@ +div.map-container { + height: 100%; + width: 100%; + display: block; +} + +div.map-buttons { + position: absolute; + top: 10px; + right: 10px; + z-index: 10000; +} diff --git a/src/app/modules/menu/context/context-menu.component.ts b/src/app/modules/menu/context/context-menu.component.ts index c66066d5..75372401 100644 --- a/src/app/modules/menu/context/context-menu.component.ts +++ b/src/app/modules/menu/context/context-menu.component.ts @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, 2019, 2020 StApps + * Copyright (C) 2018-2021 StApps * This program is free software: you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the Free * Software Foundation, version 3. @@ -12,7 +12,7 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ -import {Component, OnDestroy} from '@angular/core'; +import {Component, Input, OnDestroy} from '@angular/core'; import {LangChangeEvent, TranslateService} from '@ngx-translate/core'; import { SCLanguage, @@ -38,6 +38,12 @@ import {FilterContext, SortContext, SortContextOption} from './context-type'; templateUrl: 'context-menu.html', }) export class ContextMenuComponent implements OnDestroy { + /** + * Id of the content the menu is used for + */ + @Input() + contentId: string; + /** * Amount of filter options shown on compact view */ @@ -145,7 +151,6 @@ export class ContextMenuComponent implements OnDestroy { for (const filterBucket of filterFacet.buckets) { filterBucket.checked = false; } - this.contextMenuService.contextFilterChanged(this.filterOption); }; diff --git a/src/app/modules/menu/context/context-menu.html b/src/app/modules/menu/context/context-menu.html index c01545bf..07121e35 100644 --- a/src/app/modules/menu/context/context-menu.html +++ b/src/app/modules/menu/context/context-menu.html @@ -1,4 +1,9 @@ - +

{{ 'menu.context.title' | translate | titlecase }}

diff --git a/src/app/modules/menu/context/context-menu.scss b/src/app/modules/menu/context/context-menu.scss deleted file mode 100644 index 97a0d932..00000000 --- a/src/app/modules/menu/context/context-menu.scss +++ /dev/null @@ -1,7 +0,0 @@ -stapps-navigation { - ion-radio { - .radio-icon { - - } - } -} diff --git a/src/app/modules/menu/context/context-menu.service.ts b/src/app/modules/menu/context/context-menu.service.ts index 08f00a82..48356174 100644 --- a/src/app/modules/menu/context/context-menu.service.ts +++ b/src/app/modules/menu/context/context-menu.service.ts @@ -1,5 +1,5 @@ /* - * Copyright (C) 2020 StApps + * Copyright (C) 2020-2021 StApps * This program is free software: you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the Free * Software Foundation, version 3. @@ -13,14 +13,92 @@ * this program. If not, see . */ import {Injectable} from '@angular/core'; -import {SCFacet, SCSearchFilter, SCSearchSort} from '@openstapps/core'; -import {Subject} from 'rxjs'; import { - FilterBucket, - FilterContext, - FilterFacet, - SortContext, -} from './context-type'; + SCFacet, + SCFacetBucket, + SCSearchFilter, + SCSearchSort, +} from '@openstapps/core'; +import {Subject} from 'rxjs'; + +export type ContextType = FilterContext | SortContext; + +/** + * A sort context + */ +interface SortContext { + /** + * Name of the context + */ + name: 'sort'; + + /** + * Reverse option + */ + reversed: boolean; + + /** + * sort value + */ + value: string; + + /** + * Sort options + */ + values: SortContextOption[]; +} + +/** + * A sort context option + */ +interface SortContextOption { + /** + * sort option is reversible + */ + reversible: boolean; + + /** + * sort option value + */ + value: string; +} + +/** + * A filter context + */ +interface FilterContext { + /** + * Compact view of the filter options + */ + compact?: boolean; + /** + * Name of the context + */ + name: 'filter'; + + /** + * Filter values + */ + options: FilterFacet[]; +} + +interface FilterFacet extends SCFacet { + /** + * FilterBuckets of a FilterFacet + */ + buckets: FilterBucket[]; + /** + * Compact view of the option buckets + */ + compact?: boolean; +} + +interface FilterBucket extends SCFacetBucket { + /** + * Sets the Filter active + */ + checked: boolean; +} /** * ContextMenuService provides bidirectional communication of context menu options and search queries @@ -35,13 +113,13 @@ export class ContextMenuService { /** * Container for the filter context */ - // eslint-disable-next-line @typescript-eslint/member-ordering + // tslint:disable-next-line:member-ordering filterOptions = new Subject(); /** * Observable filterContext streams */ - // eslint-disable-next-line @typescript-eslint/member-ordering + // tslint:disable-next-line:member-ordering filterContextChanged$ = this.filterOptions.asObservable(); /** @@ -52,19 +130,19 @@ export class ContextMenuService { /** * Observable filterContext streams */ - // eslint-disable-next-line @typescript-eslint/member-ordering + // tslint:disable-next-line:member-ordering filterQueryChanged$ = this.filterQuery.asObservable(); /** * Container for the sort context */ - // eslint-disable-next-line @typescript-eslint/member-ordering + // tslint:disable-next-line:member-ordering sortOptions = new Subject(); /** * Observable SortContext streams */ - // eslint-disable-next-line @typescript-eslint/member-ordering + // tslint:disable-next-line:member-ordering sortContextChanged$ = this.sortOptions.asObservable(); /** @@ -75,7 +153,7 @@ export class ContextMenuService { /** * Observable SortContext streams */ - // eslint-disable-next-line @typescript-eslint/member-ordering + // tslint:disable-next-line:member-ordering sortQueryChanged$ = this.sortQuery.asObservable(); /** diff --git a/src/assets/custom-ionicons/navigate-straight.svg b/src/assets/custom-ionicons/navigate-straight.svg new file mode 100644 index 00000000..b4d932c9 --- /dev/null +++ b/src/assets/custom-ionicons/navigate-straight.svg @@ -0,0 +1,56 @@ + + + + + + image/svg+xml + + + + + + + Navigate + + diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json index e368feee..00d04034 100644 --- a/src/assets/i18n/de.json +++ b/src/assets/i18n/de.json @@ -1,4 +1,12 @@ { + "app": { + "ui": { + "CLOSE": "Schließen" + }, + "errors": { + "UNKNOWN": "Unbekannter Fehler" + } + }, "data": { "REFRESH_ACTION": "Aktualisieren", "REFRESHING": "Aktualisierung läuft...", @@ -60,8 +68,31 @@ } } }, - "news": { - "title": "Aktuelles" + "map": { + "page": { + "TITLE": "Karte", + "search": { + "PLACEHOLDER": "Finde Gebäude, Points of Interest, Mensen und Cafés ..." + }, + "buttons": { + "SHOW_LIST": "Liste ansehen", + "MORE": "Mehr" + }, + "geolocation": { + "TITLE": "Standort", + "SUBTITLE": "Standort nicht erreichbar", + "NOT_ENABLED": "Standortermittlung auf Deinem Gerät ist nicht aktiviert", + "NOT_ALLOWED": "Zugriff auf den Standort für die App nicht zugelassen" + } + }, + "modals": { + "single": { + "TITLE": "Angezeigter Ort" + }, + "list": { + "TITLE": "Angezeigte Orte" + } + } }, "menu": { "context": { @@ -81,6 +112,9 @@ "settings": "Einstellungen" } }, + "news": { + "title": "Aktuelles" + }, "search": { "nothing_found": "Keine Ergebnisse" }, diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 4f231a39..9b120bde 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1,4 +1,12 @@ { + "app": { + "ui": { + "CLOSE": "Close" + }, + "errors": { + "UNKNOWN": "Unknown problem" + } + }, "data": { "REFRESH_ACTION": "Refresh", "REFRESHING": "Refreshing...", @@ -60,8 +68,31 @@ } } }, - "news": { - "title": "News" + "map": { + "page": { + "TITLE": "Map", + "search": { + "PLACEHOLDER": "Find buildings, points of interests, canteens and cafes ..." + }, + "buttons": { + "SHOW_LIST": "Show list", + "MORE": "More" + }, + "geolocation": { + "TITLE": "Location", + "SUBTITLE": "Location not available", + "NOT_ENABLED": "Location service is not enabled on your device", + "NOT_ALLOWED": "The app is not allowed to access your location" + } + }, + "modals": { + "single": { + "TITLE": "Place shown" + }, + "list": { + "TITLE": "Places shown" + } + } }, "menu": { "context": { @@ -81,6 +112,9 @@ "settings": "settings" } }, + "news": { + "title": "News" + }, "search": { "nothing_found": "No results" }, diff --git a/src/theme/variables.scss b/src/theme/variables.scss index b50dcc8e..89fc398d 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -78,4 +78,5 @@ /** StApps **/ --placeholder-gray: #F1F0ED; /** Change the colors of the toolbar and the toolbar text here **/ + --map-box-shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.2), 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12); }