Initial commit

This commit is contained in:
Norbert Schmidt
2023-01-02 09:30:17 +01:00
parent ef89af1dda
commit 3b3353fff1
316 changed files with 7522 additions and 1 deletions

View File

@@ -0,0 +1,88 @@
import { NgModule } from '@angular/core';
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';
import {redirectUnauthorizedTo, redirectLoggedInTo, canActivate } from '@angular/fire/auth-guard';
const redirectUnauthorizedToLogin = () => redirectUnauthorizedTo(['login']);
const redirectLoggedInToProfile = () => redirectLoggedInTo(['profile']);
const routes: Routes = [
{
path: 'home',
loadChildren: () => import('./home/home.module').then( m => m.HomePageModule),
},
{
path: '',
redirectTo: 'home',
pathMatch: 'full'
},
{
path: 'login',
loadChildren: () => import('./auth/login/login.module').then( m => m.LoginPageModule),
...canActivate(redirectLoggedInToProfile)
},
{
path: 'resetpw',
loadChildren: () => import('./auth/resetpw/resetpw.module').then( m => m.ResetpwPageModule)
},
{
path: 'profile',
loadChildren: () => import('./auth/profile/profile.module').then( m => m.ProfilePageModule),
...canActivate(redirectUnauthorizedToLogin)
},
{
path: 'signup',
loadChildren: () => import('./auth/signup/signup.module').then( m => m.SignupPageModule)
},
{
path: 'observe',
loadChildren: () => import('./observe/observe.module').then( m => m.MeasurePageModule)
},
{
path: 'options',
loadChildren: () => import('./options/options.module').then( m => m.OptionsPageModule)
},
{
path: 'plantnet',
loadChildren: () => import('./mobisplugins/plantnet/plantnet.module').then( m => m.PlantnetPageModule)
},
{
path: 'ispex',
loadChildren: () => import('./mobisplugins/ispex/ispex.module').then( m => m.IspexPageModule)
},
{
path: 'minisecchi',
loadChildren: () => import('./mobisplugins/minisecchi/minisecchi.module').then( m => m.MinisecchiPageModule)
},
{
path: 'canairiopm25',
loadChildren: () => import('./mobisplugins/canairiopm25/canairiopm25.module').then( m => m.Canairiopm25PageModule)
},
{
path: 'canairioco2',
loadChildren: () => import('./mobisplugins/canairioco2/canairioco2.module').then( m => m.Canairioco2PageModule)
},
{
path: 'ms_measure',
loadChildren: () => import('./mobisplugins/minisecchi/measure/measure.module').then( m => m.MeasurePageModule)
},
{
path: 'ms_instructions',
loadChildren: () => import('./mobisplugins/minisecchi/instructions/instructions.module').then( m => m.InstructionsPageModule)
}
];
@NgModule({
imports: [
RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })
],
exports: [RouterModule]
})
export class AppRoutingModule { }

View File

@@ -0,0 +1,60 @@
<ion-app>
<ion-menu side="start" contentId="main">
<ion-menu-toggle>
<ion-header color="primary">
<ion-toolbar>
<ion-item lines="none">
<ion-label routerLink="home">M O B I S</ion-label>
<ion-buttons>
<ion-button type="icon-only" (click)="closeMenu()"><ion-icon name="close-outline" color="dark"></ion-icon></ion-button>
</ion-buttons>
</ion-item>
</ion-toolbar>
</ion-header>
</ion-menu-toggle>
<ion-content>
<ion-list>
<ion-list-header>
<ion-label>{{ "menu.account" | translate }}</ion-label>
</ion-list-header>
<ion-menu-toggle>
<ion-item button routerLink="signup" [disabled]="user">
<ion-icon name="person-add-outline"></ion-icon>
<ion-label>{{ "menu.signup" | translate }}</ion-label>
</ion-item>
</ion-menu-toggle>
<ion-menu-toggle>
<ion-item button routerLink="login" [disabled]="user">
<ion-icon name="log-in-outline"></ion-icon>
<ion-label>{{ "menu.signin" | translate }}</ion-label>
</ion-item>
</ion-menu-toggle>
<ion-menu-toggle>
<ion-item button routerLink="profile" [disabled]="!user">
<ion-icon name="person-circle-outline"></ion-icon>
<ion-label>{{ "menu.profile" | translate }}</ion-label>
</ion-item>
</ion-menu-toggle>
<ion-menu-toggle>
<ion-item button (click)="logout()" [disabled]="!user">
<ion-icon name="log-out-outline"></ion-icon>
<ion-label>{{ "menu.signout" | translate }}</ion-label>
</ion-item>
</ion-menu-toggle>
<ion-list-header>
<ion-label>{{ "menu.settings" | translate }}</ion-label>
</ion-list-header>
<ion-menu-toggle>
<ion-item button routerLink="options">
<ion-icon name="settings-outline"></ion-icon>
<ion-label>{{ "menu.options" | translate }}</ion-label>
</ion-item>
</ion-menu-toggle>
</ion-list>
</ion-content>
</ion-menu>
<ion-router-outlet id="main"></ion-router-outlet>
</ion-app>

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,23 @@
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { TestBed, waitForAsync } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [AppComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).compileComponents();
}));
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
});
// TODO: add more tests!
});

53
src/app/app.component.ts Normal file
View File

@@ -0,0 +1,53 @@
import { Component, OnInit } from '@angular/core';
import { Auth, user } from '@angular/fire/auth';
import { Router } from '@angular/router';
import { MenuController } from '@ionic/angular';
import { TranslateService } from '@ngx-translate/core';
import { AuthService } from './services/auth.service';
import { Storage } from '@ionic/storage-angular';
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
styleUrls: ['app.component.scss'],
})
export class AppComponent implements OnInit{
user = null;
language= '';
constructor(
private menuCtrl: MenuController,
private authService: AuthService,
private router: Router,
private afAuth: Auth,
private translateService: TranslateService,
private storage: Storage
) {
user(this.afAuth).subscribe((response) => {
//fill the user to verify if someone is logged in
this.user = response;
});
}
async ngOnInit() {
this.storage.create();
this.translateService.setDefaultLang('en'); //set default language English
this.language = await this.storage.get('language');
if (this.language === null){
console.log('no value for language in storage, trying getting from browser');
this.language = this.translateService.getBrowserLang();
//if
}
this.translateService.use(this.language);
}
closeMenu() {
this.menuCtrl.close();
}
async logout() {
await this.authService.logout();
this.router.navigateByUrl('/home', { replaceUrl: true });
}
}

41
src/app/app.module.ts Normal file
View File

@@ -0,0 +1,41 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router';
import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
import { initializeApp, provideFirebaseApp } from '@angular/fire/app';
import { environment } from '../environments/environment';
import { provideAuth, getAuth } from '@angular/fire/auth';
import { provideFirestore, getFirestore } from '@angular/fire/firestore';
import { provideStorage, getStorage } from '@angular/fire/storage';
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';
import { HttpClient, HttpClientModule } from '@angular/common/http';
import { IonicStorageModule } from '@ionic/storage-angular';
//define loader to load local files(on given path) with HttpClient
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
export function createTranslateLoader(http: HttpClient) {
return new TranslateHttpLoader(http, './assets/languages/', '.json');
}
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
IonicModule.forRoot(),
AppRoutingModule,
HttpClientModule,
IonicStorageModule.forRoot(),
TranslateModule.forRoot({loader: {provide: TranslateLoader, useFactory: (createTranslateLoader), deps: [HttpClient]}}),
provideFirebaseApp(() => initializeApp(environment.firebase)),
provideAuth(() => getAuth()),
provideFirestore(() => getFirestore()),
provideStorage(() => getStorage()),
],
providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
bootstrap: [AppComponent],
})
export class AppModule {}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { LoginPage } from './login.page';
const routes: Routes = [
{
path: '',
component: LoginPage
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class LoginPageRoutingModule {}

View File

@@ -0,0 +1,23 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { LoginPageRoutingModule } from './login-routing.module';
import { LoginPage } from './login.page';
import { TranslateModule } from '@ngx-translate/core';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
ReactiveFormsModule,
LoginPageRoutingModule,
TranslateModule
],
declarations: [LoginPage]
})
export class LoginPageModule {}

View File

@@ -0,0 +1,52 @@
<ion-header class="ion-no-border">
<ion-toolbar color="primary">
<ion-buttons slot="start">
<ion-back-button defaultHref="home" icon="chevron-back-outline"></ion-back-button>
</ion-buttons>
<ion-title class="ion-text-center">{{ "loginPage.title" | translate}}</ion-title>
<ion-buttons slot="end">
<ion-menu-button></ion-menu-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-card>
<ion-card-content>
<ion-grid>
<ion-row>
<ion-col sizeSm="6" offsetSm="3" sizeLg="4" offsetLg="4" sizeMd="6" offsetMd="3" *ngIf="showForm" class="ion-text-center">
<form (ngSubmit)="login()" [formGroup]="credentials">
<ion-item fill="solid" class="ion-margin-bottom">
<ion-input type="email" [placeholder]="'loginPage.email-placeholder' | translate" formControlName="email"><ion-icon name="mail-outline"></ion-icon></ion-input>
<ion-icon name="person-outline" slot=“end” align-self-center></ion-icon>
<ion-note slot="error" *ngIf="(email.dirty || email.touched) && email.errors">{{ "loginPage.email-invalid" | translate }}</ion-note>
</ion-item>
<ion-item fill="solid" class="ion-margin-bottom">
<ion-input type="password" [placeholder]="'loginPage.password-placeholder' | translate" formControlName="password"><ion-icon name="key-outline"></ion-icon></ion-input>
<ion-note slot="error" *ngIf="(password.dirty || password.touched) && password.errors">{{ "loginPage.password-invalid" | translate }}</ion-note>
</ion-item>
<ion-button type="submit" expand="block" [disabled]="!credentials.valid">{{ "loginPage.login-button" | translate }}</ion-button>
<a [routerLink]="['/resetpw']">{{ "loginPage.forgot-password" | translate }}</a>
</form>
</ion-col>
</ion-row>
<ion-row>
<ion-col class="ion-text-center" sizeSm="6" offsetSm="3" sizeLg="4" offsetLg="4" sizeMd="6" offsetMd="3">
<ion-button type="button" (click)="toggleForm()" expand="block"><ion-icon name="person-outline"></ion-icon>{{ "loginPage.login-username" | translate }}</ion-button>
<ion-button type="button" (click)="anonymousLogin()" expand="block"><ion-icon name="eye-outline"></ion-icon>{{ "loginPage.login-anonymous" | translate }}</ion-button>
<ion-button type="button" (click)="loginWithGoogle()" expand="block" color="danger"><ion-icon name="logo-google"></ion-icon>{{ "loginPage.login-google" | translate }}</ion-button>
<ion-button type="button" (click)="loginWithApple()" expand="block" color="dark"><ion-icon name="logo-apple"></ion-icon>{{ "loginPage.login-apple" | translate }}</ion-button>
<a href="signup">{{ "loginPage.goto-signup" | translate }}</a>
<br>
</ion-col>
</ion-row>
</ion-grid>
</ion-card-content>
</ion-card>
</ion-content>

View File

@@ -0,0 +1,11 @@
ion-input ion-icon{
padding-right: 15px;
}
ion-button {
border: 1px solid var(--ion-color-grey);
border-radius: 16px;
--color-hover: var(--ion-color-primary)
}

View File

@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { LoginPage } from './login.page';
describe('LoginPage', () => {
let component: LoginPage;
let fixture: ComponentFixture<LoginPage>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ LoginPage ],
imports: [IonicModule.forRoot()]
}).compileComponents();
fixture = TestBed.createComponent(LoginPage);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,121 @@
import { Component, OnInit } from '@angular/core';
import { Auth, User } from '@angular/fire/auth';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { AlertController, LoadingController } from '@ionic/angular';
import { TranslateService } from '@ngx-translate/core';
import { AuthService } from '../../services/auth.service';
@Component({
selector: 'app-login',
templateUrl: './login.page.html',
styleUrls: ['./login.page.scss'],
})
export class LoginPage implements OnInit {
credentials: FormGroup;
user: User | null = null;
showForm = false;
constructor(
private fb: FormBuilder,
private loadingController: LoadingController,
private alertController: AlertController,
private router: Router,
private authService: AuthService,
private afAuth: Auth,
private translateService: TranslateService
) {
this.afAuth.onAuthStateChanged((userState) => {
this.user = userState;
});
}
// Easy access for form fields
get email() {
return this.credentials.get('email');
}
get password() {
return this.credentials.get('password');
}
ngOnInit() {
this.credentials = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(6)]],
});
}
async login() {
const loading = await this.loadingController.create({
backdropDismiss: true,
message: this.translateService.instant('loginPage.logging-in-with-email'),
duration: 5000,
});
await loading.present();
const user = await this.authService.login(this.credentials.value);
await loading.dismiss();
if (user) {
this.router.navigateByUrl('/profile', { replaceUrl: true });
} else {
this.showAlert(this.translateService.instant('loginPage.login-failed'), this.translateService.instant('loginPage.please-try-again'));
}
}
async showAlert(header, message) {
const alert = await this.alertController.create({
header,
message,
buttons: ['OK'],
});
await alert.present();
}
async loginWithGoogle() {
const loading = await this.loadingController.create({
backdropDismiss: true,
message: this.translateService.instant('loginPage.logging-in-with-google'),
duration: 5000,
});
await loading.present();
this.authService
.loginWithGoogle()
.then(() => this.router.navigate(['/profile']))
.catch((e) => console.log(e.message));
await loading.dismiss();
}
async loginWithApple() {
const loading = await this.loadingController.create({
backdropDismiss: true,
message: this.translateService.instant('loginPage.logging-in-with-apple'),
duration: 5000,
});
await loading.present();
this.authService
.loginWithApple()
.then(() => this.router.navigate(['/profile']))
.catch((e) => console.log(e.message));
await loading.dismiss();
}
async anonymousLogin() {
const loading = await this.loadingController.create({
backdropDismiss: true,
message: this.translateService.instant('loginPage.logging-in-anonymously'),
duration: 5000,
});
await loading.present();
this.authService
.anonymousLogin()
.then(() => this.router.navigate(['/profile']))
.catch((e) => console.log(e.message));
await loading.dismiss();
}
toggleForm() {
this.showForm = !this.showForm;
}
}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ProfilePage } from './profile.page';
const routes: Routes = [
{
path: '',
component: ProfilePage
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class ProfilePageRoutingModule {}

View File

@@ -0,0 +1,22 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { ProfilePageRoutingModule } from './profile-routing.module';
import { ProfilePage } from './profile.page';
import { TranslateModule } from '@ngx-translate/core';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
ProfilePageRoutingModule,
TranslateModule
],
declarations: [ProfilePage]
})
export class ProfilePageModule {}

View File

@@ -0,0 +1,110 @@
<ion-header class="ion-no-border">
<ion-toolbar color="primary">
<ion-buttons slot="start">
<ion-back-button defaultHref="home" icon="chevron-back-outline"></ion-back-button>
</ion-buttons>
<ion-title class="ion-text-center">{{ "profilePage.title" | translate }}</ion-title>
<ion-buttons slot="end">
<ion-menu-button></ion-menu-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content *ngIf="user !== null">
<ion-card *ngIf="!isAnonymous && user !== null">
<ion-card-content>
<ion-grid>
<ion-row>
<div class="preview">
<ion-avatar (click)="changeImage()">
<img
*ngIf="photoURL; else placeholder_avatar;"
[src]="photoURL"
referrerpolicy="no-referrer"
/>
<ng-template #placeholder_avatar>
<div class="fallback">
<p>{{ "profilePage.select avatar" | translate }}</p>
</div>
</ng-template>
</ion-avatar>
</div>
</ion-row>
</ion-grid>
<ion-grid *ngIf="!isAnonymous && user !== null">
<ion-row>
<ion-col>
<span class="text"><ion-label class="title">{{ "profilePage.name" | translate }}</ion-label></span>
<span title="Click to change" *ngIf="!editDisplayName" (click)="editDisplayNameField()">
<ion-icon class="icon-edit" name="create-outline" ></ion-icon>
</span>
<span title="Save" *ngIf="editDisplayName" (click)="updateUserName(displayName)">
<ion-icon class="icon-edit" name="save-outline"></ion-icon>
</span>
</ion-col>
</ion-row>
<ion-row>
<ion-col><ion-input class="ion-no-padding" [(ngModel)]="displayName" [readonly]="!editDisplayName" [placeholder]="displayName"></ion-input>
</ion-col></ion-row>
<ion-row>
<ion-col>
<span class="text"><ion-label class="title">Email</ion-label></span>
<span title="Verified"
><ion-icon
class="icon-verified"
name="checkmark-circle-outline"
*ngIf="emailVerified"
></ion-icon
></span>
<span title="Unverified"
><ion-icon
class="icon-not-verified"
name="close-circle-outline"
*ngIf="!emailVerified"
></ion-icon
></span>
</ion-col>
</ion-row>
<ion-row>
<ion-col>
<ion-label class="text">{{email}}</ion-label>
</ion-col>
<ion-button
size="small"
(click)="verifyEmail()"
*ngIf="(!emailVerified) && (email !== null)"
>{{ "profilePage.verify email" | translate }}</ion-button>
</ion-row>
</ion-grid>
<ion-grid *ngIf="!emailVerified && !isAnonymous && user !== null">
<ion-row >
<ion-col><b>
{{ "profilePage.unverified email" | translate }}</b>
</ion-col>
</ion-row>
<ion-row>
<ion-col>
{{ "profilePage.unverified email text" | translate }}
</ion-col>
</ion-row>
</ion-grid>
</ion-card-content>
</ion-card>
<ion-card *ngIf="isAnonymous">
<ion-card-header>{{ "profilePage.anonymous login" | translate }}
</ion-card-header>
<ion-card-content
>{{ "profilePage.anonymous login text" | translate }}
</ion-card-content>
</ion-card>
</ion-content>
<ion-content *ngIf="user === null">
<ion-card>
<ion-card-header>{{ "profilePage.logged out" | translate }}</ion-card-header>
<ion-card-content>Please <a routerLink="['/login']">login</a> to view your profile</ion-card-content>
</ion-card>
</ion-content>

View File

@@ -0,0 +1,116 @@
#container {
text-align: center;
position: absolute;
left: 0;
right: 0;
top: 50%;
transform: translateY(-50%);
}
#container strong {
font-size: 20px;
line-height: 26px;
}
#container p {
font-size: 16px;
line-height: 22px;
color: #8c8c8c;
margin: 0;
}
#container a {
text-decoration: none;
}
ion-avatar {
width: 128px;
height: 128px;
}
.preview {
//margin-top: 50px;
display: flex;
justify-content: center;
}
.fallback {
width: 128px;
height: 128px;
border-radius: 50%;
background: #bfbfbf;
display: flex;
justify-content: center;
align-items: center;
font-weight: 500;
}
ion-label.title{
font-weight: bold;
//border: 1px solid black;
}
ion-input{
--margin: 0px;
--padding: 0px;
//border: 1px solid black;
}
ion-col{
text-align: start;
vertical-align: middle;
//border: 1px solid black;
padding: 3px;
}
ion-col ion-button{
vertical-align: baseline;
}
ion-icon {
pointer-events: none;
//border: 1px solid black;
}
ion-card{
padding: 0px;
margin: 2px;
ion-card-header{
font-weight: bold;
}
ion-card-content{
font-size: 12px;
}
}
.icon-edit{
display: inline-block;
font-size: 20px;
vertical-align: middle;
}
.icon-verified {
display: inline-block;
font-size: 20px;
color: #1a7900;
vertical-align: middle;
}
.icon-not-verified {
display: inline-block;
font-size: 20px;
color: #790000;
vertical-align: middle;
}
.text{
display: inline-block;
vertical-align: middle;
}

View File

@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { ProfilePage } from './profile.page';
describe('ProfilePage', () => {
let component: ProfilePage;
let fixture: ComponentFixture<ProfilePage>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ ProfilePage ],
imports: [IonicModule.forRoot()]
}).compileComponents();
fixture = TestBed.createComponent(ProfilePage);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,125 @@
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { AlertController, LoadingController } from '@ionic/angular';
import { AuthService } from '../../services/auth.service';
import { user, Auth } from '@angular/fire/auth';
import { AvatarService } from '../../services/avatar.service';
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
import { TranslateService } from '@ngx-translate/core';
@Component({
selector: 'app-profile',
templateUrl: 'profile.page.html',
styleUrls: ['profile.page.scss'],
})
export class ProfilePage implements OnInit {
profile = null;
user = null;
photoURL: string = null;
displayName: string = null;
email: string = null;
emailVerified = false;
isAnonymous: boolean = null;
editDisplayName = false;
constructor(
private authService: AuthService,
private router: Router,
private avatarService: AvatarService,
private loadingController: LoadingController,
private alertController: AlertController,
private afAuth: Auth,
private translateService: TranslateService
) {
// not the nicest way, sort of duplicate to have a URL in the profile variable and also check later the user variable
this.avatarService.getUserProfile().subscribe((data) => {
if (data) {
this.profile = data;
if (this.profile.imageUrl !== null) {
this.photoURL = this.profile.imageUrl;
}
}
//console.log('data' , data);
});
user(this.afAuth).subscribe((response) => {
//fill the user to verify if someone is logged in
this.user = response;
if (response !== null) {
console.log(response);
this.displayName = response.displayName;
this.email = response.email;
this.photoURL = response.photoURL;
this.emailVerified = response.emailVerified;
this.isAnonymous = response.isAnonymous;
} else {
this.displayName = null;
this.email = null;
this.photoURL = null;
this.emailVerified = false;
this.isAnonymous = null;
}
});
}
ngOnInit(): void {}
editDisplayNameField() {
this.editDisplayName = true;
}
updateUserName(name: string) {
this.authService.updateUserName(name);
this.editDisplayName = false;
}
verifyEmail() {
this.authService.sendVerificationMail();
this.showAlert(
this.translateService.instant('profilePage.verification-mail-sent'),
this.translateService.instant('profilePage.check-also-spam')
);
}
async showAlert(header, message) {
const alert = await this.alertController.create({
header,
message,
buttons: ['OK'],
});
await alert.present();
}
async logout() {
await this.authService.logout();
this.router.navigateByUrl('/home', { replaceUrl: true });
}
async changeImage() {
const image = await Camera.getPhoto({
quality: 90,
allowEditing: false,
resultType: CameraResultType.Base64,
source: CameraSource.Photos, // Camera, Photos or Prompt!
});
if (image) {
const loading = await this.loadingController.create({
message: this.translateService.instant('profilePage.processing-image'),
});
await loading.present();
const result = await this.avatarService.uploadImage(image);
window.location.reload(); //ugly to force the image to reload after upload
loading.dismiss();
if (!result) {
const alert = await this.alertController.create({
header: this.translateService.instant('profilePage.upload-failed'),
message: this.translateService.instant('profilePage.problem-uploading-avatar'),
buttons: ['OK'],
});
await alert.present();
}
}
}
}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ResetpwPage } from './resetpw.page';
const routes: Routes = [
{
path: '',
component: ResetpwPage
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class ResetpwPageRoutingModule {}

View File

@@ -0,0 +1,21 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule,ReactiveFormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { ResetpwPageRoutingModule } from './resetpw-routing.module';
import { ResetpwPage } from './resetpw.page';
import { TranslateModule } from '@ngx-translate/core';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
ResetpwPageRoutingModule, ReactiveFormsModule, TranslateModule
],
declarations: [ResetpwPage]
})
export class ResetpwPageModule {}

View File

@@ -0,0 +1,26 @@
<ion-header>
<ion-toolbar color="primary">
<ion-title>{{ "resetpwPage.title" | translate }}</ion-title>
<ion-buttons slot="start">
<ion-back-button defaultHref="/"></ion-back-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-grid>
<ion-row>
<ion-col sizeSm="10" offsetSm="1" sizeLg="6" offsetLg="3" sizeMd="8" offsetMd="2">
<form (ngSubmit)="resetPw()" [formGroup]="frmPasswordReset">
<ion-item fill="solid" class="ion-margin-bottom">
<ion-input type="email" [placeholder]="'resetpwPage.email-placeholder' | translate" formControlName="email"></ion-input>
<ion-note slot="error" *ngIf="(email.dirty || email.touched) && email.errors">{{ "resetpwPage.email-invalid" | translate }}</ion-note>
</ion-item>
<ion-button type="submit" expand="block" [disabled]="!frmPasswordReset.valid">{{ "resetpwPage.send-email-button" | translate }}</ion-button>
</form>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>

View File

View File

@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { ResetpwPage } from './resetpw.page';
describe('ResetpwPage', () => {
let component: ResetpwPage;
let fixture: ComponentFixture<ResetpwPage>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ ResetpwPage ],
imports: [IonicModule.forRoot()]
}).compileComponents();
fixture = TestBed.createComponent(ResetpwPage);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,57 @@
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { ToastController } from '@ionic/angular';
import { TranslateService } from '@ngx-translate/core';
import { AuthService } from '../../services/auth.service';
@Component({
selector: 'app-resetpw',
templateUrl: './resetpw.page.html',
styleUrls: ['./resetpw.page.scss'],
})
export class ResetpwPage implements OnInit {
frmPasswordReset: FormGroup;
password = '';
error = '';
constructor(
private fb: FormBuilder,
private toastController: ToastController,
private router: Router,
private authService: AuthService,
private translateService: TranslateService
) {}
get email() {
return this.frmPasswordReset.get('email');
}
ngOnInit() {
this.frmPasswordReset = this.fb.group({
email: [null, [Validators.required, Validators.email]],
});
}
resetPw() {
this.authService
.sendPasswordResetEmail(this.email.value)
.then((data) => {
this.presentToast(this.translateService.instant('resetpwPage.password-reset-email-sent'), 'bottom', 2000);
this.router.navigateByUrl('/');
})
.catch((err) => {
console.log(` failed ${err}`);
this.error = err.message;
});
}
async presentToast(message: string, position, duration: number) {
const toast = await this.toastController.create({
message,
duration,
position,
});
toast.present();
}
}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { SignupPage } from './signup.page';
const routes: Routes = [
{
path: '',
component: SignupPage
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class SignupPageRoutingModule {}

View File

@@ -0,0 +1,23 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { SignupPageRoutingModule } from './signup-routing.module';
import { SignupPage } from './signup.page';
import { TranslateModule } from '@ngx-translate/core';
@NgModule({
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
IonicModule,
SignupPageRoutingModule,
TranslateModule
],
declarations: [SignupPage]
})
export class SignupPageModule {}

View File

@@ -0,0 +1,48 @@
<ion-header class="ion-no-border">
<ion-toolbar color="primary">
<ion-buttons slot="start">
<ion-back-button defaultHref="home" icon="chevron-back-outline"></ion-back-button>
</ion-buttons>
<ion-title class="ion-text-center">{{ "signupPage.title" | translate }}</ion-title>
<ion-buttons slot="end">
<ion-menu-button></ion-menu-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-card>
<ion-card-content>
<ion-grid>
<ion-row>
<ion-col sizeSm="4" sizeLg="4" sizeMd="6">
<form (ngSubmit)="register()" [formGroup]="credentials">
<ion-item fill="solid" class="ion-margin-bottom">
<ion-input type="name" [placeholder]="'signupPage.name-placeholder' | translate" formControlName="name"><ion-icon name="person-outline"></ion-icon></ion-input>
</ion-item>
<ion-item fill="solid" class="ion-margin-bottom">
<ion-input type="email" [placeholder]="'signupPage.email-placeholder' | translate" formControlName="email"><ion-icon name="mail-outline"></ion-icon></ion-input>
<ion-note slot="error" *ngIf="(email.dirty || email.touched) && email.errors">{{ "signupPage.email-invalid" | translate }}</ion-note>
</ion-item>
<ion-item fill="solid" class="ion-margin-bottom">
<ion-input type="password" [placeholder]="'signupPage.password-placeholder' | translate" formControlName="password"><ion-icon name="key-outline"></ion-icon></ion-input>
<ion-note slot="error" *ngIf="(password.dirty || password.touched) && password.errors">{{ "signupPage.password-invalid" | translate }}</ion-note>
</ion-item>
<ion-button type="submit" expand="block" [disabled]="!credentials.valid">{{ "signupPage.create-account-button" | translate }}</ion-button>
</form>
</ion-col>
</ion-row>
<ion-row>
<ion-col sizeSm="10" offsetSm="1" sizeLg="6" offsetLg="3" sizeMd="8" offsetMd="2" class="ion-text-center">
<a [routerLink]="['/login']">{{ "signupPage.have-account" | translate }}</a>
</ion-col>
</ion-row>
</ion-grid>
</ion-card-content>
</ion-card>
</ion-content>

View File

@@ -0,0 +1,11 @@
ion-item{
--border-radius: 10px;
}
ion-button{
--border-radius: 10px;
}
ion-input ion-icon{
padding-right: 15px;
}

View File

@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { SignupPage } from './signup.page';
describe('SignupPage', () => {
let component: SignupPage;
let fixture: ComponentFixture<SignupPage>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ SignupPage ],
imports: [IonicModule.forRoot()]
}).compileComponents();
fixture = TestBed.createComponent(SignupPage);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,96 @@
import { Component, OnInit } from '@angular/core';
import { Auth, User } from '@angular/fire/auth';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { AlertController, LoadingController } from '@ionic/angular';
import { TranslateService } from '@ngx-translate/core';
import { AuthService } from '../../services/auth.service';
@Component({
selector: 'app-signup',
templateUrl: './signup.page.html',
styleUrls: ['./signup.page.scss'],
})
export class SignupPage implements OnInit {
credentials: FormGroup;
user: User | null = null;
constructor(
private fb: FormBuilder,
private loadingController: LoadingController,
private alertController: AlertController,
private router: Router,
private authService: AuthService,
private afAuth: Auth,
private translateService: TranslateService
) {
this.afAuth.onAuthStateChanged((userState) => {
this.user = userState;
});
}
// Easy access for form fields
get email() {
return this.credentials.get('email');
}
get password() {
return this.credentials.get('password');
}
get name() {
return this.credentials.get('name');
}
ngOnInit() {
this.credentials = this.fb.group({
name: ['', Validators.minLength(3)],
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(6)]],
});
}
async register() {
const loading = await this.loadingController.create({
backdropDismiss: true,
message: this.translateService.instant('signupPage.creating-account'),
duration: 5000,
});
await loading.present();
console.log('received following values at registration', this.credentials);
const user = await this.authService.register(this.credentials.value);
await loading.dismiss();
if (user) {
this.router.navigateByUrl('/profile', { replaceUrl: true });
} else {
this.showAlert(
this.translateService.instant('signupPage.registration-failed'),
this.translateService.instant('signupPage.please-try-again')
);
}
}
async showAlert(header, message) {
const alert = await this.alertController.create({
header,
message,
buttons: ['OK'],
});
await alert.present();
}
loginWithGoogle() {
this.authService
.loginWithGoogle()
.then(() => this.router.navigate(['/profile']))
.catch((e) => console.log(e.message));
}
loginWithApple() {
this.authService
.loginWithApple()
.then(() => this.router.navigate(['/profile']))
.catch((e) => console.log(e.message));
}
}

View File

@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomePage } from './home.page';
const routes: Routes = [
{
path: '',
component: HomePage,
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class HomePageRoutingModule {}

View File

@@ -0,0 +1,21 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule } from '@ionic/angular';
import { FormsModule } from '@angular/forms';
import { HomePage } from './home.page';
import { HomePageRoutingModule } from './home-routing.module';
import { TranslateModule } from '@ngx-translate/core';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
HomePageRoutingModule,
TranslateModule
],
declarations: [HomePage]
})
export class HomePageModule {}

View File

@@ -0,0 +1,56 @@
<ion-header>
<ion-toolbar color="primary">
<ion-title>M O B I S</ion-title>
<ion-buttons slot="start">
<ion-menu-button slot="start"></ion-menu-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-card>
<ion-card-title>{{ "measure.welcome-to-mobis" | translate }}</ion-card-title>
<ion-card-header>{{ "measure.measure" | translate }}</ion-card-header>
<ion-card-content>
<ion-list>
<ion-list-header>
{{ "measure.water-quality" | translate }}
</ion-list-header>
<ion-button shape="round" expand="block" routerLink="/minisecchi" strong="true" routerDirection="forward" >
{{ "measure.new-mini-secchi-observation" | translate }} </ion-button>
<ion-button shape="round" expand="block" routerLink="/ispex" routerDirection="forward" strong="true" routerDirection="forward" >
{{ "measure.new-ispex2-observation" | translate }} </ion-button>
<ion-list-header>
{{ "measure.air-quality" | translate }}
</ion-list-header>
<ion-button shape="round" expand="block" routerLink="/canairiopm25" routerDirection="forward" strong="true" routerDirection="forward" >
{{ "measure.new-canairiopm25-observation" | translate }}
</ion-button>
<ion-button shape="round" expand="block" routerLink="/canairioco2" routerDirection="forward" strong="true" routerDirection="forward" >
{{ "measure.new-canairioco2-observation" | translate }}
</ion-button>
<ion-list-header>
{{ "measure.biodiversity" | translate }}
</ion-list-header>
<ion-button shape="round" expand="block" routerLink="/plantnet" routerDirection="forward" strong="true" routerDirection="forward" >
{{ "measure.new-plantnet-observation" | translate }}
</ion-button>
</ion-list>
</ion-card-content>
</ion-card>
</ion-content>

128
src/app/home/home.page.scss Normal file
View File

@@ -0,0 +1,128 @@
#container {
text-align: center;
position: absolute;
left: 0;
right: 0;
top: 50%;
transform: translateY(-50%);
}
#container strong {
font-size: 20px;
line-height: 26px;
}
#container p {
font-size: 16px;
line-height: 22px;
color: #8c8c8c;
margin: 0;
}
#container a {
text-decoration: none;
}
ion-avatar {
width: 128px;
height: 128px;
}
.preview {
margin-top: 50px;
display: flex;
justify-content: center;
}
.fallback {
width: 128px;
height: 128px;
border-radius: 50%;
background: #bfbfbf;
display: flex;
justify-content: center;
align-items: center;
font-weight: 500;
}
ion-label.title{
font-weight: bold;
}
ion-col{
text-align: start;
vertical-align: middle;
}
ion-col ion-button{
vertical-align: baseline;
}
ion-icon {
pointer-events: none;
}
ion-card{
padding: 0px;
margin: 2px;
ion-card-header{
font-weight: bold;
}
ion-card-content{
font-size: 12px;
}
}
ion-header ion-img{
width:50px;
}
ion-img{
// display: block;
margin-left: auto;
margin-right: auto;
width: 80%;
}
ion-col {
display: flex;
justify-content: center;
padding: 0px;
}
ion-footer{
padding-top: 2px;
color: black;
display: flex;
align-items:center;
justify-content: center;
border-top: 5px, solid;
border-color: #8c8c8c;
background-color: rgb(255, 255, 255);
}
.icon-verified {
display: inline-block;
font-size: 20px;
color: #1a7900;
vertical-align: middle;
}
.icon-not-verified {
display: inline-block;
font-size: 20px;
color: #790000;
vertical-align: middle;
}
.text{
display: inline-block;
vertical-align: middle;
}

View File

@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { HomePage } from './home.page';
describe('HomePage', () => {
let component: HomePage;
let fixture: ComponentFixture<HomePage>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ HomePage ],
imports: [IonicModule.forRoot()]
}).compileComponents();
fixture = TestBed.createComponent(HomePage);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('should create', () => {
expect(component).toBeTruthy();
});
});

49
src/app/home/home.page.ts Normal file
View File

@@ -0,0 +1,49 @@
import { Component, OnInit } from '@angular/core';
import { MenuController } from '@ionic/angular';
import { TranslateService } from '@ngx-translate/core';
import { Geolocation, PositionOptions } from '@capacitor/geolocation';
@Component({
selector: 'app-home',
templateUrl: 'home.page.html',
styleUrls: ['home.page.scss'],
})
export class HomePage implements OnInit{
constructor(
private menuController: MenuController, private translateService: TranslateService
) {
}
async ngOnInit(): Promise<void> {
const options: PositionOptions = {
maximumAge: 10000,
enableHighAccuracy: true
};
const location = await Geolocation.getCurrentPosition(options);
console.log('Current location: ', location);
}
openMenu(){
this.menuController.open();
}
}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Canairioco2Page } from './canairioco2.page';
const routes: Routes = [
{
path: '',
component: Canairioco2Page
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class Canairioco2PageRoutingModule {}

View File

@@ -0,0 +1,19 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { Canairioco2PageRoutingModule } from './canairioco2-routing.module';
import { BackgroundMode } from '@awesome-cordova-plugins/background-mode/ngx';
import { Canairioco2Page } from './canairioco2.page';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
Canairioco2PageRoutingModule
],
declarations: [Canairioco2Page],
providers: [BackgroundMode]
})
export class Canairioco2PageModule {}

View File

@@ -0,0 +1,82 @@
<ion-header>
<ion-toolbar>
<ion-title>New Canair.io CO2 observation</ion-title>
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-item>
<ion-label>
Canairio output
</ion-label>
</ion-item>
<ion-item>
<ion-label primary>CO2: {{CO2}} ({{CO2Interpret}}) </ion-label>
</ion-item>
<ion-item>
<ion-label primary>Temperature: {{TEMP}} C</ion-label>
</ion-item>
<ion-item>
<ion-label primary>Humidity: {{HUMID }} % </ion-label>
</ion-item>
<ion-item>
<ion-label primary>PAX: {{PAX}} </ion-label>
</ion-item>
<ion-item>
<ion-label primary>Lat: {{latitude}} </ion-label>
</ion-item>
<ion-item>
<ion-label primary>Lon: {{longitude}} </ion-label>
</ion-item>
<ion-item>
<ion-label primary>Alt: {{altitude}} </ion-label>
</ion-item>
<ion-item>
<ion-button (click) ="startLogging()">Start logging</ion-button>
<ion-button (click) ="stopLogging()"> Stop logging</ion-button>
</ion-item>
<ion-item>
<ion-input>Log Interval:{{logInterval}}</ion-input>
</ion-item>
<ion-item>
<ion-label>Log status:{{lblLogstatus}}</ion-label>
</ion-item>
</ion-content>

View File

@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { Canairioco2Page } from './canairioco2.page';
describe('Canairioco2Page', () => {
let component: Canairioco2Page;
let fixture: ComponentFixture<Canairioco2Page>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ Canairioco2Page ],
imports: [IonicModule.forRoot()]
}).compileComponents();
fixture = TestBed.createComponent(Canairioco2Page);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,245 @@
import { Component, OnInit } from '@angular/core';
import { ENV } from '../../app.constant';
import { Geolocation} from '@capacitor/geolocation';
import { Guid } from "guid-typescript";
import {interval, Subscription} from 'rxjs';
import { Storage } from '@ionic/storage-angular';
import * as Parse from 'parse';
import { BackgroundMode } from '@awesome-cordova-plugins/background-mode/ngx';
// Setup Bluetooth LE
// Import the wrapper class directly
import { BleClient, numbersToDataView, numberToUUID, dataViewToText } from '@capacitor-community/bluetooth-le';
const PM25_SERVICE = 'C8D1D262-861F-4082-947E-F383A259AAF3';
const PM25_SERVICE_LCASE= 'c8d1d262-861f-4082-947e-f383a259aaf3';
const PM25_SERVICE_CHARACTERISTIC = 'B0F332A8-A5AA-4F3F-BB43-F99E7791AE01';
/* other services "B0F332A8-A5AA-4F3F-BB43-F99E7791AE02",
"B0F332A8-A5AA-4F3F-BB43-F99E7791AE03"
*/
@Component({
selector: 'app-canairio',
templateUrl: './canairioco2.page.html',
styleUrls: ['./canairioco2.page.scss'],
})
export class Canairioco2Page implements OnInit {
public txtco2: string;
public datetime_ux: string;
public datetime: Date;
public latitude: number;
public longitude: number;
public altitude: number;
private parseAppId: string = ENV.parseAppId;
private parseServerUrl: string = ENV.parseServerUrl;
private parseJSKey: string=ENV.parseJSKey;
public result: string;
public output_json: string;
public rec_uid: string;
public session_uid: string;
public deviceId:string;
public intervalID:NodeJS.Timeout;
public logInterval:number;
public lblLogstatus: string;
public allData: any;
public CO2: string;
public TEMP: string;
public HUMID: string;
public PAX: string;
public CO2Interpret:string;
public ParseServerKey:string;
public ParseServerURL:string;
public ParseServerAppID:string;
constructor(private backgroundMode: BackgroundMode) { }
// Initialize Parse SDK and connect to Parse Server
ngOnInit() {
this.parseInitialize();
this.getLocation();
this.connect();
this.logInterval=5000;
this.lblLogstatus="Not logging";
this.readandSave();
this.parseAppId=ENV.parseAppId;
this.parseServerUrl=ENV.parseServerUrl;
this.parseJSKey=ENV.parseJSKey;
}
// i am not sure if this is needed, because CO2 is not moving
async startLogging() {
this.backgroundMode.enable();
this.lblLogstatus="Logging..";
// set session uid
this.session_uid = Guid.raw(); // make it a string
var str_counter;
str_counter=0;
this.intervalID = setInterval( () => {
this.lblLogstatus="Logging: " + str_counter.toString() ;
str_counter++;
this.getLocation();
this.readandSave();
},this.logInterval);
}
async stopLogging() {
clearInterval(this.intervalID);
this.lblLogstatus="Not logging";
this.backgroundMode.disable();
}
async readandSave() {
const result = await BleClient.read(this.deviceId, PM25_SERVICE, PM25_SERVICE_CHARACTERISTIC);
console.log('canair.io result array', dataViewToText(result));
this.allData = JSON.parse(dataViewToText(result)); // parse json data and pass json string
this.CO2= this.allData['CO2'];
// Indoor CO2 levels
if (Number(this.CO2)>0 && Number(this.CO2)<700 ) this.CO2Interpret= ("Excellent");
if (Number(this.CO2)>=700 && Number(this.CO2)<800 )this.CO2Interpret= ("Good");
if (Number(this.CO2)>=800 && Number(this.CO2)<1000 )this.CO2Interpret= ("Fair");
if (Number(this.CO2)>=1000 && Number(this.CO2)<1500 )this.CO2Interpret= ("Mediocre");
if (Number(this.CO2)>=1500 ) this.CO2Interpret= ("Bad");
this.TEMP= this.allData['tmp'];
this.HUMID= this.allData['hum'];
this.PAX= this.allData['PAX'];
this.CO2= this.allData['CO2'];
// lets assume we have
if (Number(this.CO2)>0) {
this.TEMP= this.allData['CO2T'];
this.HUMID= this.allData['CO2H'];
}
// this.txtco2= this.allData['CO2']; // this is the CO2 value
let d = new Date();
this.datetime=d;
var unixTimeStamp = Math.floor(d.getTime() / 1000);
this.datetime_ux=unixTimeStamp.toString();
this.output_json=dataViewToText(result);
this.rec_uid = Guid.raw(); // make it a string
var Comment = Parse.Object.extend('canairico2_raw_data');
var canairio_store = new Comment();
// set initial data record
canairio_store.set('session_UID',this.session_uid);
canairio_store.set('record_UID',this.rec_uid);
canairio_store.set('device_UID',this.deviceId);
canairio_store.set('output_json',dataViewToText(result));
canairio_store.set('TEMP',this.TEMP);
canairio_store.set('HUMID',this.HUMID);
canairio_store.set('CO2',this.CO2);
canairio_store.set('PAX',this.PAX);
canairio_store.set('latitude',this.latitude);
canairio_store.set('longitude',this.longitude);
canairio_store.set('altitude',this.altitude);
canairio_store.set('datetime_ux',this.datetime_ux);
await canairio_store.save();
}
// get location and save to class variables
async getLocation() {
const position = await Geolocation.getCurrentPosition({enableHighAccuracy: true});
this.latitude = position.coords.latitude;
console.log (position.coords.latitude);
this.longitude = position.coords.longitude;
this.altitude = position.coords.altitude;
return position.coords;
}
// connect to parse server and initialize
private parseInitialize() {
Parse.initialize(this.parseAppId, this.parseJSKey);
(Parse as any).serverURL = this.ParseServerURL; // use your server url
}
async connect(){
try {
await BleClient.initialize();
const device = await BleClient.requestDevice({
namePrefix: 'CanAirIO',
optionalServices : [PM25_SERVICE_LCASE]
});
// console.log('device', device);
await BleClient.connect(device.deviceId);
console.log('connected to device', device);
this.deviceId=device.deviceId;
this.readandSave();
setTimeout(async () => {
await BleClient.stopLEScan();
console.log('stopped scanning');
}, 5000);
} catch (error) {
console.error(error);
}
}
}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Canairiopm25Page } from './canairiopm25.page';
const routes: Routes = [
{
path: '',
component: Canairiopm25Page
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class Canairiopm25PageRoutingModule {}

View File

@@ -0,0 +1,23 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { BackgroundMode } from '@awesome-cordova-plugins/background-mode/ngx';
import { IonicModule } from '@ionic/angular';
import { Canairiopm25PageRoutingModule } from './canairiopm25-routing.module';
import { Canairiopm25Page } from './canairiopm25.page';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
Canairiopm25PageRoutingModule
],
declarations: [Canairiopm25Page],
providers: [BackgroundMode]
})
export class Canairiopm25PageModule {}

View File

@@ -0,0 +1,85 @@
<ion-header>
<ion-toolbar>
<ion-title>New Canair.io PM2.5 observation</ion-title>
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-item>
<ion-label>
Canairio output
</ion-label>
</ion-item>
<ion-item>
<ion-label primary>{{txtpm25}} </ion-label>
</ion-item>
<ion-item>
<ion-label primary>PM 2.5: {{PM25}} ({{PM25Interpret}}) </ion-label>
</ion-item>
<ion-item>
<ion-label primary>Temperature: {{TEMP}} C</ion-label>
</ion-item>
<ion-item>
<ion-label primary>Humidity: {{HUMID }} % </ion-label>
</ion-item>
<ion-item>
<ion-label primary>PAX: {{PAX}} </ion-label>
</ion-item>
<ion-item>
<ion-label primary>Lat: {{latitude}} </ion-label>
</ion-item>
<ion-item>
<ion-label primary>Lon: {{longitude}} </ion-label>
</ion-item>
<ion-item>
<ion-label primary>Alt: {{altitude}} </ion-label>
</ion-item>
<ion-item>
<ion-button (click) ="startLogging()">Start logging</ion-button>
<ion-button (click) ="stopLogging()"> Stop logging</ion-button>
</ion-item>
<ion-item>
<ion-input>Log Interval:{{logInterval}}</ion-input>
</ion-item>
<ion-item>
<ion-label>Log status:{{lblLogstatus}}</ion-label>
</ion-item>
</ion-content>

View File

@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { Canairiopm25Page } from './canairiopm25.page';
describe('Canairiopm25Page', () => {
let component: Canairiopm25Page;
let fixture: ComponentFixture<Canairiopm25Page>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ Canairiopm25Page ],
imports: [IonicModule.forRoot()]
}).compileComponents();
fixture = TestBed.createComponent(Canairiopm25Page);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,251 @@
import { Component, OnInit } from '@angular/core';
import { ENV } from '../../app.constant';
import { Geolocation} from '@capacitor/geolocation';
import { Guid } from "guid-typescript";
import {interval, Subscription} from 'rxjs';
import { Storage } from '@ionic/storage-angular';
import * as Parse from 'parse';
import { BackgroundMode } from '@awesome-cordova-plugins/background-mode/ngx';
// Import the wrapper class directly
import { BleClient, numbersToDataView, numberToUUID, dataViewToText } from '@capacitor-community/bluetooth-le';
const PM25_SERVICE = 'C8D1D262-861F-4082-947E-F383A259AAF3';
const PM25_SERVICE_LCASE= 'c8d1d262-861f-4082-947e-f383a259aaf3';
const PM25_SERVICE_CHARACTERISTIC = 'B0F332A8-A5AA-4F3F-BB43-F99E7791AE01';
/* other services "B0F332A8-A5AA-4F3F-BB43-F99E7791AE02",
"B0F332A8-A5AA-4F3F-BB43-F99E7791AE03"
*/
@Component({
selector: 'app-canairio',
templateUrl: './canairiopm25.page.html',
styleUrls: ['./canairiopm25.page.scss'],
})
export class Canairiopm25Page implements OnInit {
public txtpm25: string;
public datetime_ux: string;
public datetime: Date;
public latitude: number;
public longitude: number;
public altitude: number;
private parseAppId: string = ENV.parseAppId;
private parseServerUrl: string = ENV.parseServerUrl;
private parseJSKey: string=ENV.parseJSKey;
public result: string;
public output_json: string;
public rec_uid: string;
public session_uid: string;
public deviceId:string;
public intervalID:NodeJS.Timeout;
public logInterval:number;
public lblLogstatus: string;
public allData: any;
public PM25: string;
public CO2: string;
public TEMP: string;
public HUMID: string;
public PAX: string;
public PM25Interpret:string;
public ParseServerKey:string;
public ParseServerURL:string;
public ParseServerAppID:string;
constructor(private backgroundMode: BackgroundMode) { }
ngOnInit() {
this.parseInitialize();
this.getLocation();
this.connect();
this.logInterval=5000;
this.lblLogstatus="Not logging";
this.readandSave();
this.parseAppId=ENV.parseAppId;
this.parseServerUrl=ENV.parseServerUrl;
this.parseJSKey=ENV.parseJSKey;
}
async startLogging() {
this.backgroundMode.enable();
this.lblLogstatus="Logging..";
// set session uid
this.session_uid = Guid.raw(); // make it a string
var str_counter;
str_counter=0;
this.intervalID = setInterval( () => {
this.lblLogstatus="Logging: " + str_counter.toString() ;
str_counter++;
this.getLocation();
this.readandSave();
},this.logInterval);
}
async stopLogging() {
clearInterval(this.intervalID);
this.lblLogstatus="Not logging";
this.backgroundMode.disable();
}
async readandSave() {
const result = await BleClient.read(this.deviceId, PM25_SERVICE, PM25_SERVICE_CHARACTERISTIC);
console.log('canair.io result array', dataViewToText(result));
this.allData = JSON.parse(dataViewToText(result)); // parse json data and pass json string
this.PM25= this.allData['P25'];
//outdoor
if (Number(this.PM25)>0 && Number(this.PM25)<25 ) this.PM25Interpret= ("Good");
if (Number(this.PM25)>24 && Number(this.PM25)<51 )this.PM25Interpret= ("Fair");
if (Number(this.PM25)>49 && Number(this.PM25)<101 )this.PM25Interpret= ("Poor");
if (Number(this.PM25)>100 && Number(this.PM25)<300 )this.PM25Interpret= ("Very Poor");
if (Number(this.PM25)>300 ) this.PM25Interpret= ("Extremely Poor");
this.TEMP= this.allData['tmp'];
this.HUMID= this.allData['hum'];
this.PAX= this.allData['PAX'];
this.CO2= this.allData['CO2'];
// lets assume we have
if (Number(this.CO2)>0) {
this.TEMP= this.allData['CO2T'];
this.HUMID= this.allData['CO2H'];
}
let d = new Date();
this.datetime=d;
var unixTimeStamp = Math.floor(d.getTime() / 1000);
this.datetime_ux=unixTimeStamp.toString();
this.output_json=dataViewToText(result);
this.txtpm25=this.output_json;
this.rec_uid = Guid.raw(); // make it a string
var Comment = Parse.Object.extend('canairio_raw_data');
var canairio_store = new Comment();
// set initial data record
canairio_store.set('session_UID',this.session_uid);
canairio_store.set('record_UID',this.rec_uid);
canairio_store.set('device_UID',this.deviceId);
canairio_store.set('output_json',dataViewToText(result));
canairio_store.set('PM25',this.PM25);
canairio_store.set('TEMP',this.TEMP);
canairio_store.set('HUMID',this.HUMID);
canairio_store.set('CO2',this.CO2);
canairio_store.set('PAX',this.PAX);
canairio_store.set('latitude',this.latitude);
canairio_store.set('longitude',this.longitude);
canairio_store.set('altitude',this.altitude);
canairio_store.set('datetime_ux',this.datetime_ux);
await canairio_store.save();
}
async getLocation() {
const position = await Geolocation.getCurrentPosition({enableHighAccuracy: true});
this.latitude = position.coords.latitude;
console.log (position.coords.latitude);
this.longitude = position.coords.longitude;
this.altitude = position.coords.altitude;
return position.coords;
}
private parseInitialize() {
Parse.initialize(this.parseAppId, this.parseJSKey);
(Parse as any).serverURL = this.ParseServerURL; // use your server url
}
async connect(){
try {
await BleClient.initialize();
const device = await BleClient.requestDevice({
namePrefix: 'CanAirIO',
optionalServices : [PM25_SERVICE_LCASE]
});
// console.log('device', device);
await BleClient.connect(device.deviceId);
console.log('connected to device', device);
this.deviceId=device.deviceId;
this.readandSave();
setTimeout(async () => {
await BleClient.stopLEScan();
console.log('stopped scanning');
}, 5000);
} catch (error) {
console.error(error);
}
}
}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { IspexPage } from './ispex.page';
const routes: Routes = [
{
path: '',
component: IspexPage
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class IspexPageRoutingModule {}

View File

@@ -0,0 +1,24 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { IspexPageRoutingModule } from './ispex-routing.module';
import { PreviewPageModule } from "./preview/preview.module"
import { Vibration } from '@ionic-native/vibration/ngx';
import { IspexPage } from './ispex.page';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
IspexPageRoutingModule
],
declarations: [IspexPage],
providers: [Vibration]
})
export class IspexPageModule {}

View File

@@ -0,0 +1,64 @@
<ion-header>
<ion-toolbar color="primary">
<ion-title>New iSPEX observation</ion-title>
<ion-buttons slot="start">
<ion-menu-button slot="start"></ion-menu-button>
<ion-back-button></ion-back-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content [fullscreen]="true">
<div *ngIf="image">
<img #image id="img" [src]="image" alt="" srcset="">
</div>
<ion-button (click)="openCamera()" color="primary" expand="block" fill="solid" size="default">
Attach the iSPEX unit and open camera
</ion-button>
<ion-card>
<ion-card-title>Mount iSPEX2 unit on phone</ion-card-title>
<ion-card-subtitle></ion-card-subtitle>
<ion-card-content>1. If you have a smartphone
cover or case, remove it.<br>
2. Take your smartphone in
one hand and the iSPEX2
add-on in the other. If this
is not possible, use a table
or similar surface to lay the
smartphone on.<br>
<ion-img src="assets/images/ispex-mount1.png"></ion-img>
3. The iSPEX2 add-on will be mounted on the back side of the phone and the clip will be on the front screen.
Slide your smartphone into the smartphone clip. You may need to use your other
hand to slightly open the clip, especially for thicker
smartphones.<br>
<ion-img src="assets/images/ispex-mount3.png"></ion-img>
4. Slide the iSPEX2
add-on over your
smartphone until the
bars on the top and
left sides fit snugly.<br>
<ion-img src="assets/images/ispex-mount2.png"></ion-img>
5. Firmly press the suction cup on the
back of the smartphone until it is
clearly attached.<br>
<ion-img src="assets/images/ispex-mount4.png"></ion-img>
6. Gently move the smartphone back
and forth to ensure the add-on is
firmly attached.
If the add-on appears to rotate or slide off, start again. </ion-card-content>
</ion-card>
<canvas id="canvasOutput"></canvas>
</ion-content>

View File

@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { IspexPage } from './ispex.page';
describe('IspexPage', () => {
let component: IspexPage;
let fixture: ComponentFixture<IspexPage>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ IspexPage ],
imports: [IonicModule.forRoot()]
}).compileComponents();
fixture = TestBed.createComponent(IspexPage);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,75 @@
import { Component, OnInit, AfterViewInit, ViewChild, ElementRef } from '@angular/core';
import { ModalController } from '@ionic/angular';
import { PreviewPage } from './preview/preview.page';
declare var cv: any;
@Component({
selector: 'app-ispex',
templateUrl: './ispex.page.html',
styleUrls: ['./ispex.page.scss'],
})
export class IspexPage implements AfterViewInit {
@ViewChild('imageCanvas', { static: false }) canvasEl : ElementRef;
@ViewChild('image', { static: false }) imageEl : ElementRef;
image = null;
constructor(private modal: ModalController) {}
ngAfterViewInit() {
// this._CANVAS = this.canvasEl.nativeElement;
// this._IMAGE = this.imageEl.nativeElement;
console.log ("ngAfterViewInit");
}
async openCamera() {
const modal = await this.modal.create({
component: PreviewPage,
cssClass: 'fullscreen',
animated: true
});
modal.onDidDismiss().then((data) => {
if (data !== null) {
this.image = data.data;
let imgElement = document.getElementById('img');
let src = cv.imread(imgElement);
let dst = new cv.Mat();
let dsize = new cv.Size(src.rows, src.cols);
let center = new cv.Point(src.cols / 2, src.rows / 2);
let M = cv.getRotationMatrix2D(center, -90, 1);
cv.warpAffine(src, dst, M, dsize, cv.INTER_LINEAR, cv.BORDER_CONSTANT, new cv.Scalar());
cv.imshow('canvasOutput', dst);
src.delete(); dst.delete(); M.delete();
cv.imshow('canvasOutput', dst);
src.delete(); dst.delete();
}
});
return await modal.present();
}
}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { PreviewPage } from './preview.page';
const routes: Routes = [
{
path: '',
component: PreviewPage
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class PreviewPageRoutingModule {}

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { PreviewPageRoutingModule } from './preview-routing.module';
import { PreviewPage } from './preview.page';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
PreviewPageRoutingModule
],
declarations: [PreviewPage]
})
export class PreviewPageModule {}

View File

@@ -0,0 +1,32 @@
<ion-content id="content" [fullscreen]="true">
<div *ngIf="cameraActive">
<ion-button (click)="stopCamera()" expand="block" id="close">
X</ion-button>
<ion-button (click)="takePicture(1)" expand="block" id="grey">
Grey
</ion-button>
<ion-button (click)="takePicture(2)" expand="block" id="sky">
Sky
</ion-button>
<ion-button (click)="takePicture(3)" expand="block" id="water">
Water
</ion-button>
<ion-button (click)="takePicture(4)" expand="block" id="free">
Free
</ion-button>
</div>
</ion-content>

View File

@@ -0,0 +1,65 @@
#close {
position: absolute;
bottom: 30px;
left: calc(50% - 160px);
width: 50px;
height: 50px;
z-index: 99999;
}
#rgb {
position: absolute;
bottom: 30px;
left: calc(50% - 140px);
width: 50px;
height: 50px;
z-index: 99999;
}
#grey {
position: absolute;
bottom: 30px;
left: calc(50% - 80px);
width: 50px;
height: 50px;
z-index: 99999;
}
#sky {
position: absolute;
bottom: 30px;
left: calc(50% - 20px);
width: 50px;
height: 50px;
z-index: 99999;
}
#water{
position: absolute;
bottom: 30px;
left: calc(50% + 40px);
width: 50px;
height: 50px;
z-index: 99999;
}
#free {
position: absolute;
bottom: 30px;
left: calc(50% + 100px);
width: 50px;
height: 50px;
z-index: 99999;
}
ion-content { --background: black;}

View File

@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { PreviewPage } from './preview.page';
describe('PreviewPage', () => {
let component: PreviewPage;
let fixture: ComponentFixture<PreviewPage>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ PreviewPage ],
imports: [IonicModule.forRoot()]
}).compileComponents();
fixture = TestBed.createComponent(PreviewPage);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,259 @@
import * as Parse from 'parse';
import { ENV } from '../../../app.constant';
import { Vibration } from '@ionic-native/vibration/ngx';
import { Component, OnInit } from '@angular/core';
import { Plugins } from "@capacitor/core"
const { CameraPreview } = Plugins;
import { CameraPreviewOptions, CameraPreviewPictureOptions, CameraSampleOptions } from '@capacitor-community/camera-preview';
import { ModalController } from '@ionic/angular';
import { Geolocation} from '@capacitor/geolocation';
import { Device } from '@capacitor/device';
@Component({
selector: 'app-preview',
templateUrl: './preview.page.html',
styleUrls: ['./preview.page.scss'],
})
export class PreviewPage implements OnInit {
image = null;
cameraActive = false;
private parseAppId: string = ENV.parseAppId;
private parseServerUrl: string = ENV.parseServerUrl;
private parseJSKey: string=ENV.parseJSKey;
public datetime_ux: string;
public datetime: string;
public latitude: number;
public longitude: number;
public altitude: number;
public heading: number;
// public deviceAppVersion: string;
// public deviceAppBuild: string;
public deviceOsVersion: string;
public devicePlatform: string;
public deviceManufacturer: string;
public deviceModel: string;
constructor(private modal: ModalController, private vibration: Vibration
) { }
ngOnInit() {
this.getLocation();
this.parseInitialize();
this.launchCamera();
this.getDeviceInfo();
}
launchCamera() {
const cameraPreviewOptions: CameraPreviewOptions = {
position: 'rear', // front or rear
parent: 'content', // the id on the ion-content
className: '',
width: window.screen.width, //width of the camera display
height:window.screen.height-200, //height of the camera
toBack: false,
disableAudio: true,
rotateWhenOrientationChanged: false,
lockAndroidOrientation: true
};
CameraPreview.start(cameraPreviewOptions);
this.cameraActive = true;
}
async takePicture( dngType ) {
// buzz for user feedback
this.vibration.vibrate(1);
// this captures a raw image. replace quality with type
const cameraPreviewPictureOptions: CameraPreviewPictureOptions = {
quality: dngType// misuse quality: 0 normal (rgb) image 1 gray card 2 sky image 3 water image 4 other spectro(pol)
};
const result = await CameraPreview.capture(cameraPreviewPictureOptions);
this.image = `data:image/jpeg;base64,${result.value}`;
this.getLocation();
// save to parse
const base64PictureData = result.value;
let d = new Date();
this.datetime=d.toString();
var unixTimeStamp = Math.floor(d.getTime() / 1000);
this.datetime_ux=unixTimeStamp.toString();
// do something with base64PictureData
var Base64SnapImage:string;
Base64SnapImage = "data:image/jpeg;base64," + base64PictureData;
var Base64DNGImage:string;
var Save2Parse = Parse.Object.extend('ispex_data');
var iSPEX2_store = new Save2Parse();
// set initial data record
// upload thumb for user feedback. DNG's are uploaded within the native part
const thumbFiletoUpload = new Parse.File("thumb.jpg", { base64: Base64SnapImage},"image/jpeg");
thumbFiletoUpload.save().then(function() {
console.log ("saved thumb!")
}, function(error) {
// The file either could not be read, or could not be saved to Parse.
console.log ("error saving thumb")
});
iSPEX2_store.set('thumb', thumbFiletoUpload);
iSPEX2_store.set('latitude',this.latitude);
iSPEX2_store.set('longitude',this.longitude);
iSPEX2_store.set('altitude',this.altitude);
iSPEX2_store.set('heading',this.heading);
iSPEX2_store.set('dngFileURL',result.dngFileURL);
iSPEX2_store.set('heading',result.heading);
iSPEX2_store.set('device_angle',result.device_angle);
iSPEX2_store.set ('device_osversion', this.deviceOsVersion);
iSPEX2_store.set ('device_platform', this.devicePlatform);
iSPEX2_store.set ('device_manufacturer', this.deviceManufacturer);
iSPEX2_store.set ('device_model', this.deviceModel);
iSPEX2_store.set ('datetimerecorded', this.datetime);
iSPEX2_store.set ('datetime_ux', this.datetime_ux);
await iSPEX2_store.save();
this.stopCamera();
}
async getLocation() {
const position = await Geolocation.getCurrentPosition({enableHighAccuracy: true});
this.latitude = position.coords.latitude;
this.heading= position.coords.heading;
// console.log (position.coords.latitude);
this.longitude = position.coords.longitude;
this.altitude = position.coords.altitude;
return position.coords;
}
async takeSample() {
}
async stopCamera() {
await CameraPreview.stop();
this.modal.dismiss(this.image);
}
async flipCamera() {
await CameraPreview.flip();
}
async getDeviceInfo() {
const info = await Device.getInfo();
// this.deviceAppVersion = info.appVersion;
// this.deviceAppBuild = info.appBuild;
this.deviceOsVersion = info.osVersion;
this.devicePlatform = info.platform;
this.deviceManufacturer = info.manufacturer;
this.deviceModel = info.model;
console.log(info);
}
private parseInitialize() {
Parse.initialize(this.parseAppId, this.parseJSKey);
(Parse as any).serverURL = this.parseServerUrl; // use your server url
}
}

View File

@@ -0,0 +1,33 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { InstructionsPage } from './instructions.page';
const routes: Routes = [
{
path: '',
component: InstructionsPage
},
{
path: 'step2',
loadChildren: () => import('./step2/step2.module').then( m => m.Step2PageModule)
},
{
path: 'step3',
loadChildren: () => import('./step3/step3.module').then( m => m.Step3PageModule)
},
{
path: 'step4',
loadChildren: () => import('./step4/step4.module').then( m => m.Step4PageModule)
},
{
path: 'step5',
loadChildren: () => import('./step5/step5.module').then( m => m.Step5PageModule)
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class InstructionsPageRoutingModule {}

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { InstructionsPageRoutingModule } from './instructions-routing.module';
import { InstructionsPage } from './instructions.page';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
InstructionsPageRoutingModule
],
declarations: [InstructionsPage]
})
export class InstructionsPageModule {}

View File

@@ -0,0 +1,65 @@
<ion-header>
<ion-toolbar>
<ion-title>Prepare</ion-title>
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-title size="large">Prepare the device</ion-title>
<ion-item>
Attach (optional) pH Strip</ion-item>
<ion-item>
<img src="assets/images/attachph.png">
</ion-item>
<ion-item>
1. Hand through strap</ion-item>
<ion-item>
<img src="assets/images/handtroughstrap.png">
</ion-item>
<ion-item>
2. Take disk from body</ion-item>
<ion-item>
<img src="assets/images/takediskfrombody.png">
</ion-item>
<ion-item>
3. Finger through clip</ion-item>
<ion-item>
<img src="assets/images/fingertroughclip.png">
</ion-item>
<ion-item>
4. Unlock spindle</ion-item>
<ion-item>
<img src="assets/img/unlockspindle.png"></ion-item>
<ion-item>
5. Ready!</ion-item>
<ion-item>
<img src="assets/images/ready.png"></ion-item>
<ion-button expand="block" routerLink="/instructions/step2" routerDirection="forward" >
Step 2
</ion-button>
</ion-content>

View File

@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { InstructionsPage } from './instructions.page';
describe('InstructionsPage', () => {
let component: InstructionsPage;
let fixture: ComponentFixture<InstructionsPage>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ InstructionsPage ],
imports: [IonicModule.forRoot()]
}).compileComponents();
fixture = TestBed.createComponent(InstructionsPage);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,16 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-instructions',
templateUrl: './instructions.page.html',
styleUrls: ['./instructions.page.scss'],
})
export class InstructionsPage implements OnInit {
constructor() { }
ngOnInit() {
}
}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Step2Page } from './step2.page';
const routes: Routes = [
{
path: '',
component: Step2Page
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class Step2PageRoutingModule {}

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { Step2PageRoutingModule } from './step2-routing.module';
import { Step2Page } from './step2.page';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
Step2PageRoutingModule
],
declarations: [Step2Page]
})
export class Step2PageModule {}

View File

@@ -0,0 +1,41 @@
<ion-header>
<ion-toolbar>
<ion-title>2. Transperancy</ion-title>
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-item>
Turning the spindle, lower the disk until it disappears from view, then raise the disk until it re-appears. Record the re-appearance distance.
</ion-item>
<ion-item>
<img src="assets/images/transperancy.png"><br>
</ion-item>
<ion-item>
The app will now calculate the Secchi disk depth (re-appareance distance minus distance to water)
</ion-item>
<ion-button expand="block" routerLink="/instructions/step3" routerDirection="forward" >
Step 3
</ion-button>
</ion-content>

View File

@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { Step2Page } from './step2.page';
describe('Step2Page', () => {
let component: Step2Page;
let fixture: ComponentFixture<Step2Page>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ Step2Page ],
imports: [IonicModule.forRoot()]
}).compileComponents();
fixture = TestBed.createComponent(Step2Page);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,15 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-step2',
templateUrl: './step2.page.html',
styleUrls: ['./step2.page.scss'],
})
export class Step2Page implements OnInit {
constructor() { }
ngOnInit() {
}
}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Step3Page } from './step3.page';
const routes: Routes = [
{
path: '',
component: Step3Page
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class Step3PageRoutingModule {}

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { Step3PageRoutingModule } from './step3-routing.module';
import { Step3Page } from './step3.page';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
Step3PageRoutingModule
],
declarations: [Step3Page]
})
export class Step3PageModule {}

View File

@@ -0,0 +1,31 @@
<ion-header>
<ion-toolbar>
<ion-title>3. Disk color</ion-title>
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-item>
Record the colour of the disk, as it appears at half the Secchi depth, by raising the disk to the distance calculated by the app. </ion-item><ion-item>
<img src="assets/images/diskcolor.png"><br>
</ion-item>
<ion-item>
The app will now calculate the Secchi disk depth (re-appareance distance minus distance to water)
</ion-item>
<ion-button expand="block" routerLink="/instructions/step4" routerDirection="forward" >
Step 4
</ion-button>
</ion-content>

View File

@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { Step3Page } from './step3.page';
describe('Step3Page', () => {
let component: Step3Page;
let fixture: ComponentFixture<Step3Page>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ Step3Page ],
imports: [IonicModule.forRoot()]
}).compileComponents();
fixture = TestBed.createComponent(Step3Page);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,15 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-step3',
templateUrl: './step3.page.html',
styleUrls: ['./step3.page.scss'],
})
export class Step3Page implements OnInit {
constructor() { }
ngOnInit() {
}
}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Step4Page } from './step4.page';
const routes: Routes = [
{
path: '',
component: Step4Page
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class Step4PageRoutingModule {}

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { Step4PageRoutingModule } from './step4-routing.module';
import { Step4Page } from './step4.page';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
Step4PageRoutingModule
],
declarations: [Step4Page]
})
export class Step4PageModule {}

View File

@@ -0,0 +1,34 @@
<ion-header>
<ion-toolbar>
<ion-title>4. Color of the water</ion-title>
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-item>
Colour of the water surface
Estimate the Forel-Ule colour scale by looking at the water surface, away from sun glitter.
</ion-item>
<ion-item>
<img src="assets/images/bodypos.png"><br>
</ion-item>
<ion-item>
Keeping the sun diagonally behind you over your left or right shoulder, look down and forward to the water surface (40-45 degrees) and compare the colour of the water to the Forel-Ule index. Optionally upload another photo using the same viewing angles.</ion-item>
<img src="assets/images/forelule.png"><br>
<ion-button expand="block" routerLink="/instructions/step5" routerDirection="forward" >
Step 5
</ion-button>
</ion-content>

View File

@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { Step4Page } from './step4.page';
describe('Step4Page', () => {
let component: Step4Page;
let fixture: ComponentFixture<Step4Page>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ Step4Page ],
imports: [IonicModule.forRoot()]
}).compileComponents();
fixture = TestBed.createComponent(Step4Page);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,15 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-step4',
templateUrl: './step4.page.html',
styleUrls: ['./step4.page.scss'],
})
export class Step4Page implements OnInit {
constructor() { }
ngOnInit() {
}
}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Step5Page } from './step5.page';
const routes: Routes = [
{
path: '',
component: Step5Page
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class Step5PageRoutingModule {}

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { Step5PageRoutingModule } from './step5-routing.module';
import { Step5Page } from './step5.page';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
Step5PageRoutingModule
],
declarations: [Step5Page]
})
export class Step5PageModule {}

View File

@@ -0,0 +1,32 @@
<ion-header>
<ion-toolbar>
<ion-title>5. Additional</ion-title>
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-item>
pH (acidity)
</ion-item>
<ion-item>
If you deployed the mini-Secchi disk with pH paper, compare the colour of the pH paper to the pH colour scale.
Natural waters are usually only slightly acidic (6) or alkalic (8). The acidity of the water gives an indication of how much Carbon is dissolved in the water and whether algae or plants are likely to grow.
</ion-item>
<ion-item>
<img src="assets/images/ph.png"><br>
</ion-item>
<ion-button expand="block" routerLink="/home" routerDirection="root">Finish
</ion-button>
</ion-content>

View File

@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { Step5Page } from './step5.page';
describe('Step5Page', () => {
let component: Step5Page;
let fixture: ComponentFixture<Step5Page>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ Step5Page ],
imports: [IonicModule.forRoot()]
}).compileComponents();
fixture = TestBed.createComponent(Step4Page);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,15 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-step5',
templateUrl: './step5.page.html',
styleUrls: ['./step5.page.scss'],
})
export class Step5Page implements OnInit {
constructor() { }
ngOnInit() {
}
}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ColourathalfdepthPage } from './colourathalfdepth.page';
const routes: Routes = [
{
path: '',
component: ColourathalfdepthPage
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class ColourathalfdepthPageRoutingModule {}

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { ColourathalfdepthPageRoutingModule } from './colourathalfdepth-routing.module';
import { ColourathalfdepthPage } from './colourathalfdepth.page';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
ColourathalfdepthPageRoutingModule
],
declarations: [ColourathalfdepthPage]
})
export class ColourathalfdepthPageModule {}

View File

@@ -0,0 +1,53 @@
<ion-header>
<ion-toolbar>
<ion-title>Colour at half depth</ion-title>
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-item>
The Secchi depth is: {{secchi_depth}} cm <br>
Raise the disk to half the Secchi disk depth: {{this.halfdepth}} cm
Record the Forel-Ule colour of the disk at this depth (use the colour scale on the handheld device).
In addition, if it is safe to do so, take a photo of the disk in the water at this depth using the button at the bottom of this screen.
</ion-item>
<ion-item>
<img src="assets/images/halfdepth.png">
</ion-item>
<ion-item>
The colour of the disk in the water is most similar to Forel Ule colour:
</ion-item>
<ion-item>
<ion-input [(ngModel)]="colourathalfdepth" type="number" inputmode="numeric" placeholder="Enter colour number (1-21)"></ion-input>
</ion-item>
<ion-item>
<ion-label>To add a photo click here:</ion-label>
<ion-button (click)="takePicture()">
<ion-icon name="camera"></ion-icon>
</ion-button>
<img [src]="this.PictureTaken"/>
</ion-item>
<ion-button expand="block" (click)="validate()" routerLink="/ms_measure/colouratsurface" routerDirection="forward">
Go to Step 4
</ion-button>
</ion-content>

View File

@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { ColourathalfdepthPage } from './colourathalfdepth.page';
describe('ColourathalfdepthPage', () => {
let component: ColourathalfdepthPage;
let fixture: ComponentFixture<ColourathalfdepthPage>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ ColourathalfdepthPage ],
imports: [IonicModule.forRoot()]
}).compileComponents();
fixture = TestBed.createComponent(ColourathalfdepthPage);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,115 @@
import { Component, OnInit } from '@angular/core';
import { Storage } from '@ionic/storage-angular';
import { Plugins } from '@capacitor/core';
import { CameraResultType } from '@capacitor/camera';
const { Camera } = Plugins;
@Component({
selector: 'app-colourathalfdepth',
templateUrl: './colourathalfdepth.page.html',
styleUrls: ['./colourathalfdepth.page.scss'],
})
export class ColourathalfdepthPage implements OnInit {
public reappear_val:number;
public distancetowater_val: number;
public secchi_depth:number;
public colourathalfdepth:number;
public halfdepth:number;
public PictureTaken:string;
constructor(private storage: Storage) { }
async ngOnInit() {
await this.storage.create();
this.storage.get('reappear').then((val) => {
console.log('reappear', val);
this.reappear_val=val;
});
this.storage.get('distancetowater').then((val) => {
this.distancetowater_val=val;
this.secchi_depth=this.reappear_val-this.distancetowater_val;
//secchi depth (Zsd = reappear 1.2 1.1 distance to water)
this.halfdepth=this.distancetowater_val+((this.reappear_val-this.distancetowater_val)/2);
});
}
async takePicture() {
try {
const Picture = await Camera.getPhoto({
quality: 90,
allowEditing: false,
resultType: CameraResultType.Base64,
});
this.PictureTaken = "data:image/jpeg;base64," + Picture.base64String;
this.storage.set('colourathalfdepthimage', Picture.base64String).then(result => {
console.log('Data is saved');
}).catch(e => {
console.log("error: " + e);
});
} catch (error) {
console.error(error);
}
}
async validate() {
// alert(`hola ${this.distancetowater}!`);
await this.storage.create();
this.storage.set('colourathalfdepth', this.colourathalfdepth).then(result => {
// console.log('Data is saved');
}).catch(e => {
console.log("error: " + e);
});
this.storage.set('secchi_depth', this.secchi_depth).then(result => {
// console.log('Data is saved');
}).catch(e => {
console.log("error: " + e);
});
}
}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ColouratsurfacePage } from './colouratsurface.page';
const routes: Routes = [
{
path: '',
component: ColouratsurfacePage
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class ColouratsurfacePageRoutingModule {}

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { ColouratsurfacePageRoutingModule } from './colouratsurface-routing.module';
import { ColouratsurfacePage } from './colouratsurface.page';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
ColouratsurfacePageRoutingModule
],
declarations: [ColouratsurfacePage]
})
export class ColouratsurfacePageModule {}

View File

@@ -0,0 +1,120 @@
<ion-header>
<ion-toolbar>
<ion-title>Additional</ion-title>
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-card><ion-icon name="warning-outline"></ion-icon>You can now wind up the Secchi disk mechanism.</ion-card>
<ion-item>
<ion-label>
<b>Acidity</b></ion-label>
</ion-item>
<ion-item>
If you used pH paper, compare the colour of the pH strip to the pH colour index provided.
</ion-item>
<ion-item>
<img src="assets/images/ph.png"><br>
</ion-item>
<ion-item>
<ion-label> The pH value is (1-14):</ion-label>
</ion-item>
<ion-item>
<ion-input [(ngModel)]="phvalue" type="number" inputmode="numeric" value="1" placeholder="Enter pH value here"></ion-input>
</ion-item>
<ion-item>
<ion-label><b>
Surface water colour</b>
</ion-label>
</ion-item>
<ion-item>
You can upload a photo of the colour of the water, avoiding reflections from the sun:
Hold the camera 40-45 degrees from horizontal towards the water surface.
With the sun straight at your back rotate 45 degrees to your left or your right to have the sun diagonally over your left or right shoulder.
</ion-item>
<ion-item>
<img src="assets/images/bodypos.png"><br>
</ion-item>
<ion-item>
To add a photo click here:
<ion-button (click)="takePicture()">
<ion-icon name="camera"></ion-icon>
</ion-button>
<img [src]="this.ColouratSurfacePictureTaken"/>
</ion-item>
<ion-item>
<ion-label>Looking at the water surface in this direction, the colour of the water is <br> most similar to Forel-Ule colour number (1-21):
</ion-label>
</ion-item>
<ion-item>
<ion-input [(ngModel)]="colouratsurface" type="number" inputmode="numeric" placeholder="Enter colour value here"></ion-input></ion-item>
<ion-item>
<ion-label><b>Quality control check-list</b></ion-label>
</ion-item>
<ion-item >
<ion-checkbox slot="start" [(ngModel)]="bottom_visible"></ion-checkbox>
<ion-label class="ion-text-wrap">The bottom was visible / the disk reached the bottom while I could still see it.</ion-label>
</ion-item>
<ion-item>
<ion-checkbox slot="start" [(ngModel)]="end_of_tape"></ion-checkbox>&nbsp; <ion-label class="ion-text-wrap">I reached the end of the tape while I could still see the disk</ion-label>
</ion-item>
<ion-item>
<ion-checkbox slot="start"></ion-checkbox><ion-label class="ion-text-wrap">
I was not able to keep the disk going down at a straight angle. The estimated distance angle is:</ion-label>
</ion-item>
<ion-item>
<ion-input [(ngModel)]="angle_estimated" type="number" inputmode="numeric" value="01" placeholder="Enter" ></ion-input><ion-label class="ion-text-wrap">
degrees from vertical.</ion-label>
</ion-item>
<ion-button expand="block" (click)="validate()" routerLink="/ms_measure/qccheck" routerDirection="forward">
Go to step 5
</ion-button>
</ion-content>

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