Quantcast
Channel: Devdactic – Ionic Tutorials
Viewing all 183 articles
Browse latest View live

How to Integrate Supabase in Your Ionic App

$
0
0

If you have used Firebase in the past, you might have come across Supabase, an open source Firebase alternative that’s currently in Beta but getting a lot of great feedback.

In this tutorial we will build a simple Ionic Angular application that uses Supabase as our cloud backend. We will be able to immediately integrate authentication, all CRUD functions on the Supabase database and also a way to observe our database changes in real time.

ionic-supabase

Be aware that this tutorial used a beta version of Supabase, so likely a lot inside it will change in the future. But it’s a starting point to see if it fits your needs better than Firebase.

We will not cover all the technical details and features of Supabase today but in a video at a later point. For now, let’s see how fast we can build our app!

Supabase Project Setup

To get started, simply create an account (or log in) to Supabase and then create a new organisation under which we can add a project.

Pick any name for your project and the database region close to your users, and set a password for the database.

ionic-supabas-setup

Now we need to wait a few minutes until the database is ready. This is a bit different from Firebase, because the database used by Supabase is a Postgres SQL database instead of NoSQL in Firebase.

Therefore, you also need to create the structure of your database upfront. I’m not a SQL expert, but luckily Supabase prepared some scripts for people like us to get started quickly and test everything.

Simply open the SQL tab inside the menu and you will see a bunch of scripts. We will pick the todo list, or you could also simply paste the following into the SQL editor there:

create table todos (
  id bigint generated by default as identity primary key,
  user_id uuid references auth.users not null,
  task text check (char_length(task) > 3),
  is_complete boolean default false,
  inserted_at timestamp with time zone default timezone('utc'::text, now()) not null
);

alter table todos enable row level security;

create policy "Individuals can create todos." on todos for
    insert with check (auth.uid() = user_id);

create policy "Individuals can view their own todos. " on todos for
    select using (auth.uid() = user_id);

create policy "Individuals can update their own todos." on todos for
    update using (auth.uid() = user_id);

create policy "Individuals can delete their own todos." on todos for
    delete using (auth.uid() = user_id);

This creates a new table with columns to create new todos, plus it adds row level security with a few policies so users can only see the data they created themself!

We might talk more about securing your Supabase app in the future, for now we will keep it just like this.

Ionic App Setup

Now we can get started with our Ionic app, and after setting up the project we install the Supabase JS client package and generate a few additional pages for our Ionic app:

ionic start devdacticSupa blank --type=angular --capacitor
cd ./devdacticSupa

npm install @supabase/supabase-js

ionic g page pages/login
ionic g page pages/list

ionic g service services/supabase

ionic g guard guards/auth --implements CanLoad

To connect our Ionic app to Supabase we need two values, which you can find inside the Settings tab in Supabase and further down in the API entry.

You can now copy the URL and the below listed anon key, which is used to create a connection to Supabase until a user signs in. We can put both of these values directly into our src/environments/environment.ts:

export const environment = {
  production: false,
  supabaseUrl: 'YOUR-URL',
  supabaseKey: 'YOUR-ANON-KEY'
};

Because we generated some pages and a guard, we can quickly fix our routing upfront inside the src/app/app-routing.module.ts with our new pages and the guard applied to our internal page:

import { NgModule } from '@angular/core';
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';
import { AuthGuard } from './guards/auth.guard';

const routes: Routes = [
  {
    path: 'login',
    loadChildren: () => import('./pages/login/login.module').then( m => m.LoginPageModule)
  },
  {
    path: 'list',
    loadChildren: () => import('./pages/list/list.module').then( m => m.ListPageModule),
    canLoad: [AuthGuard]
  },
  {
    path: '',
    redirectTo: 'login',
    pathMatch: 'full'
  },

];

@NgModule({
  imports: [
    RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })
  ],
  exports: [RouterModule]
})
export class AppRoutingModule { }

Now let’s work with some Supabase data!

Building a Supabase CRUD Service

As usual, we will build all the necessary logic for our app inside a service. To get establish a connection to Supabase, simply call the createClient() function right in the beginning with the information we added to our environment.

The automatic handling of stored user data didn’t work 100% for me, or in general could be improved. Loading the stored token from the Web storage (possibly using Ionic Storage for that in the future as a different Storage engine) is synchronous, but without awaiting the data inside loadUser() it didn’t quite work.

Besides that, you can listen to auth changes within onAuthStateChange to get notified when the user signs in or out. We will emit that information to our BehaviorSubject so we can later use it as an Observable for our guard.

The sign up and sign in are almost the same and only require a simple function of the Supabase JS package. What I found not optimal was that even an unsuccessful login fulfils the promise, while I would expect it to throw an error.

That’s the reason why I wrapped the calls in another Promise and called either resolve or reject depending of the result from Supabase, which makes it easier for our controller to handle the result in the next step without own logic!

Now get started with the src/app/services/supabase.service.ts and change it to:

import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { createClient, SupabaseClient, User } from "@supabase/supabase-js";
import { BehaviorSubject, Observable } from 'rxjs';
import { environment } from '../../environments/environment';

const TODO_DB = 'todos';

export interface Todo {
  id: number;
  inserted_at: string;
  is_complete: boolean;
  task: string;
  user_id: string;
}

@Injectable({
  providedIn: 'root'
})
export class SupabaseService {
  private _todos: BehaviorSubject<Todo[]> = new BehaviorSubject([]);
  private _currentUser: BehaviorSubject<any> = new BehaviorSubject(null);

  private supabase: SupabaseClient;

  constructor(private router: Router) {
    this.supabase = createClient(environment.supabaseUrl, environment.supabaseKey, {
      autoRefreshToken: true,
      persistSession: true
    });

    // Try to recover our user session
    this.loadUser();

    this.supabase.auth.onAuthStateChange((event, session) => {
      if (event == 'SIGNED_IN') {
        this._currentUser.next(session.user);
        this.loadTodos();
        this.handleTodosChanged();
      } else {
        this._currentUser.next(false);
      }
    });
  }

  async loadUser() {
    const user = await this.supabase.auth.user();

    if (user) {
      this._currentUser.next(user);
    } else {
      this._currentUser.next(false);
    }
  }

  get currentUser(): Observable<User> {
    return this._currentUser.asObservable();
  }

  async signUp(credentials: { email, password }) {
    return new Promise(async (resolve, reject) => {
      const { error, data } = await this.supabase.auth.signUp(credentials)
      if (error) {
        reject(error);
      } else {
        resolve(data);
      }
    });
  }

  signIn(credentials: { email, password }) {
    return new Promise(async (resolve, reject) => {
      const { error, data } = await this.supabase.auth.signIn(credentials)
      if (error) {
        reject(error);
      } else {
        resolve(data);
      }
    });
  }

  signOut() {
    this.supabase.auth.signOut().then(_ => {
      // Clear up and end all active subscriptions!
      this.supabase.getSubscriptions().map(sub => {
        this.supabase.removeSubscription(sub);
      });
      
      this.router.navigateByUrl('/');
    });
  }
}

There’s also a cool functionality that we can use after signup: Get all active subscriptions and remove them!

That means, you don’t have to come up with your own managed of Subscriptions in this case and can be sure everything is closed when a user logs out.

Now we got the authentication basics in place and can move on to the CRUD part and interaction with the Supabase database.

First of all, this looks mostly like building an SQL query. You can find all of the available functions and filters inside the Supabase documentation, but most of it is self explaining.

We use our constant database name and query the data, which returns a Promise. To keep the data now locally inside the service without reloading it all the time, we simply emit it to our BehaviourSubject by calling next().

When we add, remove or update a row in our database, we will also only call the according function and not directly handle the result, as we also integrate another handleTodosChanged() that listens to all database changes!

There is no realtime connection with the database like you might have used with Firebase, but by listening to the different change events you can easily manipulate your local data to add, remove or update an element from the array inside the BehaviourSubject.

If someone would now use the todos Observable, it would basically be like having a realtime connection to Firebase – but with Supabase!

Continue with the src/app/services/supabase.service.ts and add the following functions:

get todos(): Observable < Todo[] > {
    return this._todos.asObservable();
}

async loadTodos() {
    const query = await this.supabase.from(TODO_DB).select('*');
    this._todos.next(query.data);
}

async addTodo(task: string) {
    const newTodo = {
        user_id: this.supabase.auth.user().id,
        task
    };
    // You could check for error, minlegth of task is 3 chars!
    const result = await this.supabase.from(TODO_DB).insert(newTodo);
}

async removeTodo(id) {
    await this.supabase
        .from(TODO_DB)
        .delete()
        .match({ id })
}

async updateTodo(id, is_complete: boolean) {
    await this.supabase
        .from(TODO_DB)
        .update({ is_complete })
        .match({ id })
}

handleTodosChanged() {
    return this.supabase
        .from(TODO_DB)
        .on('*', payload => {
            console.log('Todos changed: ', payload);

            if (payload.eventType == 'DELETE') {
                // Filter out the removed item
                const oldItem: Todo = payload.old;
                const newValue = this._todos.value.filter(item => oldItem.id != item.id);
                this._todos.next(newValue);
            } else if (payload.eventType == 'INSERT') {
                // Add the new item
                const newItem: Todo = payload.new;
                this._todos.next([...this._todos.value, newItem]);
            } else if (payload.eventType == 'UPDATE') {
                // Update one item
                const updatedItem: Todo = payload.new;
                const newValue = this._todos.value.map(item => {
                    if (updatedItem.id == item.id) {
                        item = updatedItem;
                    }
                    return item;
                });
                this._todos.next(newValue);
            }
        }).subscribe();
}

Since we added the row level security to our database schema in the beginning, we don’t need to worry about filtering only data from the current user. This already happens on the server side now!

We got our authentication and CRUD logic finished, now let’s build some templates to test things out.

Supabase Login and Registration

To quickly build a simple sign up, get started by adding the ReactiveFormsModule to the src/app/pages/login/login.module.ts for our login:

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

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    IonicModule,
    LoginPageRoutingModule,
    ReactiveFormsModule
  ],
  declarations: [LoginPage]
})
export class LoginPageModule {}

Just like we did in other full navigation examples with Ionic
src/app/pages/login/login.page.ts, we craft a simple form to hold the user data.

On sign up or sign in, we use the according functions from our service, add some loading here and there and handle the errors now easily within the promise by using then/err to catch the different results.

Otherwise we would have to check the result in the then() block here, but I want to keep the controller here as dumb as possible and let the service handle the logic of that!

Therefore we can now simply open the src/app/pages/login/login.page.ts and change it to:

import { SupabaseService } from './../../services/supabase.service';
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { AlertController, LoadingController } from '@ionic/angular';
import { Router } from '@angular/router';

@Component({
  selector: 'app-login',
  templateUrl: './login.page.html',
  styleUrls: ['./login.page.scss'],
})
export class LoginPage implements OnInit {

  credentials: FormGroup;
 
  constructor(
    private fb: FormBuilder,
    private alertController: AlertController,
    private router: Router,
    private loadingController: LoadingController,
    private supabaseService: SupabaseService
  ) {}
 
  ngOnInit() {
    this.credentials = this.fb.group({
      email: ['', [Validators.required, Validators.email]],
      password: ['', Validators.required],
    });
  }
 
  async login() {
    const loading = await this.loadingController.create();
    await loading.present();
    
    this.supabaseService.signIn(this.credentials.value).then(async data => {
      await loading.dismiss();        
      this.router.navigateByUrl('/list', { replaceUrl: true });
    }, async err => {           
      await loading.dismiss();
      this.showError('Login failed', err.message);
    });
  }

  async signUp() {
    const loading = await this.loadingController.create();
    await loading.present();
    
    this.supabaseService.signUp(this.credentials.value).then(async data => {      
      await loading.dismiss();
      this.showError('Signup success', 'Please confirm your email now!');       
      }, async err => {           
      await loading.dismiss();
      const alert = await this.alertController.create({
        header: 'Registration failed',
        message: err.error.msg,
        buttons: ['OK'],
      });
      await alert.present();
    });
  }

  async showError(title, msg) {
    const alert = await this.alertController.create({
      header: title,
      message: msg,
      buttons: ['OK'],
    });
    await alert.present();
  }
}

Besides the loading and error logic, there’s not much to do for our class!

The according template is also quite easy, just adding the input fields for our form so we can quickly create and sign in users.

Go ahead with the src/app/pages/login/login.page.html and change it to:

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>Devdactic Supabase</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <form (ngSubmit)="login()" [formGroup]="credentials">
    <div class="input-group">
      <ion-item>
        <ion-input placeholder="john@doe.com" formControlName="email"></ion-input>
      </ion-item>
      <ion-item>
        <ion-input type="password" placeholder="password" formControlName="password"></ion-input>
      </ion-item>
    </div>

    <ion-button type="submit" expand="block" [disabled]="!credentials.valid">Log in</ion-button>
    <ion-button type="button" expand="block" (click)="signUp()" color="secondary">Sign up!
    </ion-button>
  </form>
</ion-content>

Now you should be able to create a new user. By default, that user has to confirm the email adress, that’s also why we don’t immediately log in new users.

If you don’t want this behaviour, you can also change it inside your Supabase app under Authentication -> Settings.

Working with Supabase Data

After the login, our user arrives on the list page. The idea is to get all the data from our service, which will emit any new values to the internal BehaviourSubject to which we can subscribe from the outside.

Therefore the items variable is now an Observable, and we can use the async pipe inside the view later to get all the new values every time something changes.

In terms of changes, we start by showing an alert with an input field to capture data for a new todo. The other functionalities to delete or toggle the completion of a todo are simply passed to our service with the right information about the object!

Get started with the list logic by changing the src/app/pages/list/list.page.ts to:

import { SupabaseService, Todo } from './../../services/supabase.service';
import { Component, OnInit } from '@angular/core';
import { AlertController } from '@ionic/angular';

@Component({
  selector: 'app-list',
  templateUrl: './list.page.html',
  styleUrls: ['./list.page.scss'],
})
export class ListPage implements OnInit {

  items = this.supabaseService.todos;

  constructor(private supabaseService: SupabaseService, private alertCtrl: AlertController) { }

  ngOnInit() { }

  async createTodo() {
    const alert = await this.alertCtrl.create({
      header: 'New todo',
      inputs: [
        {
          name: 'task',
          placeholder: 'Learn Ionic'
        }
      ],
      buttons: [
        {
          text: 'Cancel',
          role: 'cancel'
        },
        {
          text: 'Add',
          handler: (data: any) => {
            this.supabaseService.addTodo(data.task);
          }
        }
      ]
    });

    await alert.present();
  }

  delete(item: Todo) {    
    this.supabaseService.removeTodo(item.id);
  }

  toggleDone(item: Todo) {
    this.supabaseService.updateTodo(item.id, !item.is_complete);
  }

  signOut() {
    this.supabaseService.signOut();
  }
}

Inside the template we can now iterate all available todos and print out some information about them. By using the ion-item-sliding we can also add some option buttons to the sides to toggle the completion or delete a todo.

Besides that we can add some logic to dynamically change colors or an icon based on the is_complete property of a todo.

Nothing really fancy, so go ahead and change the src/app/pages/list/list.page.html to:

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>My Todos</ion-title>
    <ion-buttons slot="end">
      <ion-button (click)="signOut()">
        <ion-icon slot="icon-only" name="log-out"></ion-icon>
      </ion-button>
    </ion-buttons>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-list>
    <ion-list>
      <ion-item-sliding *ngFor="let todo of items | async">
        <ion-item>
          <ion-label>{{ todo.task }}
            <p>{{ todo.inserted_at | date:'short' }}</p>
          </ion-label>
          <ion-icon name="checkbox-outline" slot="end" color="success" *ngIf="todo.is_complete"></ion-icon>
        </ion-item>

        <ion-item-options side="end">
          <ion-item-option (click)="delete(todo)" color="danger">
            <ion-icon name="trash" slot="icon-only"></ion-icon>
          </ion-item-option>
        </ion-item-options>

        <ion-item-options side="start">
          <ion-item-option (click)="toggleDone(todo)" [color]="todo.is_complete ? 'warning' : 'success'">
            <ion-icon [name]="todo.is_complete ? 'close' : 'checkmark'" slot="icon-only"></ion-icon>
          </ion-item-option>
        </ion-item-options>
      </ion-item-sliding>
    </ion-list>
  </ion-list>

  <ion-fab vertical="bottom" horizontal="end" slot="fixed">
    <ion-fab-button (click)="createTodo()">
      <ion-icon name="add"></ion-icon>
    </ion-fab-button>
  </ion-fab>
</ion-content>

That’s already everything to create basically the same kind of realtime logic that you are used from Firebase collections.

All changes to our data are sent to Supabase, and since we listen to changes of our table we can locally handle the rest without reloading the whole table of data all the time!

Authentication with Guard

Right now every user could access the list page, so we need to protect it with a guard.

We’ve used basically the same logic in other places before, the idea is to use the currentUser Observable returned by our service to check if a user is authenticated.

Because the BehaviourSubject inside the service is initialised with null, we need to filter out this initial value,

Afterwards, the information of a user is retrieved from the Storage and we can use that value to check if we have a user or not, and then allow access or send the user back to the login.

Therefore wrap up our example by changing the src/app/guards/auth.guard.ts to:

import { SupabaseService } from './../services/supabase.service';
import { Injectable } from '@angular/core';
import { CanLoad, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { filter, map, take } from 'rxjs/operators'

@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanLoad {

  constructor(private supabaseService: SupabaseService, private router: Router) { }
 
  canLoad(): Observable<boolean> {   
    return this.supabaseService.currentUser.pipe(
      filter(val => val !== null), // Filter out initial Behaviour subject value
      take(1), // Otherwise the Observable doesn't complete!
      map(isAuthenticated => {
        if (isAuthenticated) {          
          return true;
        } else {          
          this.router.navigateByUrl('/')
          return false;
        }
      })
    );
  }
}

Now you can log in and refresh an internal page, and the page only loads when the user was authenticated before!

Conclusion

This simple example of Ionic with Supabase was my first try and I really enjoyed working with it so far. It’s still in beta so things might change, but the whole idea and approach feels already pretty close to what Firebase offers!

On top of that, your Supabase project directly offers an API that also comes with Swagger docs. Their docs also show a cool example of generating TS interfaces based on that Swagger information, like running:

npx @manifoldco/swagger-to-ts https://your-project.supabase.co/rest/v1/?apikey=your-anon-key --output types/supabase.ts

Just add your URL and key and you got your interfaces ready!

One additional note: We injected our service in the first page shown in our app, therefore the createClient() is called right away. If you have a different kind of routing, make sure you still inject it in the first page or call some kind of init() of your service right inside the app.component.ts.

You can also find a video version of this tutorial below.

The post How to Integrate Supabase in Your Ionic App appeared first on Devdactic - Ionic Tutorials.


Ionic Responsive Design and Navigation for All Screens

$
0
0

You already know that you can create web and mobile apps from one codebase with Ionic, but having a responsive design that looks good on all platforms is sometimes challenging.

By default Ionic apps look on the web not like class web apps, and more like mobile apps on bigger screens.

In this tutorial we will take the necessary steps to build upon a general menu routing but use a more traditional navigation bar on bigger screens.

ionic-responsive-design

To achieve this we will use a custom component that can be used easily everywhere in our app, we’ll use media queries to define custom CSS and work with the ion-grid for more responsive internal pages!

Setting up our Responsive Ionic App

To get started, bring up a new Ionic app and add a few pages for our routing. On top of that we need a module for our custom header component that we will create:

ionic start devdacticWebsite blank --type=angular --capacitor
cd ./devdacticWebsite

ionic g page pages/menu
ionic g page pages/home
ionic g page pages/products
ionic g page pages/about

ionic g module components/sharedComponents --flat
ionic g component components/header

Our menu routing will happen inside a different file, so we need to clean up the src/app/app-routing.module.ts to just hold a reference to that file now:

import { NgModule } from '@angular/core';
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';

const routes: Routes = [
  {
    path: '',
    loadChildren: () => import('./pages/menu/menu.module').then(m => m.MenuPageModule)
  }
];

@NgModule({
  imports: [
    RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })
  ],
  exports: [RouterModule]
})
export class AppRoutingModule { }

Because we will make a simple HTTP call to show some dummy data we also need to include the HttpClientModule in our src/app/app.module.ts:

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 { HttpClientModule } from '@angular/common/http';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule,
    HttpClientModule],
  providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
  bootstrap: [AppComponent]
})
export class AppModule { }

Finally we gonna add a background to our content pages to distinguish it a bit better from our header, so open the src/global.scss and add:

ion-content {
    --background: #e9e9e9;
}

Now we are ready to build out our app!

Building a Global Header Component

First step is to create the header component, and because we used a custom module we now need to declare and export it inside the src/app/components/shared-components.module.ts:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HeaderComponent } from './header/header.component';
import { IonicModule } from '@ionic/angular';
import { RouterModule } from '@angular/router';

@NgModule({
  declarations: [HeaderComponent],
  imports: [
    CommonModule,
    IonicModule,
    RouterModule
  ],
  exports: [HeaderComponent]
})
export class SharedComponentsModule { }

The purpose of this component depends on the screen size:

  • Small screens: Show the default Ionic toolbar with menu button and dynamic title
  • Big screens: Present a custom navigation bar with links and dropdown box

The first part is achieved quite easily, as it looks like the default in Ionic apps. We will handle the hide/show with a media query from CSS later, but we could also use an *ngIf to completely disable that area or use an approach we used to Create a Horizontal Navigation for Ionic Desktop Views in the past.

The navigation bar for bigger screens can be accomplished by using the grid component (or use whatever you prefer like flexbox). Inside we will use the routes to our pages which we will set up inside the menu in the next step, and since we got a few properties let’s take a look at them:

  • routerDirection: Affects how new pages appear, but for menu routing root is most of the time the best
  • routerLinkActive: A CSS class that is added to an element when this is the active route
  • routerLinkActiveOptions: Used to make sure that the empty path “/” is not activated as well and only exact matches count

For the dropdown, we could either use a simple hover from CSS but I felt like the approach with manual control and reacting to
mouseenter and mouseleave work better in this case, since we basically leave the button hover area when we go into the dropdown list with a cursor.

The items in the dropdown itself are pretty boring and follow the usual routing, I just added a query param as an example (the Angular way!).

Now go ahead and change the src/app/components/header/header.component.html to:

<ion-header class="mobile-header">
  <ion-toolbar>
    <ion-buttons slot="start">
      <ion-menu-button menu="myMenu"></ion-menu-button>
    </ion-buttons>
    <ion-title>{{ title }}</ion-title>
    <ion-buttons slot="end">
      <ion-button>
        <ion-icon slot="icon-only" name="cart"></ion-icon>
      </ion-button>
    </ion-buttons>
  </ion-toolbar>
</ion-header>

<ion-row class="header ion-align-items-center">
  <ion-col size="2" class="ion-align-items-center">
    <img src="https://ionicacademy.com/wp-content/uploads/2020/02/ionic-Logo.svg" width="50px">
  </ion-col>
  <ion-col size="8" class="ion-text-left">
    <ion-button fill="clear" color="dark" routerLink="/" routerDirection="root" routerLinkActive="active-item"
      [routerLinkActiveOptions]="{exact: true}">
      Home
    </ion-button>
    <!-- Button with dropdown -->
    <ion-button #productbtn (mouseenter)="dropdown=true" (mouseleave)="hideDropdown($event)" fill="clear" color="dark"
      routerLink="/products" routerDirection="root" routerLinkActive="active-item">
      Products
      <ion-icon slot="end" [name]="dropdown ? 'chevron-down' : 'chevron-up'"></ion-icon>
    </ion-button>
    <div *ngIf="dropdown" class="dropdown" (mouseleave)="dropdown = false" #dropdownbox>
      <ion-item (click)="dropdown = false" routerLink="/products" [queryParams]="{ category: 'popular'}" lines="none" routerDirection="root" >
        Popular
      </ion-item>
      <ion-item (click)="dropdown = false" button routerLink="/products" [queryParams]="{ category: 'cloth'}"
        lines="none" routerDirection="root" >
        Cloth
      </ion-item>
      <ion-item (click)="dropdown = false" button routerLink="/products" [queryParams]="{ category: 'gadgets'}"
        lines="none" routerDirection="root">
        Gadgets
      </ion-item>
    </div>
    <ion-button fill="clear" color="dark" routerLink="/about" routerDirection="root" routerLinkActive="active-item">
      About
    </ion-button>
  </ion-col>
  <ion-col size="2" class="ion-text-right">
    <ion-button fill="clear" color="dark">
      <ion-icon slot="icon-only" name="cart"></ion-icon>
    </ion-button>
  </ion-col>

</ion-row>

As said before, we can now manage when the dropdown should be hidden again from code. And here we can check if we left with the mouse event to the left boundary, to the right or to the top – leaving to the bottom is fine since that’s the place where the dropdown itself is displayed!

Besides that we define an Input so we can dynamically pass in the title on smaller screens and use it as the page title.

Continue by opening the src/app/components/header/header.component.ts and change it to:

import { Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core';

@Component({
  selector: 'app-header',
  templateUrl: './header.component.html',
  styleUrls: ['./header.component.scss'],
})
export class HeaderComponent implements OnInit {

  @Input() title: string;
  dropdown = false;

  @ViewChild('productbtn', { read: ElementRef })productbtn: ElementRef;

  constructor() { }

  ngOnInit() { }

  hideDropdown(event) {
    const xTouch = event.clientX;
    const yTouch = event.clientY;
    
    const rect = this.productbtn.nativeElement.getBoundingClientRect();
    const topBoundary = rect.top+2;
    const leftBoundary = rect.left+2;
    const rightBoundary = rect.right-2;

    if (xTouch < leftBoundary || xTouch > rightBoundary || yTouch < topBoundary) {      
      this.dropdown = false;
    }
  }
}

Note: I added a few pixels to the boundaries to make sure any hover event that goes just slightly over the edge triggers the disappearance.

The last step now is to add our media query to dynamically switch between our different navigation bar elements in our custom component, one of the elements discussed in our 10 Tips & Tricks for Building Websites with Ionic as well.

I’ve simply decided that 768px would be a good breakpoint – feel free to use anything that works better for you!

So now we can add the rules to our src/app/components/header/header.component.scss like this:

@media(min-width: 768px) {
  .mobile-header {
    display: none;
  }

  .header {
    display: flex;
    background: #fff;
    padding-left: 40px;
    padding-right: 40px;
  }
}

@media(max-width: 768px) {
  .mobile-header {
    display: block;
  }

  .header {
    display: none;
  }
}

.active-item {
  border-bottom: 2px solid var(--ion-color-primary);
}

.dropdown {
    width: 136px;
    height: 150px;
    background: #fff;
    position: absolute;
    top: 40px;
    left: 87px;
    z-index: 1;
    ion-item:hover {
        --ion-item-color: var(--ion-color-primary);
    }
}

For the dropdown we use some fixed values to position it correctly, and we also applied a hover to the items within the dropdown.

Now we got the component but it’s not yet visible – let’s change that!

Creating the Menu

First of all we need to include the module of our component in order to use the cool header component we created.

Therefore, open the src/app/pages/menu/menu.module.ts and insert it like this:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';

import { IonicModule } from '@ionic/angular';

import { MenuPageRoutingModule } from './menu-routing.module';

import { MenuPage } from './menu.page';
import { SharedComponentsModule } from '../../components/shared-components.module';

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    IonicModule,
    MenuPageRoutingModule,
    SharedComponentsModule
  ],
  declarations: [MenuPage]
})
export class MenuPageModule {}

Now we can also define our routing with the 3 pages we generated in the beginning. For this, open the src/app/pages/menu/menu-routing.module.ts and add these pages inside the children array of our parent menu page:

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

import { MenuPage } from './menu.page';

const routes: Routes = [
  {
    path: '',
    component: MenuPage,
    children: [
      {
        path: '',
        loadChildren: () => import('../home/home.module').then( m => m.HomePageModule)
      },
      {
        path: 'products',
        loadChildren: () => import('../products/products.module').then( m => m.ProductsPageModule)
      },
      {
        path: 'about',
        loadChildren: () => import('../about/about.module').then( m => m.AboutPageModule)
      }
    ]
  }
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule],
})
export class MenuPageRoutingModule {}

The menu page now holds a list for our navigation, and we will define an additional HostListener so we can disable the menu. The reason is simple, the menu while not visible through a button could still be pulled in from the side on bigger screens.

Therefore, we check the width in the beginning and then handle it through our listener using the breakpoint of our app later.

Go ahead and change the src/app/pages/menu/menu.page.ts to:

import { Component, HostListener, OnInit } from '@angular/core';
import { MenuController, Platform } from '@ionic/angular';

@Component({
  selector: 'app-menu',
  templateUrl: './menu.page.html',
  styleUrls: ['./menu.page.scss'],
})
export class MenuPage implements OnInit {

  menuItems = [
    {
      title: 'Home',
      icon: 'home',
      path: '/'
    },
    {
      title: 'Products',
      icon: 'list',
      path: '/products'
    },
    {
      title: 'About',
      icon: 'information',
      path: '/about'
    }
  ];

  title = 'Home';

  constructor(private menuCtrl: MenuController, private plt: Platform) { }

  ngOnInit() {
    const width = this.plt.width();
    this.toggleMenu(width);
  }

  @HostListener('window:resize', ['$event'])
  private onResize(event) {
    const newWidth = event.target.innerWidth;
    this.toggleMenu(newWidth);
  }

  toggleMenu(width) {
    if (width > 768) {
      this.menuCtrl.enable(false, 'myMenu');
    } else {
      this.menuCtrl.enable(true, 'myMenu');
    }
  }

  setTitle(title) {
    this.title = title
  }
  
}

The template for our menu follows the standard markup and displays the menu elements based on the information of our array.

The important part is now adding our custom header component: We add it above the router outlet, which means it’s always visible and won’t reload all the time while navigating!

The content of the pages will be rendered inside the router outlet as usual, and we need to add a bit of margin to move that down in the next step.

For now, open the src/app/pages/menu/menu.page.html and change it to:

<ion-menu contentId="main" menuId="myMenu" side="start">
  <ion-header>
    <ion-toolbar>
      <ion-title>Menu</ion-title>
    </ion-toolbar>
  </ion-header>
  <ion-content>
    <ion-menu-toggle *ngFor="let item of menuItems">
      <ion-item button (click)="setTitle(item.title)" [routerLink]="item.path" routerDirection="root"
        routerLinkActive="active-item" [routerLinkActiveOptions]="{exact: true}">
        <ion-icon [name]="item.icon" slot="start"></ion-icon>
        {{ item.title }}
      </ion-item>
    </ion-menu-toggle>
  </ion-content>
</ion-menu>

<!-- Global App Header -->
<app-header [title]="title"></app-header>

<ion-router-outlet id="main"></ion-router-outlet>

Because the outlet would usually cover the whole screen and would overlay our header, we now move it down a bit on the different screen sizes. You might have to tweak the margin a bit depending on the size of your navigation bar and elements within, but you can get started for now by changing the src/app/pages/menu/menu.page.scss to:

@media(min-width: 768px) {
  ion-router-outlet {
    margin-top: 63px;
  }
}

@media(max-width: 768px) {
    ion-router-outlet {
        margin-top: 56px;
      }
}

.active-item {
    color: var(--ion-color-primary);
    font-weight: 500;
    ion-icon {
        color: var(--ion-color-primary);
    }
}

We now got the whole navigation and global header in place, but if you haven’t touched the additional pages you will still see their headers. Let’s change all of them to display some more responsive elements!

Building Responsive Pages with Grid

One example would be adding a little hero background image to the starting page, and we can do this right inside of our src/app/pages/home/home.page.html:

<ion-content>
  <div class="hero"></div>
</ion-content>

Yes, we really don’t need the header anymore now – we have our global header already anyway!

For the CSS you could now define a little background image like this inside the src/app/pages/home/home.page.scss:

.hero {
    background-image: url('https://images.unsplash.com/photo-1472851294608-062f824d29cc');
    background-repeat: no-repeat;
    background-position: center;
    background-size: cover;
    height: 30vh;
    width: 100%;
}

If you want to make your views responsive when using the grid, you can define how much space it used on different screen sizes.

An example could look like this inside the src/app/pages/about/about.page.html:

<ion-content>
  <ion-grid>
    <ion-row class="ion-justify-content-center">
      <ion-col size="12" size-md="10" size-lg="8" size-xl="6">
        Lorem ipsum dolor sit amet, consectetur adipiscing elit....
      </ion-col>
    </ion-row>
  </ion-grid>
</ion-content>

Simply add a bit more of text inside and see how it changes when making the window bigger or smaller. The trick is that we only have one column, but by using ion-justify-content-center it we automatically get the nice effect of not filling out the whole screen.

On top of that we wanted to create a little product showcase for which we will pull in some data inside the src/app/pages/products/products.page.ts:

import { HttpClient } from '@angular/common/http';
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';

@Component({
  selector: 'app-products',
  templateUrl: './products.page.html',
  styleUrls: ['./products.page.scss'],
})
export class ProductsPage implements OnInit {

  products: Observable<any>;

  constructor(private http: HttpClient) { }

  ngOnInit() {
    this.products = this.http.get('https://fakestoreapi.com/products');
  }
}

With the async pipe we can now iterate the values and again define different sizes so we see either 1, 2, 3 or 6 elements within our row at the same time, depending on the defined size for each column by breakpoint.

Finish our app by changing the src/app/pages/products/products.page.html to this now:

<ion-content>
  <ion-grid>
    <ion-row class="ion-justify-content-center">
      <ion-col size="12" size-sm="6" size-md="4" size-lg="3" size-xl="2" *ngFor="let p of products | async">
        <ion-card>
          <img [src]="p.image">
          <ion-card-content>
            <ion-label>
              {{ p.title}}
              <p>{{ p.price | currency:'USD' }}</p>
            </ion-label>
          </ion-card-content>
        </ion-card>
      </ion-col>
    </ion-row>
  </ion-grid>
</ion-content>

If you also want to apply more overall padding to the grid on different screen sizes, you could now easily tweak that by setting the according CSS variable inside the src/app/pages/products/products.page.scss:

ion-grid {
    --ion-grid-padding-sm: 20px;
    --ion-grid-padding-md: 30px;
    --ion-grid-padding-lg: 40px;
    --ion-grid-padding-xl: 100px;
}

And we are done with our responsive Ionic app that looks amazing on both mobile and the web!

Conclusion

By default the UI for Ionic apps doesn’t look like a website, but with a few tricks you can achieve a great result that works across all devices and works from one codebase.

If you got any question or want to see more about building websites with Ionic, simply leave a comment below!

You can also find a video version of this tutorial below.

The post Ionic Responsive Design and Navigation for All Screens appeared first on Devdactic - Ionic Tutorials.

How to Handle User Roles in Ionic Apps with Guard & Directives

$
0
0

When working with user accounts, you sometimes need to handle multiple user roles within your Ionic app or even specific permissions users might have

In this tutorial we will handle both cases with different user roles as well as more fine granulated user permission for specific actions.

We’ll not build a backend and rely on some fake dummy data, but we’ll make sure our Ionic app handles roles and permissions correctly.

ionic-roles-app

That means we will hide certain elements based on roles using directives, or prevents access to pages with detailed role check through a guard!

Starting our Roles App

To get started, we bring up a blank new Ionic app and generate two pages, a service that holds the state of our user, two directives to show/hide elements based on roles or permissions and finally a guard to protect our inside pages:

ionic start devdacticRoles blank --type=angular --capacitor
cd ./devdacticRoles

# Pages for testing
ionic g page pages/login
ionic g page pages/secret

# Handle the current user
ionic g service services/auth

ionic g module directives/SharedDirectives --flat
ionic g directive directives/hasPermission   
ionic g directive directives/disableRole

# Guard to protect pages
ionic g guard guards/auth --implements CanActivate

To apply the new routes we can already change our setup inside the src/app/app-routing.module.ts now:

import { NgModule } from '@angular/core';
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';
import { AuthGuard } from './guards/auth.guard';

const routes: Routes = [
  {
    path: '',
    loadChildren: () => import('./pages/login/login.module').then( m => m.LoginPageModule)
  },
  {
    path: 'home',
    loadChildren: () => import('./home/home.module').then( m => m.HomePageModule),
    canActivate: [AuthGuard]
  },
  {
    path: 'secret',
    loadChildren: () => import('./pages/secret/secret.module').then( m => m.SecretPageModule),
    canActivate: [AuthGuard],
    data: {
      role: 'ADMIN'
    }
  }
];

@NgModule({
  imports: [
    RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })
  ],
  exports: [RouterModule]
})
export class AppRoutingModule { }

We already see the first element, the authentication guard applied to our internal area routes. Right now it will allow all access, but we will build this out later so we either allow access only for authorised users (like the home route) or make this even dependent on a user role (like the secret route).

Authentication Service & Dummy Login

Before we dive into our directives and guards, we first need a bit of logic to fake the login process that usually makes a call to your backend and gets back the user information and any potential roles or permissions for a certain user.

Like done before, we will handle the current user information with a BehaviorSubject to which we can easily emit new values.

Here are our functions in detail:

  • loadUser: Try to load a token or user information from Storage right in the beginning. We have some plain information, but you usually have something like a JWT in there so you can directly log in a user
  • signIn: Our dummy login function that simply checks the name and fakes information that you would get from a server. We got a standard user with basic permissions and an admin user, but you could of course have even more in your app.
  • hasPermission: Check if inside the array of a user a set of permissions is included

Besides that we only return an Observable of our BehaviourSubject so outside pages can’t change the value and only read and subscribe to the changes. The logout in the end will clear all user information and bring us back to the login.

Go ahead with our dummy service and change the src/app/services/auth.service.ts to:

import { Injectable } from '@angular/core';
import { BehaviorSubject, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Plugins } from '@capacitor/core';
import { Router } from '@angular/router';

const { Storage } = Plugins;

const TOKEN_KEY = 'user-token';

export interface User {
  name: string;
  role: string;
  permissions: string[];
}

@Injectable({
  providedIn: 'root'
})
export class AuthService {
  private currentUser: BehaviorSubject<any> = new BehaviorSubject(null);

  constructor(private router: Router) {
    this.loadUser();
  }

  loadUser() {
    // Normally load e.g. JWT at this point
    Storage.get({ key: TOKEN_KEY }).then(res => {
      if (res.value) {
        this.currentUser.next(JSON.parse(res.value));
      } else {
        this.currentUser.next(false);
      }
    });
  }

  signIn(name) {
    // Local Dummy check, usually server request!
    let userObj: User;

    if (name === 'user') {
      userObj = {
        name: 'Tony Test',
        role: 'USER',
        permissions: ['read']
      };
    } else if (name === 'admin') {
      userObj = {
        name: 'Adam Admin',
        role: 'ADMIN',
        permissions: ['read', 'write']
      };
    }

    return of(userObj).pipe(
      tap(user => {
        // Store the user or token
        Storage.set({ key: TOKEN_KEY, value: JSON.stringify(user) })
        this.currentUser.next(user);
      })
    );
  }

  // Access the current user
  getUser() {
    return this.currentUser.asObservable();
  }

  // Remove all information of the previous user
  async logout() {
    await Storage.remove({ key: TOKEN_KEY });
    this.currentUser.next(false);
    this.router.navigateByUrl('/', { replaceUrl: true });
  }

  // Check if a user has a certain permission
  hasPermission(permissions: string[]): boolean {
    for (const permission of permissions) {
      if (!this.currentUser.value || !this.currentUser.value.permissions.includes(permission)) {
        return false;
      }
    }
    return true;
  }
}

Note that we always emit false to the current user when we log out or don’t have stored information, which is necessary to distinguish from the initial null value of our Behaviour Subject!

Now we can create a simple login inside the src/app/pages/login/login.page.ts by using our service:

import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../../services/auth.service';

@Component({
  selector: 'app-login',
  templateUrl: './login.page.html',
  styleUrls: ['./login.page.scss'],
})
export class LoginPage implements OnInit {

  constructor(private auth: AuthService, private router: Router) { }

  ngOnInit() { }

  signIn(userName) {
    this.auth.signIn(userName).subscribe(user => {
      // You could now route to different pages
      // based on the user role
      // let role = user['role'];

      this.router.navigateByUrl('/home', {replaceUrl: true });
    });
  }
}

We don’t use role based routing in this tutorial, but you could now easily guide users to different pages inside your app based on the role after the login!

To use the login we finally also need a simple view, so change the src/app/pages/login/login.page.html to:

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>
      Ionic Role Auth
    </ion-title>
  </ion-toolbar>
</ion-header>

<ion-content padding>
  <ion-button expand="full" (click)="signIn('admin')">Login as Admin</ion-button>
  <ion-button expand="full" (click)="signIn('user')">Login as User</ion-button>

  <ion-button expand="full" routerLink="/home">
    Open home page (Authorized only)</ion-button>
</ion-content>

Now we can already navigate inside our app, but we don’t really have any kind of authentication in place yet.

Protecting Pages with a Guard

First of all we will protect the pages that only logged in users should be able to see. Keep in mind that this is only half of the security, the more important security always happens inside your API and all your endpoints should be protected accordingly.

Anyway, we need a way to change between users so let’s start by adding a quick logout to the src/app/home/home.page.ts:

import { Component } from '@angular/core';
import { AuthService } from '../services/auth.service';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})
export class HomePage {

  user = this.authService.getUser();
  
  constructor(private authService: AuthService) {}

  logout() {
    this.authService.logout();
  }
}

To call the function, we will also add a button to the src/app/home/home.page.html and print out some information about the current user for debugging:

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>
      Inside Area
    </ion-title>
    <ion-buttons slot="end">
      <ion-button (click)="logout()">
        <ion-icon slot="icon-only" name="log-out"></ion-icon>
      </ion-button>
    </ion-buttons>
  </ion-toolbar>
</ion-header>

<ion-content>

  <ion-card>
    <ion-card-content>
      <p>Current user: {{ user | async | json }}</p>
    </ion-card-content>
  </ion-card>

  <ion-button expand="full" routerLink="/secret">
    Open secret page (Admin only)</ion-button>

</ion-content>

To complete the navigation you can also add a little back button to the src/app/pages/secret/secret.page.html:

<ion-header>
  <ion-toolbar color="secondary">
    <ion-buttons slot="start">
      <ion-back-button defaultHref="/home"></ion-back-button>
    </ion-buttons>
    <ion-title>Secret Admin Page</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>

</ion-content>

Now the protection starts: We want to prevent unauthorised access, and if any specific role for a route was supplied (check back the app.routing and the role we added!) we also want to make sure the user has that role.

Within the guard we can now use the Observable from our service, filter out the initial null value and then check if the user is authenticated in general, and if there are any roles specified for this route, we also check if the user has the necessary role.

Go ahead and change the src/app/guards/auth.guard.ts to this now:

import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, CanActivate } from '@angular/router';
import { AlertController } from '@ionic/angular';
import { AuthService } from '../services/auth.service';
import { take, map, filter } from 'rxjs/operators';

@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanActivate {

  constructor(private router: Router, private authService: AuthService, private alertCtrl: AlertController) { }


  canActivate(route: ActivatedRouteSnapshot) {
    // Get the potentially required role from the route
    const expectedRole = route.data?.role || null;

    return this.authService.getUser().pipe(
      filter(val => val !== null), // Filter out initial Behaviour subject value
      take(1),
      map(user => {   
             
        if (!user) {
          this.showAlert();
          return this.router.parseUrl('/')
        } else {
          let role = user['role'];

          if (!expectedRole || expectedRole == role) {
            return true;
          } else {
            this.showAlert();
            return false;
          }
        }
      })
    )
  }

  async showAlert() {
    let alert = await this.alertCtrl.create({
      header: 'Unauthorized',
      message: 'You are not authorized to visit that page!',
      buttons: ['OK']
    });
    alert.present();
  }
}

Now the guard works and you can test the behaviour with our two users, from which only the admin is allowed to enter the secret page.

Note: We used canActivate for our guard here instead of canLoad. The different is usually that the later prevents loading of the module, while the first will actually load the module but then prevent access to the page.

However, if a user accessed a page that is protected with a canLoad guard, the module loads. If the user now signs out and another user signs in on that same device, that user would always have access to the route as well because the module was now loaded already!

It’s something to keep in mind when picking the right guard functionality for your app.

Custom Role Directives

After protecting full pages, we go into more details about specific elements inside a page. And if you want to change how certain elements work based on user roles and permissions, you can create some cool directives!

First of all we need to make sure that other pages can use the directives we generated in the beginning, so open the
src/app/directives/shared-directives.module.ts and add them to the exports and declarations:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DisableRoleDirective } from './disable-role.directive';
import { HasPermissionDirective } from './has-permission.directive';

@NgModule({
  declarations: [HasPermissionDirective, DisableRoleDirective],
  imports: [
    CommonModule
  ],
  exports: [HasPermissionDirective, DisableRoleDirective]
})
export class SharedDirectivesModule { }

Now we can dive into the first of our directives that should prevent a user from seeing a certain DOM element. To achieve this, we can use the reference to the template where our directive is attached and either call createEmbeddedView() to embed the element or clear() to remove it, based on the permission of a user.

The permissions will be passed to the directive through the @Input() we defined at the top, and will be checked with the hasPermission() of our service.

Get started with the first directive by changing the src/app/directives/has-permission.directive.ts to:

import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
import { AuthService } from '../services/auth.service';

@Directive({
  selector: '[appHasPermission]'
})
export class HasPermissionDirective {

  @Input('appHasPermission') permissions: string[];

  constructor(private authService: AuthService,
    private templateRef: TemplateRef<any>,
    private viewContainer: ViewContainerRef) { }

  ngOnInit() {
    this.authService.getUser().subscribe(_ => {
      if (this.authService.hasPermission(this.permissions)) {
        this.viewContainer.createEmbeddedView(this.templateRef);
      } else {
        this.viewContainer.clear();
      }
    });
  }
}

The second directive should disabled a view element for a specific role of a user, which will again be passed to the directive. Tip: If you name the input exactly like the directive itself you can later pass that value quite easily to the directive!

We follow mostly the same approach but use the Renderer2 to directly change the appearance of the host element where the directive is attached.

Go ahead now and also change the src/app/directives/disable-role.directive.ts to:

import { Directive, ElementRef, Input, Renderer2 } from '@angular/core';
import { AuthService } from '../services/auth.service';

@Directive({
  selector: '[appDisableRole]'
})
export class DisableRoleDirective {

  @Input() disableForRole: string;

  constructor(private authService: AuthService,
    private renderer: Renderer2,
    public element: ElementRef) { }

  ngAfterViewInit() {
    this.authService.getUser().subscribe(user => {
      const userRole = user['role'];

      if (userRole == this.disableForRole) {
        this.renderer.setStyle(this.element.nativeElement, 'pointer-events', 'none');
        this.renderer.setStyle(this.element.nativeElement, 'opacity', 0.4);
      }
    });
  }
}

With our directives in place we now need to load the module which exports the directives in the module where we plan to use them, in our case that’s the src/app/home/home.module.ts:

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 { SharedDirectivesModule } from '../directives/shared-directives.module';

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    IonicModule,
    HomePageRoutingModule,
    SharedDirectivesModule
  ],
  declarations: [HomePage]
})
export class HomePageModule {}

Finally we can attach our new directives to DOM elements to either hide them completely based on a permission (*appHasPermission) or otherwise disabled them based on a specific role (appDisableRole).

Wrap up our code by changing the src/app/home/home.page.html to:

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>
      Inside Area
    </ion-title>
    <ion-buttons slot="end">
      <ion-button (click)="logout()">
        <ion-icon slot="icon-only" name="log-out"></ion-icon>
      </ion-button>
    </ion-buttons>
  </ion-toolbar>
</ion-header>

<ion-content>

  <!-- Everything from before... -->

  <ion-card *appHasPermission="['read']">
    <ion-card-header>
      <ion-card-title>My Content</ion-card-title>
    </ion-card-header>
    <ion-card-content>
      Everyone can read this
      <ion-button expand="full" *appHasPermission="['write']">
        Admin action
      </ion-button>
    </ion-card-content>
  </ion-card>

  <ion-button expand="full" appDisableRole disableForRole="USER">Action for Admins</ion-button>
  <ion-button expand="full" appDisableRole disableForRole="ADMIN">Action for Users</ion-button>
</ion-content>

Now the button inside the card is only visible to users with the “write” permission, and the button below is only active for a specific role!

Conclusion

To protect your Ionic app based on user roles and permissions you can introduce guards and directives to control access to pages and even to specific view elements.

On top of that you could route after a login to different pages or set up menu items based on the user role as well to create applications with different functionality for different users without having if statements all over your apps code!

You can also find a video version of this tutorial below.

The post How to Handle User Roles in Ionic Apps with Guard & Directives appeared first on Devdactic - Ionic Tutorials.

Building a Deliveroo Food Ordering UI with Ionic

$
0
0

Building complex UIs with Ionic can be challenging, so today we will tackle another big app and build an epic food ordering application design with Ionic Angular together!

There are several apps like these, but I picked the Deliveroo app as it contains some cool animations and transitions between different components.

ionic-deliveroo

We will replicated the initial home screen with different slides and custom header animation plus the details screen with parallax image and a cool functionality to automatically track our position inside the page to update a sticky slides component.

If you want to see more apps like these come together, check out the other tutorials of the #builtwithionic series!

Also make sure to scroll to the end of the tutorial for a full video where you can see how everything slowly comes together.

Starting our Ionic App

We actually don’t need a lot today, just a blank app with one additional page and two directives that will be added to their own respective module:

ionic start foodUi blank --type=angular --capacitor
cd ./foodUi

ionic g page details

ionic g module directives/sharedDirectives --flat
ionic g directive directives/parallax
ionic g directive directives/hideHeader

Since we will load some dummy JSON data we also add the HttpClient to our src/app/app.module.ts

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 { HttpClientModule } from '@angular/common/http';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule, HttpClientModule],
  providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
  bootstrap: [AppComponent],
})
export class AppModule { }

Just to make sure your routing for the two pages is correct, also quickly check your src/app/app-routing.module.ts and make it look like this:

import { NgModule } from '@angular/core';
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';

const routes: Routes = [
  {
    path: 'home',
    loadChildren: () => import('./home/home.module').then( m => m.HomePageModule)
  },
  {
    path: 'details',
    loadChildren: () => import('./details/details.module').then( m => m.DetailsPageModule)
  },
  {
    path: '',
    redirectTo: 'home',
    pathMatch: 'full'
  }
];

@NgModule({
  imports: [
    RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })
  ],
  exports: [RouterModule]
})
export class AppRoutingModule { }

As a preparation we can also already declare and export our directives so we can’t forget about it later. To do so, bring up the src/app/directives/shared-directives.module.ts and change it to:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ParallaxDirective } from './parallax.directive';
import { HideHeaderDirective } from './hide-header.directive';

@NgModule({
  declarations: [ParallaxDirective, HideHeaderDirective],
  imports: [
    CommonModule
  ],
  exports: [ParallaxDirective, HideHeaderDirective]
})
export class SharedDirectivesModule { }

As both of our pages will need access to the directives, we can already include this new module in both the src/app/home/home.module.tssrc/app/details/details.module.ts like this:

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 { SharedDirectivesModule } from '../directives/shared-directives.module';

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    IonicModule,
    HomePageRoutingModule,
    SharedDirectivesModule
  ],
  declarations: [HomePage]
})
export class HomePageModule {}

That’s all for the structural setup, let’s get the party started!

Dummy Data

For this tutorial I’ve faked some data and content and you can find the two JSON files here:

You can use them from there or simply store them inside your application and change the two places where we call it to a local path so you don’t need any further connection while testing.

Custom Ionic Theme and Font

I’ve picked some colors from the official application and went to the Ionic color generator to create a new theme for our app. You can copy the result below and directly overwrite the part within your src/theme/variables.scss:

:root {
  /** primary **/
  --ion-color-primary: #02CCBC;
  --ion-color-primary-rgb: 2,204,188;
  --ion-color-primary-contrast: #000000;
  --ion-color-primary-contrast-rgb: 0,0,0;
  --ion-color-primary-shade: #02b4a5;
  --ion-color-primary-tint: #1bd1c3;

  --ion-color-secondary: #77bf2a;
  --ion-color-secondary-rgb: 119,191,42;
  --ion-color-secondary-contrast: #000000;
  --ion-color-secondary-contrast-rgb: 0,0,0;
  --ion-color-secondary-shade: #69a825;
  --ion-color-secondary-tint: #85c53f;

  /** tertiary **/
  --ion-color-tertiary: #5260ff;
  --ion-color-tertiary-rgb: 82, 96, 255;
  --ion-color-tertiary-contrast: #ffffff;
  --ion-color-tertiary-contrast-rgb: 255, 255, 255;
  --ion-color-tertiary-shade: #4854e0;
  --ion-color-tertiary-tint: #6370ff;

  /** success **/
  --ion-color-success: #2dd36f;
  --ion-color-success-rgb: 45, 211, 111;
  --ion-color-success-contrast: #ffffff;
  --ion-color-success-contrast-rgb: 255, 255, 255;
  --ion-color-success-shade: #28ba62;
  --ion-color-success-tint: #42d77d;

  /** warning **/
  --ion-color-warning: #ffc409;
  --ion-color-warning-rgb: 255, 196, 9;
  --ion-color-warning-contrast: #000000;
  --ion-color-warning-contrast-rgb: 0, 0, 0;
  --ion-color-warning-shade: #e0ac08;
  --ion-color-warning-tint: #ffca22;

  /** danger **/
  --ion-color-danger: #eb445a;
  --ion-color-danger-rgb: 235, 68, 90;
  --ion-color-danger-contrast: #ffffff;
  --ion-color-danger-contrast-rgb: 255, 255, 255;
  --ion-color-danger-shade: #cf3c4f;
  --ion-color-danger-tint: #ed576b;

  /** dark **/
  --ion-color-dark: #222428;
  --ion-color-dark-rgb: 34, 36, 40;
  --ion-color-dark-contrast: #ffffff;
  --ion-color-dark-contrast-rgb: 255, 255, 255;
  --ion-color-dark-shade: #1e2023;
  --ion-color-dark-tint: #383a3e;

  /** medium **/
  --ion-color-medium: #92949c;
  --ion-color-medium-rgb: 146, 148, 156;
  --ion-color-medium-contrast: #ffffff;
  --ion-color-medium-contrast-rgb: 255, 255, 255;
  --ion-color-medium-shade: #808289;
  --ion-color-medium-tint: #9d9fa6;

  /** light **/
  --ion-color-light: #ffffff;
  --ion-color-light-rgb: 255,255,255;
  --ion-color-light-contrast: #000000;
  --ion-color-light-contrast-rgb: 0,0,0;
  --ion-color-light-shade: #e0e0e0;
  --ion-color-light-tint: #ffffff;
}

Besides that I also added a custom font that looked similar which is free and called Plex. You can download it and put the files into your assets/fonts folder, then we simply need to define them and set them as the default font for our Ionic application in our src/global.scss like this:

@font-face {
  font-family: 'Plex';
  font-style: normal;
  font-weight: normal;
  src: url('/assets/fonts/IBMPlexSans-Regular.ttf');
}

@font-face {
  font-family: 'Plex';
  font-weight: bold;
  src: url('/assets/fonts/IBMPlexSans-Bold.ttf');
}

:root {
    --ion-text-color: #828585;
    --ion-font-family: 'Plex';
}

Already now we are really done with all the setup and can head over to UI!

Ionic Slides in Detail

We begin with the home view which mostly contains different slide elements and a header area that we will focus on later as it’s gonna be more challenging. The original app looks like below.

deliveroo_home

We can start this page by making our HTTP call to get the dummy data and put the different results into three arrays. We also define some slide options for each of our slides:

  1. First slide should show multiple slides at once and have a free mode
  2. Second slides should snap and stay centered, show a part of the other cards and run in a loop
  3. Third slide is mostly like the first

If you are unsure about the sliding options, simply take a look at the documentation for the underlying Swiper!

Now get started by changing the src/app/home/home.page.ts to:

import { HttpClient } from '@angular/common/http';
import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})
export class HomePage implements OnInit {
  categories = [];
  highlights = [];
  featured = [];

  catSlideOpts = {
    slidesPerView: 3.5,
    spaceBetween: 10,
    slidesOffsetBefore: 11,
    freeMode: true
  };

  highlightSlideOpts =  {
    slidesPerView: 1.05,
    spaceBetween: 10,
    centeredSlides: true,
    loop: true
  };

  featuredSlideOpts = {
    slidesPerView: 1.2,
    spaceBetween: 10,
    freeMode: true
  };

  showLocationDetail = false;

  constructor(private http: HttpClient) {}

  ngOnInit() {
    this.http.get('https://devdactic.fra1.digitaloceanspaces.com/foodui/home.json').subscribe((res: any) => {
      this.categories = res.categories;
      this.highlights = res.highlights;
      this.featured = res.featured;
    });
  }

  // Dummy refresher function
  doRefresh(event) {
    setTimeout(() => {
      event.target.complete();
    }, 2000);
  }

  // show or hide a location string later
  onScroll(ev) {
    const offset = ev.detail.scrollTop;
    this.showLocationDetail = offset > 40;
  }
}

With our dummy data being loaded we can create the view with our different slide elements and the according options.

Most of this is just basic stuff and using the data of our JSON, so continue with the src/app/home/home.page.html and change it to:

<ion-content>

  <ion-slides [options]="catSlideOpts">
    <ion-slide *ngFor="let cat of categories">
      <img [src]="cat.img">
    </ion-slide>
  </ion-slides>

  <ion-slides [options]="highlightSlideOpts">
    <ion-slide *ngFor="let h of highlights">
      <img [src]="h.img">
    </ion-slide>
  </ion-slides>

  <ion-text color="dark"><b style="padding-left: 10px;">Featured</b></ion-text>

  <ion-slides [options]="featuredSlideOpts">
    <ion-slide *ngFor="let f of featured" class="featured-slide" routerLink="/details">
      <img [src]="f.img">
      <div class="info">
        <ion-text color="dark"><b>{{f.name }}</b></ion-text>
        <span>
          <ion-icon name="star" color="secondary"></ion-icon>
          <ion-text color="secondary"> {{ f.rating }} </ion-text>{{ f.ratings }}
        </span>
        <span>
          <ion-icon name="location-outline"></ion-icon> {{ f.distance }}
        </span>
      </div>
    </ion-slide>
  </ion-slides>

</ion-content>

This will already look a lot better since the first two slides have only images, but we can make this a lot better by adding some margin/padding around all slides. Also, the text within the third slide is not showing in the right place so we change the display for those slides to flex to make sure the items are aligned nicely under each other.

Apply the following styling inside the src/app/home/home.page.scss now:

ion-slides {
  padding-left: 15px;
  padding-right: 15px;
  margin-top: 15px;
  margin-bottom: 15px;
}

.featured-slide {
  display: flex;
  flex-direction: column;
  align-items: flex-start;

  .info {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
  }
}

That’s it for the first part of the home page! We will continue with the more complicated header component in the end, for now we head over to the details.

Details Page with Dynamic Header

Inside our details page we also need some dummy data, so we simply retrieve the JSON data and already define some slide options that we will need later. Go ahead and change the src/app/details/details.page.ts to this now:

import { DOCUMENT } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { AfterViewInit, Component, ElementRef, Inject, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core';
import { IonContent, IonList, IonSlides, isPlatform } from '@ionic/angular';

@Component({
  selector: 'app-details',
  templateUrl: './details.page.html',
  styleUrls: ['./details.page.scss'],
})
export class DetailsPage implements OnInit, AfterViewInit {
  data = null;
  
  opts = {
    freeMode: true,
    slidesPerView: 2.6,
    slidesOffsetBefore: 30,
    slidesOffsetAfter: 100
  }

  constructor(private http: HttpClient) { }

  ngOnInit() {
    this.http.get('https://devdactic.fra1.digitaloceanspaces.com/foodui/1.json').subscribe((res: any) => {
      this.data = res;
    });
  }

  ngAfterViewInit() {    
  
  }

}

Don’t mind all the imports, I just left them in since we will need them later, but feel free to remove them for the moment as well.

If we take a look at the official app we can see that the header (name of the restaurant) is below the image and while we scroll, it moves into the header which becomes solid at that point.

deliveroo_header

Turns out, this is a default behaviour Ionic has implemented for iOS by now!

The only thing we need to do is have another ion-header in our content with collapse set to condense. This will make the header disappear when we scroll and shows it inside our toolbar instead. Pretty neat, isn’t it?

Besides that we will display our image of the restaurant as a background image within a div since this will make the parallax directive we will add later easier.

The rest is just a basic setup of rows and cols to align all items correctly on the page. Only interesting thing might be the slice pipe, which helps to only use a specific amount of items from an array inside a ngFor iteration!

Now open the src/app/details/details.page.html and change it to:

<ion-header>
  <ion-toolbar color="light">
    <ion-buttons slot="start">
      <ion-button fill="solid" shape="round" color="light" routerLink="/home">
        <ion-icon slot="icon-only" name="arrow-back" color="primary"></ion-icon>
      </ion-button>
    </ion-buttons>
    <ion-title>{{ data?.name }}</ion-title>
    <ion-buttons slot="end">
      <ion-button fill="solid" shape="round" color="light">
        <ion-icon slot="icon-only" name="share-outline" color="primary"></ion-icon>
      </ion-button>
      <ion-button fill="solid" shape="round" color="light">
        <ion-icon slot="icon-only" name="search-outline" color="primary"></ion-icon>
      </ion-button>
    </ion-buttons>
  </ion-toolbar>
</ion-header>

<ion-content [fullscreen]="true" scrollEvents="true" (ionScroll)="onScroll($event)">

  <div #img class="header-image" [style.background-image]="'url(' + data?.img + ')'">
  </div>

  <ion-header collapse="condense" class="ion-no-border">
    <ion-toolbar color="light">
      <ion-title size="large">{{ data?.name }}</ion-title>
    </ion-toolbar>
  </ion-header>

  <div *ngIf="data">
    <ion-row>
      <ion-col size="12" class="light-bg ion-padding-start">
        <ion-icon name="star" color="primary"></ion-icon>
        <ion-text color="primary">
          {{ data.rating }}</ion-text>
        <ion-text color="medium"> {{ data.ratings }}<span *ngFor="let tag of data.tags | slice:0:2"> · {{ tag }} </span>
        </ion-text>
      </ion-col>
      <ion-col size="12" class="light-bg ion-padding-start">
        <ion-icon name="location-outline" color="medium"></ion-icon>
        <ion-text>
          {{ data.distance }}
        </ion-text>
      </ion-col>
      <ion-col size="12" class="light-bg ion-padding">
        <ion-badge color="danger">
          <ion-icon name="pricetag-outline"></ion-icon>
          Meal Deals
        </ion-badge>
      </ion-col>
      <ion-col size="12" class="ion-no-padding">
        <ion-item lines="full">
          <ion-label class="ion-text-wrap">
            {{ data.about }}
          </ion-label>
        </ion-item>
      </ion-col>
      <ion-col size="12" class="light-bg">
        <ion-row class="ion-align-items-center">
          <ion-col size="1">
            <ion-icon color="medium" name="location-outline"></ion-icon>
          </ion-col>
          <ion-col size="10">
            <ion-label>Restaurant info
              <p>Map, allergene ad hygiene rating</p>
            </ion-label>
          </ion-col>
          <ion-col size="1">
            <ion-icon color="primary" name="chevron-forward"></ion-icon>
          </ion-col>
        </ion-row>
      </ion-col>
    </ion-row>
  </div>

  <div *ngIf="data" class="ion-padding">
    Please do not call {{ data.name }} for any amends to your order, as these cannot be made once your order is
    received.
  </div>

</ion-content>

There are a few things not looking that good yet, especially the image isn’t displayed since we didn’t gave a specific size to the div. Besides that we will add a general background to our page and make sure it starts at the top without a toolbar, for which we can set absolute position.

Go ahead and make the page look better by adding the following to your src/app/details/details.page.scss:

ion-toolbar {
  ion-icon {
    font-size: 25px;
  }
}

ion-content {
    position: absolute;
    --background: #f1f1f1;
    --padding-bottom: 50px;
  }

.light-bg {
    background: #ffffff;
    z-index: 10;
}

.header-image {
    background-repeat: no-repeat;
    background-position: center; 
    background-size: cover;
    height: 30vh;
    will-change: transform;
}

Everything until here was pretty easy, now things get more challenging.

Ionic Scroll Events and Slides

If we scroll down a restaurant page, at some point we see a slides object appear at the top which automatically highlights the section you are currently scrolling through.

deliveroo_slides

To do so, we need to perform different things:

  • Display a slides component sticky to the top after a specific time when we scroll down
  • Automatically mark the active section based on the visible elements in our viewport
  • Scroll to a specific element if we click on a category in our slides component

First of all we will actually add the elements to our page based on the JSON data we already got. To make our slides component sticky to the top we can use the fixed slot of ion-content, which puts the element above everything else. We just need to add the right position and logic to show/hide it later, but that’s gonna be easy.

For the slides we will simply toggle it through a CSS class depending on the categorySlidesVisible variable.

We can also add a CSS class depending on the activeCategory value to mark the active item which we will then also update from code when we scroll through our page or click on a category.

The list for meals isn’t too hard and just iterates the different categories and according meals of our dummy data, so go ahead and add the following below the existing elements inside the src/app/details/details.page.html:

<div slot="fixed">
    <ion-slides [options]="opts" *ngIf="data"
        [ngClass]="{'slides-visible': categorySlidesVisible, 'slides-hidden': !categorySlidesVisible}">
        <ion-slide *ngFor="let entry of data.food; let i = index;">
            <ion-button [class.active-category]="activeCategory == i" fill="clear" (click)="selectCategory(i)">
                {{ entry.category }}
            </ion-button>
        </ion-slide>
    </ion-slides>
</div>

<ion-list *ngFor="let entry of data?.food">
    <ion-list-header>
        <ion-label>{{ entry.category}}</ion-label>
    </ion-list-header>
    <ion-row *ngFor="let meal of entry.meals" class="ion-padding meal-row">
        <ion-col size="8" class="border-bottom">
            <ion-label>
                {{ meal.name }}
                <p>{{ meal.info }}</p>
            </ion-label>
            <ion-text color="dark"><b>{{ meal.price | currency:'EUR' }}</b></ion-text>
        </ion-col>
        <ion-col size="4" class="border-bottom">
            <div class="meal-image" [style.background-image]="'url(' + meal.img + ')'"></div>
        </ion-col>
    </ion-row>
</ion-list>

To make the list of meals and headers stand out we need to play around with the background colors a bit, plus we need to make sure the image of the meal has a size as well. Let’s add the following to our src/app/details/details.page.scss for that:

ion-list-header {
    --background: #f1f1f1;
}

ion-list {
    --ion-background-color: #fff;
}

.meal-row {
    padding-bottom: 0px;
}

.meal-image {
    width: 100%;
    height: 100%;
    background-position: center;
    background-size: cover;
    background-repeat: no-repeat;
    border-radius: 5px;
}

.border-bottom {
    border-bottom: 1px solid var(--ion-color-step-150, rgba(0, 0, 0, 0.07));
}

ion-slides {
    background: #fff;
    padding-top: 5px;
    padding-bottom: 5px;
    ion-button {
        height: 2em;
    }

    top: var(--header-position);
    opacity: 0;
}

.slides-visible {
    opacity: 1;
    transition: 1s;
}

.slides-hidden {
    opacity: 0;
    transition: 1s;
}

.active-category {
    --background: var(--ion-color-primary);
    --color: #fff;
    --border-radius: 30px;
    font-weight: 600;
}

We now also got the different classes to show/hide the slides component with a small transition, plus the active category get’s a custom color to mark it with our primary color.

What’s most interesting here is the top position of our slides component (inside the fixed slot): We need to position it below the toolbar, which has a different height on iOS and Android. So we only set it to a custom CSS variable --header-position that we will inject from code later!

Now that we got the elements and styling we can focus on the logic to highlight and show the right elements, and we already added a ionScroll to our content which passes all scroll events to a function!

That means we now need to:

  • Get a reference to all of our ion-list by using the @ViewChildren decorator
  • Scroll to one of those elements when we click a category
  • Check during scroll if our offset passes a certain threshold to show the category slides, and at the same time check if any of our list children is visible in the viewport and set it as the active category

It’s actually not too hard once you make a plan for how the whole functionality could work.

Let’s start by setting our CSS variable for the height – we can simply check for the current platform and inject the variable by calling setProperty on the Angular Document service.

We also need to listen to the changes of our ViewChildren as otherwise the toArray() call returns an empty array right in the beginning.

Once we got them set, sliding to one of them is pretty easy and can even be done by using the ion-content component as a ViewChild and calling the scrollToPoint function!

Inside our onScroll() function we now check the offset which will show/hide the whole sticky category component, and we iterate through our children to check if any of them is currently visible in the viewport. If that’s the case, we change the activeCategory and also slide to the according index by using our IonSlides ViewChild!

Now open the src/app/details/details.page.ts and change it to:

import { DOCUMENT } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { AfterViewInit, Component, ElementRef, Inject, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core';
import { IonContent, IonList, IonSlides, isPlatform } from '@ionic/angular';

@Component({
  selector: 'app-details',
  templateUrl: './details.page.html',
  styleUrls: ['./details.page.scss'],
})
export class DetailsPage implements OnInit, AfterViewInit {
  data = null;
  
  opts = {
    freeMode: true,
    slidesPerView: 2.6,
    slidesOffsetBefore: 30,
    slidesOffsetAfter: 100
  }

  activeCategory = 0;
  @ViewChildren(IonList, { read: ElementRef }) lists: QueryList<ElementRef>;
  listElements = [];
  @ViewChild(IonSlides) slides: IonSlides;
  @ViewChild(IonContent) content: IonContent;
  categorySlidesVisible = false;

  constructor(private http: HttpClient, @Inject(DOCUMENT) private document: Document) { }

  ngOnInit() {
    this.http.get('https://devdactic.fra1.digitaloceanspaces.com/foodui/1.json').subscribe((res: any) => {
      this.data = res;
    });

    // Set the header position for sticky slides 
    const headerHeight = isPlatform('ios') ? 44 : 56;    
    this.document.documentElement.style.setProperty('--header-position', `calc(env(safe-area-inset-top) + ${headerHeight}px)`);
  }

  // Get all list viewchildren when ready
  ngAfterViewInit() {    
    this.lists.changes.subscribe(_ => { 
      this.listElements = this.lists.toArray();
    });
  }

  // Handle click on a button within slides
  // Automatically scroll to viewchild
  selectCategory(index) {
    const child = this.listElements[index].nativeElement;    
    this.content.scrollToPoint(0, child.offsetTop - 120, 1000);
  }

  // Listen to ion-content scroll output
  // Set currently visible active section
  onScroll(ev) {    
    const offset = ev.detail.scrollTop;
    this.categorySlidesVisible = offset > 500;
    
    for (let i = 0; i < this.listElements.length; i++) {
      const item = this.listElements[i].nativeElement;
      if (this.isElementInViewport(item)) {
        this.activeCategory = i;
        this.slides.slideTo(i);
        break;
      }
    }
  }

  isElementInViewport(el) {
    const rect = el.getBoundingClientRect();
    
    return (
      rect.top >= 0 &&
      rect.bottom <= (window.innerHeight || document.documentElement.clientHeight)
    );
  }
}

We got everything in place for our automatically switching sticky slides (is there a better word for this?) and can continue with the next challenge.

Parallax Image Directive

The details page image has a sweet parallax animation, and we’ve seen how that works in the past already in the according Quick Win inside the Ionic Academy.

I don’t want to get into the details for this too much at this point, so we directly start by adding the directive we created in the beginning to the content element in our src/app/details/details.page.html like this:

<ion-content [fullscreen]="true" scrollEvents="true" (ionScroll)="onScroll($event)" [appParallax]="img">

That means we pass the template reference of our image div to the directive, and we are also able to listen to the scroll events of the content as we’ve set this to true before.

The parallax directive now simply listens to the scroll events of our content and moves the image a bit slower out of our view than the content. At the same time, we can also apply some scaling to the image if we drag the screen into the other direction which gives a nice zoom effect as well.

Go ahead and change the src/app/directives/parallax.directive.ts to:

import { Directive, HostListener, Input, Renderer2 } from '@angular/core';
import { DomController } from '@ionic/angular';

@Directive({
  selector: '[appParallax]'
})
export class ParallaxDirective {
  @Input('appParallax') imageEl: any;
  private moveImage: number;
  private scaleImage: number;

  constructor(
    private renderer: Renderer2,
    private domCtrl: DomController
  ) { }

  @HostListener('ionScroll', ['$event']) onContentScroll($event: any) {
    const scrollTop = $event.detail.scrollTop;

    if (scrollTop > 0) {
      // Use higher values to move the image out faster
      // Use lower values to move it out slower
      this.moveImage = scrollTop / 1.6;
      this.scaleImage = 1;
    } else {
      // +1 at the end as the other part can become 0
      // and the image would disappear
      this.scaleImage = -scrollTop / 200 + 1;
      this.moveImage = scrollTop / 1.6;
    }

    this.domCtrl.write(() => {
      this.renderer.setStyle(this.imageEl, 'webkitTransform',
        'translate3d(0,' + this.moveImage + 'px,0) scale(' + this.scaleImage + ',' + this.scaleImage + ')'
      );
    });
  }
}

On every scroll we will then reposition the element and achieve the parallax for the image!

Header Fade Directive & Animation

To wrap up our app we move back to the first page in which we want to play around a bit with the header.

The header on this page consists of two elements: The place/location information and a row with a searchbar. The first of these leaves the view when scrolling, the searchbar moves a bit top and then sticks to the top while scrolling.

We could put the code into the header, but then we would have to move the header around as it’s usually static at the top so instead we simply create two ion-rows in our content and then apply a directive to the first one (the appHideHeader on our ion-content get’s the reference to it!) and the searchbar will be sticky through CSS later.

Therefore go ahead and add the following to the src/app/home/home.page.html in the right place (make sure you leave the rest in it as well):

<ion-content scrollEvents="true" [appHideHeader]="hideheader" (ionScroll)="onScroll($event)">

  <ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
    <ion-refresher-content></ion-refresher-content>
  </ion-refresher>

  <ion-row class="info-row" #hideheader>
    <ion-col size="2" class="ion-padding-start">
      <img src="./assets/delivery.png">
    </ion-col>
    <ion-col size="8">
      <ion-text color="dark">
        <span>Now</span><br>
        <b>London</b>
        <ion-icon name="chevron-down-outline" color="primary"></ion-icon>
      </ion-text>
    </ion-col>
    <ion-col size="2">
      <ion-button fill="clear">
        <ion-icon name="person-outline" slot="icon-only"></ion-icon>
      </ion-button>
    </ion-col>
  </ion-row>

  <ion-row class="sticky-row">
    <ion-col size="10">
      <ion-text class="ion-padding-start" color="medium"
        [ngClass]="{'location-visible': showLocationDetail, 'location-hidden': !showLocationDetail}">
        London</ion-text>
      <ion-searchbar placeholder="Dishes, restaurants or cuisines"></ion-searchbar>
    </ion-col>
    <ion-col size="2">
      <ion-button fill="clear">
        <ion-icon name="options-outline" slot="icon-only"></ion-icon>
      </ion-button>
    </ion-col>
  </ion-row>

To test our scroll behaviour I recommend to duplicate the slides code (or put in some dummy objects) so you can actually scroll the page.

Now we will make our search row sticky to the top and at the same time respect the safe-area-inset-top which we can grab from the env() so we are not displaying it in some crazy place.

Because we don’t have a standard header we also need to add some padding to our content and refresher above the two rows that we added. Finally the location row uses basically the same CSS classes like we used for the sticky slides to easily show/hide them with a shor transition.

Go ahead and finish the styling by adding this below the existing code in the src/app/home/home.page.scss:

ion-searchbar {
  --icon-color: var(--ion-color-medium);
}

ion-content {
  --padding-top: 40px;
}

.sticky-row {
  position: sticky;
  top: calc(env(safe-area-inset-top) - 30px);
  z-index: 2;
  background: #fff;
  box-shadow: 0px 9px 11px -15px rgba(0, 0, 0, 0.75);
  display: flex;
  flex-direction: row;
  align-items: flex-end;
}

.info-row {
  background: #fff;
  position: sticky;
  top: calc(env(safe-area-inset-top) - 40px);
  z-index: 2;
}

ion-refresher {
  padding-top: calc(env(safe-area-inset-top) + 50px);
}

.location-visible {
  opacity: 1;
  transition: .5s;
}

.location-hidden {
  opacity: 0;
  transition: .5s;
}

Since we also want to fade out the element in the first row while it scrolls out, we use another directive that we also used in the hide header Quick Win in the Ionic Academy.

This will scroll out the element and fade out its children at the same time, which gives some nice visuals during scrolling.

Finish our implementation by changing the src/app/directives/hide-header.directive.ts to:

import { AfterViewInit, Directive, HostListener, Input, Renderer2 } from '@angular/core';
import { isPlatform, DomController } from '@ionic/angular';

@Directive({
  selector: '[appHideHeader]'
})
export class HideHeaderDirective implements AfterViewInit {
  @Input('appHideHeader') header: any;
  private headerHeight = isPlatform('ios') ? 44 : 56;
  private children: any;
 
  constructor(
    private renderer: Renderer2,
    private domCtrl: DomController,
  ) { }
 
  ngAfterViewInit(): void {
    this.header = this.header.el;
    this.children = this.header.children;
  }
 
  @HostListener('ionScroll', ['$event']) onContentScroll($event: any) {
    const scrollTop: number = $event.detail.scrollTop;
    let newPosition = -scrollTop;
 
    if (newPosition < -this.headerHeight) {
      newPosition = -this.headerHeight;
    }
    let newOpacity = 1 - (newPosition / -this.headerHeight);
 
    this.domCtrl.write(() => {
      this.renderer.setStyle(this.header, 'top', newPosition + 'px');
      for (let c of this.children) {
        this.renderer.setStyle(c, 'opacity', newOpacity);
      }
    });
  }
}

We’ve used the same @HostListener for the scroll events of the content like before, so you now get a really good feeling for how these custom header directives work in general.

And with that we are done with the whole UI for our food ordering app as well!

Conclusion

Building complex UIs with Ionic is possible – by taking one step at a time!

All animations and UI transitions can be broken down to certain elements and what needs to happen when at which place. It’s not always easy, and putting this together took quite some time. But the result is a pretty epic UI built with Ionic!

You can also find a video version of this tutorial below.

The post Building a Deliveroo Food Ordering UI with Ionic appeared first on Devdactic - Ionic Tutorials.

Uploading Files to Supabase with Ionic using Capacitor

$
0
0

When you work with a cloud backend like Firebase or Supabase, you sometimes need to host files as well – and the actual database is usually not the best place.

In this tutorial we will build an Ionic app with Supabase backend and integrate the Storage functionality to host private and public files in different buckets while keeping track of the data in the SQL database!

ionic-supabase-storage

To do so, we will have to set up our Supabase database and buckets including security policies upfront and then create the Ionic app with authentication afterwards – gonna be a lot of fun today!

Supabase Project Setup

To get started, simply create an account (or log in) to Supabase and then create a new organisation under which we can add a project.

Pick any name for your project and the database region close to your users, and set a password for the database.

supabase-new-project

Wait until the creation is finished before we can continue. To define your backend, we need to dive a bit deeper into the SQL setup process now

SQL Security Policies

To setup the Supabase app correctly you need to define the SQL structure for the database, the buckets for Storage, and any security policies you want to add to your app.

What we do:

  • Create a files table to keep track of all created files
  • Policies so only authenticated users can create records or delete their own created files
  • Create two buckets named public and private
  • Enable public access to the public folder and only access for owners on the private files
  • Enable the realtime capabilities for our files table so we get notified about all changes

To do all of that, execute the following statement inside the SQL editor tab and start a new query with this:

-- Create a table for Public Profiles
create table files (
  id bigint generated by default as identity primary key,
  user_id uuid references auth.users not null,

  title text,
  private boolean,
  file_name text,

  created_at timestamp with time zone default timezone('utc'::text, now()) not null
);
 
alter table files enable row level security;
 
create policy "Users can delete their own files."
  on files for delete
  using (auth.uid() = user_id);

create policy "Users can insert their own files."
  on files for insert
  with check ( auth.uid() = user_id );

create policy "Users can see public and their own files."
  on files for select
  using ( private = false OR auth.uid() = user_id);
 
-- Set up Storage!
insert into storage.buckets (id, name)
values ('public', 'public');
 
create policy "Public files are accessible."
  on storage.objects for select
  using ( bucket_id = 'public' );

insert into storage.buckets (id, name)
values ('private', 'private');

create policy "Private files are accessible for their creators."
  on storage.objects for select
  using ( bucket_id = 'private' AND auth.uid() = owner );

create policy "Users can create files."
  on storage.objects for insert
  with check ( auth.role() = 'authenticated' );

create policy "Delete files" 
  on storage.objects for delete 
  using ( auth.uid() = owner );

-- Enable Realtime
begin; 
-- remove the realtime publication
drop publication if exists supabase_realtime; 

-- re-create the publication but don't enable it for any tables
create publication supabase_realtime;  
commit;

-- add a table to the publication
alter publication supabase_realtime add table files;

-- get full information even on remove
alter table files replica identity full;

You should now see your created table and also two new buckets inside the storage tab. You can also find all attached policies, which means we are ready to dive into our app!

Ionic App & Supabase Integration

For our Ionic app we start with a blank template, generate two additional pages, a service for the Supabase interaction and a guard to protect the inside area.

On top of that we install the supabase-js package and if you want to try the app on destop, install the Capacitor PWA Element and also include it in your main.ts as described in the docs.

Now run the following:

ionic start supabaseApp blank --type=angular --capacitor
cd ./supabaseApp

ionic g page login
ionic g page fileModal
ionic g service services/supabase
ionic g guard guards/auth --implements CanActivate

npm install @supabase/supabase-js

// For Desktop support if want
npm install @ionic/pwa-elements

ionic build
npx cap add ios
npx cap add android

Head back to your Supabase app and dive into the Settings -> API menu.

You can now copy the URL and the below listed anon key, which is used to create a connection to Supabase until a user signs in. We can put both of these values directly into our src/environments/environment.ts:

export const environment = {
  production: false,
  supabaseUrl: 'YOUR-URL',
  supabaseKey: 'YOUR-ANON-KEY'
};

Quickly also open our src/app/app-routing.module.ts so we can correct the entries to show our login first and the home page afterwards:

import { NgModule } from '@angular/core';
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';
import { AuthGuard } from './guards/auth.guard';

const routes: Routes = [
  {
    path: 'login',
    loadChildren: () => import('./login/login.module').then( m => m.LoginPageModule)
  },
  {
    path: 'home',
    loadChildren: () => import('./home/home.module').then( m => m.HomePageModule),
    canActivate: [AuthGuard]
  },
  {
    path: '',
    redirectTo: 'login',
    pathMatch: 'full'
  }
];

@NgModule({
  imports: [
    RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })
  ],
  exports: [RouterModule]
})
export class AppRoutingModule { }

That’s all for the configuration today!

Supabase Authentication

We can now start with the authetnication part and we will do it mostly like in our previous Ionic Supabase integration tutorial.

To get establish a connection to Supabase, simply call the createClient() function right in the beginning with the information we added to our environment.

The automatic handling of stored user data didn’t work 100% for me, or in general could be improved. Loading the stored token from the Web storage (possibly using Ionic Storage for that in the future as a different Storage engine) is synchronous, but without awaiting the data inside loadUser() it didn’t quite work.

Besides that, you can listen to auth changes within onAuthStateChange to get notified when the user signs in or out. We will emit that information to our BehaviorSubject so we can later use it as an Observable for our guard.

The sign up and sign in are almost the same and only require a simple function of the Supabase JS package. What I found not optimal was that even an unsuccessful login fulfils the promise, while I would expect it to throw an error.

That’s the reason why I wrapped the calls in another Promise and called either resolve or reject depending of the result from Supabase, which makes it easier for our controller to handle the result in the next step without own logic!

Now get started with the src/app/services/supabase.service.ts and change it to:

import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { createClient, SupabaseClient, User } from "@supabase/supabase-js";
import { BehaviorSubject, Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { CameraPhoto, FilesystemDirectory, Plugins } from '@capacitor/core';
import { isPlatform } from '@ionic/core';
import { DomSanitizer } from '@angular/platform-browser';
const { Filesystem } = Plugins;

export const FILE_DB = 'files';

export interface FileInfo {
  private: boolean;
  title: string;
  file_name?: string;
}

export interface FileItem {
  created_at: string;
  file_name: string;
  id: string;
  image_url?: Promise<any>;
  private: boolean;
  title: string;
  user_id: string;
  creator?: boolean;
}

@Injectable({
  providedIn: 'root'
})
export class SupabaseService {
  private privateFiles: BehaviorSubject<FileItem[]> = new BehaviorSubject([]);
  private publicFiles: BehaviorSubject<FileItem[]> = new BehaviorSubject([]);
  private currentUser: BehaviorSubject<boolean | User> = new BehaviorSubject(null);

  private supabase: SupabaseClient;

  constructor(private router: Router, private sanitizer: DomSanitizer) {
    this.supabase = createClient(environment.supabaseUrl, environment.supabaseKey, {
      autoRefreshToken: true,
      persistSession: true
    });

    // Load user from storage
    this.loadUser();

    // Also listen to all auth changes
    this.supabase.auth.onAuthStateChange((event, session) => {
      console.log('AUTH CHANGED: ', event);

      if (event == 'SIGNED_IN') {
        this.currentUser.next(session.user);
        this.loadFiles();
        this.handleDbChanged();
      } else {
        this.currentUser.next(false);
      }
    });
  }

  async loadUser() {
    const user = await this.supabase.auth.user();

    if (user) {
      this.currentUser.next(user);
      this.loadFiles();
      this.handleDbChanged();
    } else {
      this.currentUser.next(false);
    }
  }

  getCurrentUser() {
    return this.currentUser.asObservable();
  }

  async signUp(credentials: { email, password }) {
    return new Promise(async (resolve, reject) => {
      const { error, data } = await this.supabase.auth.signUp(credentials)
      if (error) {
        reject(error);
      } else {
        resolve(data);
      }
    });
  }

  signIn(credentials: { email, password }) {
    return new Promise(async (resolve, reject) => {
      const { error, data } = await this.supabase.auth.signIn(credentials)
      if (error) {
        reject(error);
      } else {
        resolve(data);
      }
    });
  }

  signOut() {
    this.supabase.auth.signOut().then(_ => {
      this.publicFiles.next([]);
      this.privateFiles.next([]);

      // Clear up and end all active subscriptions!
      this.supabase.getSubscriptions().map(sub => {
        this.supabase.removeSubscription(sub);
      });

      this.router.navigateByUrl('/');
    });
  }

  loadFiles() {
    // coming soon
  }

  handleDbChanged() {
    // coming soon
  }
}

To quickly build a simple sign up, get started by adding the ReactiveFormsModule to the src/app/pages/login/login.module.ts for our login next:

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

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    IonicModule,
    LoginPageRoutingModule,
    ReactiveFormsModule
  ],
  declarations: [LoginPage]
})
export class LoginPageModule {}

Just like we did in other full navigation examples with Ionic
src/app/pages/login/login.page.ts, we craft a simple form to hold the user data.

On sign up or sign in, we use the according functions from our service, add some loading here and there and handle the errors now easily within the promise by using then/err to catch the different results.

Otherwise we would have to check the result in the then() block here, but I want to keep the controller here as dumb as possible and let the service handle the logic of that!

Therefore we can now simply open the src/app/pages/login/login.page.ts and change it to:

import { SupabaseService } from './../services/supabase.service';
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { AlertController, LoadingController } from '@ionic/angular';
import { Router } from '@angular/router';

@Component({
  selector: 'app-login',
  templateUrl: './login.page.html',
  styleUrls: ['./login.page.scss'],
})
export class LoginPage implements OnInit {

  credentials: FormGroup;

  constructor(
    private fb: FormBuilder,
    private alertController: AlertController,
    private router: Router,
    private loadingController: LoadingController,
    private supabaseService: SupabaseService
  ) { }

  ngOnInit() {
    this.credentials = this.fb.group({
      email: ['', [Validators.required, Validators.email]],
      password: ['', Validators.required],
    });
  }

  async login() {
    const loading = await this.loadingController.create();
    await loading.present();

    this.supabaseService.signIn(this.credentials.value).then(async data => {
      await loading.dismiss();
      this.router.navigateByUrl('/home', { replaceUrl: true });
    }, async err => {
      await loading.dismiss();
      this.showAlert('Login failed', err.message);
    });
  }

  async signUp() {
    const loading = await this.loadingController.create();
    await loading.present();

    this.supabaseService.signUp(this.credentials.value).then(async data => {
      await loading.dismiss();
      this.showAlert('Signup success', 'Please confirm your email now!');
    }, async err => {      
      await loading.dismiss();
      this.showAlert('Registration failed', err.message);
    });
  }

  async showAlert(title, msg) {
    const alert = await this.alertController.create({
      header: title,
      message: msg,
      buttons: ['OK'],
    });
    await alert.present();
  }
}

Besides the loading and error logic, there’s not much to do for our class!

The according template is also quite easy, just adding the input fields for our form so we can quickly create and sign in users.

Go ahead with the src/app/pages/login/login.page.html and change it to:

<ion-header>
  <ion-toolbar color="dark">
    <ion-title>Devdactic Supabase</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-card>
    <ion-card-content>
      <form (ngSubmit)="login()" [formGroup]="credentials">
        <ion-item>
          <ion-input placeholder="john@doe.com" formControlName="email"></ion-input>
        </ion-item>
        <ion-item>
          <ion-input type="password" placeholder="password" formControlName="password"></ion-input>
        </ion-item>

        <ion-button type="submit" expand="block" [disabled]="!credentials.valid" class="ion-margin-top">Log in
        </ion-button>
        <ion-button type="button" expand="block" (click)="signUp()" color="secondary">Sign up!
        </ion-button>
      </form>
    </ion-card-content>
  </ion-card>

</ion-content>

Now you should be able to create a new user. By default, that user has to confirm the email adress, that’s also why we don’t immediately log in new users.

If you don’t want this behaviour, you can also change it inside your Supabase app under Authentication -> Settings.

Adding the Authentication Guard

Right now every user could access the home page, so we need to protect it with a guard.

We’ve used basically the same logic in other places before, the idea is to use the currentUser Observable returned by our service to check if a user is authenticated.

Because the BehaviourSubject inside the service is initialised with null, we need to filter out this initial value, although this code be improved with a better instanceof check on boolean or User, but it also works like this for now.

Afterwards, the information of a user is retrieved from the Storage and we can use that value to check if we have a user or not, and then allow access or send the user back to the login.

Therefore implement the guard by changing the src/app/guards/auth.guard.ts to:

import { Injectable } from '@angular/core';
import { CanActivate, UrlTree, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { SupabaseService } from '../services/supabase.service';
import { filter, map, take } from 'rxjs/operators'

@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanActivate {

  constructor(private supabaseService: SupabaseService, private router: Router) { }

  canActivate(): Observable<boolean | UrlTree> {
    return this.supabaseService.getCurrentUser().pipe(
      filter(val => val !== null), // Filter out initial Behaviour subject value
      take(1), // Otherwise the Observable doesn't complete!
      map(isAuthenticated => {
        if (isAuthenticated) {
          return true;
        } else {
          return this.router.createUrlTree(['/']);
        }
      })
    );
  }
}

Now you can log in and refresh an internal page, and the page only loads when the user was authenticated before!

Uploading Files

We can now finally get into new functionality for uploading files, and we start by setting up our service functionality.

The Capacitor Camera will give us a File URI, but we need to create a real File object from that URI for the Supabase upload (as of now).

The process is different for web and native apps, so we add a little if/else to create the file correctly on each platform.

Once we got a file object (which you could also get from a file picker!) we can upload it to the correct bucket based on the other information we pass to our function. Generate a name or maybe even put it into a subfolder using the user ID if you want to and call the according functions of the Supabase package to upload the file!

Because we also want to keep track of all the files in our database we call the saveFileInfo() function after the upload to create a new record in our database with all the necessary information.

Go ahead by appending the new functions to our src/app/services/supabase.service.ts:

async uploadFile(cameraFile: CameraPhoto, info: FileInfo): Promise<any> {
    let file = null;

    // Retrieve a file from the URI based on mobile/web
    if (isPlatform('hybrid')) {
      const { data } = await Filesystem.readFile({
        path: cameraFile.path,
        directory: FilesystemDirectory.Documents,
      });
      file = await this.dataUrlToFile(data);
    } else {
      const blob = await fetch(cameraFile.webPath).then(r => r.blob());
      file = new File([blob], 'myfile', {
        type: blob.type,
      });
    }

    const time = new Date().getTime();
    const bucketName = info.private ? 'private' : 'public';
    const fileName = `${this.supabase.auth.user().id}-${time}.png`;

    // Upload the file to Supabase
    const { data, error } = await this.supabase
      .storage
      .from(bucketName)
      .upload(fileName, file);

    info.file_name = fileName;
    // Create a record in our DB
    return this.saveFileInfo(info);
  }

  // Create a record in our DB
  async saveFileInfo(info: FileInfo): Promise<any> {
    const newFile = {
      user_id: this.supabase.auth.user().id,
      title: info.title,
      private: info.private,
      file_name: info.file_name
    };

    return this.supabase.from(FILE_DB).insert(newFile);
  }

  // Helper
  private dataUrlToFile(dataUrl: string, fileName: string = 'myfile'): Promise<File> {
    return fetch(`data:image/png;base64,${dataUrl}`)
      .then(res => res.blob())
      .then(blob => {
        return new File([blob], fileName, { type: 'image/png' })
      })
  }

There are actually even more Storage functions to list files but I thought keeping the information in the database might be a more secure and logic way to handle this.

Now we can add the call to the Capacitor camera to our src/app/home/home.page.ts but we will first pass the resulting image to a modal where we can then add some more information to it:

import { Component } from '@angular/core';
import { ModalController } from '@ionic/angular';
import { SupabaseService } from '../services/supabase.service';
import { Plugins, CameraResultType, CameraSource } from '@capacitor/core';
import { FileModalPage } from '../file-modal/file-modal.page';
const { Camera } = Plugins;

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})
export class HomePage {

  constructor(private supabaseService: SupabaseService, private modalCtrl: ModalController) { }

  async addFiles() {
    const image = await Camera.getPhoto({
      quality: 90,
      allowEditing: true,
      resultType: CameraResultType.Uri,
      source: CameraSource.Camera
    });

    const modal = await this.modalCtrl.create({
      component: FileModalPage,
      componentProps: { image }
    });

    await modal.present();
  }

  logout() {
    this.supabaseService.signOut();
  }

}

To call our functions, let’s quickly add a button to the src/app/home/home.page.html now:

<ion-header>
  <ion-toolbar color="dark">
    <ion-title>
      Supabase Files
    </ion-title>
    <ion-buttons slot="end">
      <ion-button (click)="logout()">
        <ion-icon slot="icon-only" name="log-out"></ion-icon>
      </ion-button>
    </ion-buttons>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-fab vertical="bottom" horizontal="center" slot="fixed">
    <ion-fab-button (click)="addFiles()" color="dark">
      <ion-icon name="add"></ion-icon>
    </ion-fab-button>
  </ion-fab>
</ion-content>

Within our modal we want to show the captured image and allow the user to add a title and a private/public flag. To show the captured image we need to use the Angular Sanitizer as we would otherwise get an unsafe error.

The save functionality will in the end simply pass all information to our service and then dismiss the modal – we don’t have a good error handling in here but I didn’t want to add even more stuff to the tutorial.

For now, open the src/app/file-modal/file-modal.page.ts and change it to:

import { Component, Input, OnInit } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { ModalController, LoadingController } from '@ionic/angular';
import { SupabaseService } from '../services/supabase.service';

@Component({
  selector: 'app-file-modal',
  templateUrl: './file-modal.page.html',
  styleUrls: ['./file-modal.page.scss'],
})
export class FileModalPage implements OnInit {
  @Input() image: any;
  info = {
    private: false,
    title: 'That looks good'
  };

  imagePath = null;

  constructor(private modalCtrl: ModalController, private supabaseService: SupabaseService, private loadingCtrl: LoadingController,
    private sanitizer: DomSanitizer) { }

  ngOnInit() {
    this.imagePath = this.sanitizer.bypassSecurityTrustResourceUrl(this.image.webPath);
  }

  async save() {
    const loading = await this.loadingCtrl.create({
      message: 'Uploading File...'
    });
    await loading.present();

    await this.supabaseService.uploadFile(this.image, this.info);
    await loading.dismiss();
    this.modalCtrl.dismiss();
  }

  close() {
    this.modalCtrl.dismiss();
  }
}

To wrap it up, we can now display the image and our fields to add the metadata and upload our image inside the src/app/file-modal/file-modal.page.html:

<ion-header>
  <ion-toolbar color="dark">
    <ion-buttons slot="start">
      <ion-button (click)="close()">
        <ion-icon slot="icon-only" name="close"></ion-icon>
      </ion-button>
    </ion-buttons>
    <ion-title>Preview</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <img [src]="imagePath" *ngIf="imagePath">

  <ion-item>
    <ion-label position="fixed">Title</ion-label>
    <ion-input [(ngModel)]="info.title"></ion-input>
  </ion-item>
  <ion-item>
    <ion-checkbox slot="start" [(ngModel)]="info.private"></ion-checkbox>
    <ion-label>Private File</ion-label>
  </ion-item>
  <ion-button expand="full" (click)="save()">
    <ion-icon slot="start" name="cloud-upload"></ion-icon>
    Save & Upload File
  </ion-button>
</ion-content>

You should now be able to capture/select an image, add the information and a new image should appear inside your Supabase bucket! At the same time, a new record in the database is created, so we can now continue and list all the user files in our app by loading the information from our SQL database.

Displaying Supabase Files

First of all, this looks mostly like building an SQL query. You can find all of the available functions and filters inside the Supabase documentation, but most of it is self explaining.

We use our constant database name and query all the data, which returns a Promise. To keep the data now locally inside the service without reloading it all the time, we simply emit it to our BehaviourSubject by calling next().

Before we do that we first add two fields to each item:

  • image_url: We manually call a function that downloads the image from Supabase Storage. This returns a Promise – we will handle that correctly later
  • creator: A quick flag to indicate if we created the file

The idea is that users should be able to see all public files and their own private files. Now you might ask: Doesn’t the select query return all data in the table?

The answer lies in our security policies that we created in the first step. Users are only allowed to see their own records and entries with private set to false!

Therefore we get the right data hear and only need to divide it now across our two lists.

At this point we can also create the logic to delete a file, which would remove both the file from the bucket and the actual database record.

Go ahead now and add the following to the src/app/services/supabase.service.ts:

async loadFiles(): Promise<void> {
    const query = await this.supabase.from(FILE_DB).select('*').order('created_at', { ascending: false });

    // Set some custom data for each item
    const data: FileItem[] = query.data.map(item => {
      item.image_url = this.getImageForFile(item);
      item.creator = item.user_id == this.supabase.auth.user().id;
      return item;
    });

    // Divide by private and public
    const privateFiles = data.filter(item => item.private);
    const publicFiles = data.filter(item => !item.private);

    this.privateFiles.next(privateFiles);
    this.publicFiles.next(publicFiles);
  }

  getPublicFiles(): Observable<FileItem[]> {
    return this.publicFiles.asObservable();
  }

  getPrivateFiles(): Observable<FileItem[]> {
    return this.privateFiles.asObservable();
  }

  // Remove a file and the DB record
  async removeFileEntry(item: FileItem): Promise<void> {
    const bucketName = item.private ? 'private' : 'public';

    await this.supabase
      .from(FILE_DB)
      .delete()
      .match({ id: item.id });

    await this.supabase
      .storage
      .from(bucketName)
      .remove([item.file_name]);
  }

  // Get the Image URL for a file inside a bucket
  getImageForFile(item: FileItem) {
    const bucketName = item.private ? 'private' : 'public';

    return this.supabase.storage.from(bucketName).download(item.file_name).then(res => {
      const url = URL.createObjectURL(res.data);
      const imageUrl = this.sanitizer.bypassSecurityTrustUrl(url);
      return imageUrl;
    });
  }

  // Realtime change listener
  handleDbChanged() {
    return this.supabase
      .from(FILE_DB)
      .on('*', payload => {
        console.log('Files realtime changed: ', payload);
        if (payload.eventType == 'INSERT') {
          // Add the new item
          const newItem: FileItem = payload.new;
          newItem.creator = newItem.user_id == this.supabase.auth.user().id;

          if (newItem.private && newItem.user_id == this.supabase.auth.user().id) {
            newItem.image_url = this.getImageForFile(newItem);
            this.privateFiles.next([newItem, ...this.privateFiles.value]);
          } else if (!newItem.private) {
            newItem.image_url = this.getImageForFile(newItem);
            this.publicFiles.next([newItem, ...this.publicFiles.value]);
          }
        } else if (payload.eventType == 'DELETE') {
          // Filter out the removed item
          const oldItem: FileItem = payload.old;
          if (oldItem.private && oldItem.user_id == this.supabase.auth.user().id) {
            const newValue = this.privateFiles.value.filter(item => oldItem.id != item.id);
            this.privateFiles.next(newValue);
          } else if (!oldItem.private) {
            const newValue = this.publicFiles.value.filter(item => oldItem.id != item.id);
            this.publicFiles.next(newValue);
          }
        }
      }).subscribe();
  }

The handleDbChanged() is now our last piece in the realtime puzzle: At this point, we listen to all changes on our files table and react to the INSERT and DELETE events.

There is no realtime connection with the database like you might have used with Firebase, but by listening to the different change events you can easily manipulate your local data to add, remove or update an element from the array inside the BehaviourSubject.

Because those changes actually also broadcast events like “another user added a private file”, we need a bit more logic to only add the public or our own private files.

I’m sure that it’s possible to only get these events by establishing more rules for the realtime publication – give it a try and let me know if you made it work! But from what I know, there are still some open bugs with that part of Supabase at the time writing.

Since at this point our element also lack the custom image_url and creator properties we need to attach them to any new object as well.

But now everyone can use our Observables and get all the updates in realtime!

That’s what we want to use in our src/app/home/home.page.ts now:

import { Component } from '@angular/core';
import { ModalController } from '@ionic/angular';
import { SupabaseService } from '../services/supabase.service';
import { Plugins, CameraResultType, CameraSource } from '@capacitor/core';
import { FileModalPage } from '../file-modal/file-modal.page';
const { Camera } = Plugins;

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})
export class HomePage {

  activeList = 'public';
  privateFiles = this.supabaseService.getPrivateFiles();
  publicFiles = this.supabaseService.getPublicFiles();

  constructor(private supabaseService: SupabaseService, private modalCtrl: ModalController) { }

  async addFiles() {
    const image = await Camera.getPhoto({
      quality: 90,
      allowEditing: true,
      resultType: CameraResultType.Uri,
      source: CameraSource.Camera
    });

    const modal = await this.modalCtrl.create({
      component: FileModalPage,
      componentProps: { image }
    });

    await modal.present();
  }

  logout() {
    this.supabaseService.signOut();
  }

  deleteFile(item) {
    this.supabaseService.removeFileEntry(item);
  }
}

We simply load the lists from our service and keep track of an activeList so we can create a simple segment view in our template.

When we now iterate the files of our Observables we need to be careful: We added the image_url as a Promise, so we also need to use the async pipe in that place when displaying the image again!

The rest is just using the information from the records, and showing a delete button in case we are the creator of a certain file.

Wrap up our app by changing the src/app/home/home.page.html to:

<ion-header>
  <ion-toolbar color="dark">
    <ion-title>
      Supabase Files
    </ion-title>
    <ion-buttons slot="end">
      <ion-button (click)="logout()">
        <ion-icon slot="icon-only" name="log-out"></ion-icon>
      </ion-button>
    </ion-buttons>
  </ion-toolbar>
</ion-header>

<ion-content>

  <ion-segment [(ngModel)]="activeList">
    <ion-segment-button value="public">
      <ion-label>Public</ion-label>
    </ion-segment-button>
    <ion-segment-button value="private">
      <ion-label>Private</ion-label>
    </ion-segment-button>
  </ion-segment>

  <ion-list *ngIf="activeList == 'public'">
    <ion-card *ngFor="let item of publicFiles | async">
      <ion-card-header>
        <ion-card-title>
          {{ item.title }}
        </ion-card-title>
        <ion-card-subtitle>
          Created at: {{ item.created_at | date:'short' }}
        </ion-card-subtitle>
      </ion-card-header>
      <ion-card-content>
        <img [src]="item.image_url | async">

        <ion-fab vertical="bottom" horizontal="end" *ngIf="item.creator">
          <ion-fab-button (click)="deleteFile(item)" color="danger">
            <ion-icon name="trash-outline"></ion-icon>
          </ion-fab-button>
        </ion-fab>

      </ion-card-content>
    </ion-card>
  </ion-list>

  <ion-list *ngIf="activeList == 'private'">
    <ion-card *ngFor="let item of privateFiles | async">
      <ion-card-header>
        <ion-card-title>
          {{ item.title }}
        </ion-card-title>
        <ion-card-subtitle>
          Created at: {{ item.created_at | date:'short' }}
        </ion-card-subtitle>
      </ion-card-header>
      <ion-card-content>
        <img [src]="item.image_url | async">

        <ion-fab vertical="bottom" horizontal="end" *ngIf="item.creator">
          <ion-fab-button (click)="deleteFile(item)" color="danger">
            <ion-icon name="trash-outline"></ion-icon>
          </ion-fab-button>
        </ion-fab>

      </ion-card-content>
    </ion-card>
  </ion-list>

  <ion-fab vertical="bottom" horizontal="center" slot="fixed">
    <ion-fab-button (click)="addFiles()" color="dark">
      <ion-icon name="add"></ion-icon>
    </ion-fab-button>
  </ion-fab>
</ion-content>

And with that we are finished and can upload files from both native apps and the web browser as well. If we are the owner, we can delete files and we can see all shared public files. To test this, create another account and create some more files and you’ll see how everything comes together in the end!

Conclusion

Just like inside the previous Supabase tutorial I really enjoyed working with this new feature. Defining the right rules and the table in the beginning is a crucial step to building decent Supabase apps, as it’s not as flexible as a NoSQL database like we used with Firebase.

To improve this code, you might want to add better error handling in most places, especially the file upload. And finally we would have to improve our realtime security logic, but somehow it should be possible with the according rules as well!

Want to see more about Supabase in the future? Let me know in the comments if you would like to see any other real world app or use case built with it!

You can also find a video version of this tutorial below.

The post Uploading Files to Supabase with Ionic using Capacitor appeared first on Devdactic - Ionic Tutorials.

How to Cache API Responses with Ionic & Capacitor

$
0
0

If you don’t need the latest data and speed up loading times, a decent way to improve your performance is to cache API responses – and you can do it right away with some simple logic!

In this tutorial we will build our own Caching service to cache the JSON data from API calls using Ionic Storage and Capacitor.

There are packages available like Ionic cache, but actually we don’t need another package that is potentially not always up to data since it’s quite easy to do it on our own.

If you also want to keep track of online/offline status and queue up any POST request, also check out How to Build an Ionic Offline Mode App!

Ionic App Setup

To get started, bring up a new Ionic app and add two services to manage our API requests and the cached values.

Since caching request requires saving potentially a lot of data, we should use Ionic storage and on a device the underlying SQLite database, for which we need to Cordova plugin cordova-sqlite-storage and since we are using Capacitor, we just install it right away.

Go ahead with:

ionic start devdacticCaching blank --type=angular --capacitor
cd ./devdacticCaching

ionic g service services/api
ionic g service services/caching

# For Data Caching
npm install @ionic/storage-angular
npm install cordova-sqlite-storage
npm install localforage-cordovasqlitedriver

On top of that we need to install the driver for SQLite since Ionic Storage version 3, and we need to define the order in which storage engines are selected within our src/app/app.module.ts like this:

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 { HttpClientModule } from '@angular/common/http';
import { IonicStorageModule } from '@ionic/storage-angular';
import * as CordovaSQLiteDriver from 'localforage-cordovasqlitedriver';
import { Drivers } from '@ionic/storage';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [BrowserModule,
    IonicModule.forRoot(),
    AppRoutingModule,
    HttpClientModule,
    IonicStorageModule.forRoot({
      driverOrder: [CordovaSQLiteDriver._driver, Drivers.IndexedDB]
    })],
  providers: [
    { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }
  ],
  bootstrap: [AppComponent],
})
export class AppModule { }

Now Storage will use the Database on a device and fall back to IndexedDB inside the browser when SQLite is not available.

Building a Caching Service

Let’s begin by creating the hear of our logic, which handles all the interaction with Storage. The idea is pretty easy:

  • Cache request (JSON) data by the URL plus a unique identifier (cache_key so we can find it more easily
  • Load cached data if it exists, check the self assigned time to live and either return the data or null

Since Storage v3 we also need to initialise it correctly, so we add a initStorage() function which we should call right in the beginning of our app.

To cache data, we will simply generate a validUntil value that we can check against the current time, and you could define your appropriate TTL at the top of the file.

The data is then simply stored under the URL key, and when we retrieve the data in our getCachedRequest() function we check the date again to either return the real data if it’s still valid or null in every other case.

Go ahead now and change the src/app/services/caching.service.ts to:

import { Injectable } from '@angular/core';
import { Storage } from '@ionic/storage';
import * as CordovaSQLiteDriver from 'localforage-cordovasqlitedriver'

// Expire time in seconds
const TTL = 60 * 60;
// Key to identify only cached API data
const CACHE_KEY = '_mycached_';

@Injectable({
  providedIn: 'root'
})
export class CachingService {

  constructor(private storage: Storage) { }

  // Setup Ionic Storage
  async initStorage() {
    await this.storage.defineDriver(CordovaSQLiteDriver);
    await this.storage.create();
  }

  // Store request data
  cacheRequest(url, data): Promise {
    const validUntil = (new Date().getTime()) + TTL * 1000;
    url = `${CACHE_KEY}${url}`;
    return this.storage.set(url, {validUntil, data});
  }

  // Try to load cached data
  async getCachedRequest(url): Promise {
    const currentTime = new Date().getTime();
    url = `${CACHE_KEY}${url}`;
    
    const storedValue = await this.storage.get(url);
    
    if (!storedValue) {
      return null;
    } else if (storedValue.validUntil < currentTime) {
      await this.storage.remove(url);
      return null;
    } else {
      return storedValue.data;
    }
  }

  // Remove all cached data & files
  async clearCachedData() {
    const keys = await this.storage.keys();
    
    keys.map(async key => {
      if (key.startsWith(CACHE_KEY)) {
        await this.storage.remove(key);
      }
    });
  }

  // Example to remove one cached URL
  async invalidateCacheEntry(url) {
    url = `${CACHE_KEY}${url}`;
    await this.storage.remove(url);
  }
}

We’ve also added a simple function to clear all cached data, which iterates all keys and checks for our custom CACHE_KEY to identify the entries we want to delete – otherwise we would wipe the whole storage and remove all keys that your app might have set in other places as well!

Now we just need to make sure we are calling our initialiser early enough, and a good place would be inside our src/app/app.component.ts:

import { Component } from '@angular/core';
import { CachingService } from './services/caching.service';

@Component({
  selector: 'app-root',
  templateUrl: 'app.component.html',
  styleUrls: ['app.component.scss'],
})
export class AppComponent {
  constructor(private cachingService: CachingService) {
    this.cachingService.initStorage();
  }
}

We’ve got the logic for caching in place, let’s load some data that we can actually store!

Loading & Caching API JSON Data

Inside our other service we perform the actual HTTP requests to the API, but we need to wrap calling the API in some more logic an create a getData() as we have some different cases:

  • If we don’t have an active network connection, we will directly fallback to a cached value
  • If we pass a forceRefresh flag to the function we will always make a new HTTP call
  • If none of the above applies we will try to load the cached value
  • If the cached value exists we can return it, if not we still need to make a new HTTP call at this point

It’s a basic logic covering all the possible scenarios that we could encounter. Remember that not getting cached data means either we never stored it or its TTL is expired!

The final HTTP call happens in yet another function callAndCache() since at this point we want to make sure we immediately cache the API result after getting the data so we have it locally available on our next call.

Every route you now want to call simply becomes a function with two lines where you create the URL and then call our function, like we do inside the getUsers() and getChuckJoke() functions or anything else you want to add.

Now open the src/app/services/api.service.ts and change it to:

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { from, Observable, of } from 'rxjs';
import { map, switchMap, delay, tap } from 'rxjs/operators';
import { CachingService } from './caching.service';
import { Plugins } from '@capacitor/core';
import { ToastController } from '@ionic/angular';
const { Network } = Plugins;

@Injectable({
  providedIn: 'root'
})
export class ApiService {
  connected = true;

  constructor(private http: HttpClient, private cachingService: CachingService, private toastController: ToastController) {
    Network.addListener('networkStatusChange', async status => {
      this.connected = status.connected;
    });

    // Can be removed once #17450 is resolved: https://github.com/ionic-team/ionic/issues/17450
    this.toastController.create({ animated: false }).then(t => { t.present(); t.dismiss(); });
  }

  // Standard API Functions

  getUsers(forceRefresh: boolean) {
    const url = 'https://randomuser.me/api?results=10';
    return this.getData(url, forceRefresh).pipe(
      map(res => res['results'])
    );
  }

  getChuckJoke(forceRefresh: boolean) {
    const url = 'https://api.chucknorris.io/jokes/random';
    return this.getData(url, forceRefresh);
  }

  // Caching Functions

  private getData(url, forceRefresh = false): Observable<any> {

    // Handle offline case
    if (!this.connected) {
      this.toastController.create({
        message: 'You are viewing offline data.',
        duration: 2000
      }).then(toast => {
        toast.present();
      });
      return from(this.cachingService.getCachedRequest(url));
    }

    // Handle connected case
    if (forceRefresh) {
      // Make a new API call
      return this.callAndCache(url);
    } else {
      // Check if we have cached data
      const storedValue = from(this.cachingService.getCachedRequest(url));
      return storedValue.pipe(
        switchMap(result => {
          if (!result) {
            // Perform a new request since we have no data
            return this.callAndCache(url);
          } else {
            // Return cached data
            return of(result);
          }
        })
      );
    }
  }

  private callAndCache(url): Observable<any> {
    return this.http.get(url).pipe(
      delay(2000), // Only for testing!
      tap(res => {
        // Store our new data
        this.cachingService.cacheRequest(url, res);
      })
    )
  }
}

As you can see we also implemented a super simple mechanism for handling offline mode, but we could also move that logic to a interceptor instead or follow the approach of our offline mode app tutorial.

Using our API Caching Service

The last part now is just putting our logic to work, which is fairly easy since we just need to call the according functions from our src/app/home/home.page.ts:

import { Component } from '@angular/core';
import { ApiService } from '../services/api.service';
import { finalize } from 'rxjs/operators';
import { CachingService } from '../services/caching.service';
import { LoadingController } from '@ionic/angular';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})
export class HomePage {
  joke = null;
  users = null;

  constructor(private apiService: ApiService, private cachingService: CachingService, private loadingController: LoadingController) { }

  async loadChuckJoke(forceRefresh) {
    const loading = await this.loadingController.create({
      message: 'Loading data..'
    });
    await loading.present();

    this.apiService.getChuckJoke(forceRefresh).subscribe(res => {
      this.joke = res;
      loading.dismiss();
    });
  }

  async refreshUsers(event?) {
    const loading = await this.loadingController.create({
      message: 'Loading data..'
    });
    await loading.present();

    const refresh = event ? true : false;

    this.apiService.getUsers(refresh).pipe(
      finalize(() => {        
        if (event) {
          event.target.complete();
        }
        loading.dismiss();
      })
    ).subscribe(res => {      
      this.users = res;
    })
  }

  async clearCache() {
    this.cachingService.clearCachedData();
  }

}

I’ve wrapped the calls with a loading and the refreshUsers() is tied to an ion-refresher so we might have to end the refreshing event in that function – but overall it’s only calling the Observablke from our service, just like you would do in a regular case with the HttpClient and no caching in the background!

To test out the functionality, bring up a super easy with with a few buttons and controls like this inside the src/app/home/home.page.html:

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>
      Devdactic Caching
    </ion-title>
    <ion-buttons slot="end">
      <ion-button (click)="clearCache()">
        <ion-icon slot="icon-only" name="trash"></ion-icon>
      </ion-button>
    </ion-buttons>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-refresher slot="fixed" (ionRefresh)="refreshUsers($event)">
    <ion-refresher-content></ion-refresher-content>
  </ion-refresher>

  <ion-button expand="full" (click)="refreshUsers()">Load Users</ion-button>
  <ion-button expand="full" (click)="loadChuckJoke(false)">Load Joke</ion-button>
  <ion-button expand="full" (click)="loadChuckJoke(true)">Fresh Joke</ion-button>

  <ion-card *ngIf="joke">
    <ion-card-header>
      <ion-card-title>Latest Chuck Joke</ion-card-title>
    </ion-card-header>
    <ion-card-content class="ion-text-center">
      <img [src]="joke.icon_url">
      <ion-label>{{ joke.value }}</ion-label>
    </ion-card-content>
  </ion-card>

  <ion-list>
    <ion-item *ngFor="let user of users">
      <ion-avatar slot="start">
        <img [src]="user.picture.medium">
      </ion-avatar>
      <h2 class="ion-text-capitalize">{{ user.name?.first }} {{ user.name?.last }}</h2>
    </ion-item>
  </ion-list>

</ion-content>

When you now run your application, you can grab some data and then check the Storage/Application tab of your debugging tools and you should see the JSON data from the API stored under the URL key like this:
ionic-caching-storage-data

While the TTL isn’t reached the functions would now simply return the cached data, which you also notice as the results appear instantly and our API call has a 2 second delay for testing applied right now.

Conclusion

If you want to add caching to your Ionic app you don’t need to use an external package, some basic logic and services are all you need to store your API results locally and return them in case they are still valid.

Of course especially the TTL or when your app needs to reload data is different in every use case, but the basic logic will work and you can put your additional requirements on top of this logic!

Right now we are also using the full URL including query params for the storage key, maybe removing the params works better for you in some situations so you can implement sort of “cache busting” by adding random params to the URL to force a reload of data.

You can also find a video version of this tutorial below.

The post How to Cache API Responses with Ionic & Capacitor appeared first on Devdactic - Ionic Tutorials.

Ionic E2E Tests with Cypress.io

$
0
0

Since the Ioniconf 2020, Cypress caught my attention as a better E2E testing framework instead of Protractor for our Angular Ionic apps, and we’ll see why in this tutorial.

With Angular v12, Portractir tests won’t be included by default and Angular also recommends other testing frameworks for E2E tests, one of them being Cypress.

Cypress runs your tests automatically while watching code changes, you get a decent dashboard for managing and debugging test cases, and you can easily see what Cypress does when running your tests as if it was a real person interacting with your app!

ionic-e2e-tests-cypress

In this tutorial we will integrate Cypress into an existing Ionic Angular project and step through different cases to understand the basics of Cypress.

Setting up the Testing App with Cypress

Since a blank Ionic app doesn’t make sense today, I’ve prepared a little repository from a previous tutorial that we will use for testing.

To get started, clone the repository and install Cypress in the project. Since it didn’t create all the necessary folders for me initially, also run the open command afterwards to open the test suite and get the folders in your project:

git clone https://github.com/saimon24/devdactic-testing-app
cd ./devdactic-testing-app
npm i

# Install Cypress
npm install cypress --save-dev
npx cypress open

# Create a new testing file
touch cypress/integration/tests.spec.js

There are a few things I recommend to change to make Cypress work better with your IDE. First, set the jsHint version inside your package.json like this to remove some warnings you would get all the time:

{
  "jshintConfig": {
    "esversion": 6
  }
}

Next we enable Typescript support by adding Cypress inside the tsconfig.json types:

"compilerOptions": {
    "types": ["cypress"],
    ...
  }

Finally we need to tell Cypress where to look for our Ionic application. By default this will be port 8100 when you serve the Ionic app, but of course use a different port if you don’t serve the Ionic app on that port.

We can define the URL now inside the cypress.json:

{
    "baseUrl": "http://localhost:8100"
}

Alright, that’s it for the configuration. Make sure you serve your Ionic application now before writing the tests as Cypress will look for the running instance on that port to use it for E2E tests.

Testing Navigation with Cypress

To start with something easy, let’s just try to move around in our app. By using cy.visit() we can easily check out the different routes of our app, so let’s start a new test suite inside the cypress/integration/tests.spec.js like this (create the file if you haven’t):

describe('Web App Testing', () => {

    it('shows the landing page', () => {
        cy.visit('/');
        cy.contains('Welcome to our shop!');
    });

    it('shows the about page', () => {
        cy.visit('/about');
        cy.contains('Lorem ipsum dolor sit amet');
    });

    it('shows 4 jewelery products', () => {
        cy.visit('/products?category=jewelery');
        cy.get('ion-card').should('have.length', '4');
    });
});

On our different pages we simply check if a string is contained on that page. On top of that we try to get an array of ion-card elements on the products page, which should result in 4 objects being displayed on that page (check the dummy app to see it in action).

One recommendation upfront: First make your tests fail!

Having all green tests is cool, but sometimes the test case was simply wrong and therefore you should always make your tests fail first, and then write the correct version to verify that your tests work like you think.

By now you should have the test runner open, if not simply run npx cypress open to see all available test cases:

cypress-test-suite

We are not interested in the examples, but we want to run our own file so click it and a new browser should open which runs your tests.

cypress-test-runner

This view will now automatically refresh once we add more tests, so leave it open and let’s write some more advanced tests!

Testing Mobile Screens & Click Actions

You might have noticed that the test runner actually used the default full width version of our Ionic app. If you want to test mobile behaviour instead, you can easily add a beforeEach block which runs before every test and define the size of your viewport.

Let’s change that and also mimic a more natural usage of a mobile app by not routing to the different pages but actually clicking on the elements inside our side menu on small screens:

describe('Mobile App Testing', () => {

    beforeEach(() => {
        cy.viewport('iphone-x');
        cy.visit('/');
    });

    it('shows the about page', () => {
        cy.get('ion-menu-button').click();
        cy.get('ion-menu-toggle').eq(2).click();
        cy.contains('Lorem ipsum dolor sit amet');
    });

    it('shows 4 jewelery products', () => {
        cy.get('ion-menu-button').click();
        cy.get('ion-menu-toggle').eq(1).click();
        cy.get('ion-card').should('have.length', '20');
    });
});

You can have this suite next to the previous web tests or even create separate files. The test runner will now use a smaller frame for the app tests like this:

cypress-mobile-tests

Keep in mind that this is only a smaller version of your app on the web – this is not a native app!

That means, you can’t really test Cordova stuff or actual native devices functionalities this way.

Using Cypress Commands

Let’s say you want to perform something all the time in your tests like clicking the menu and selecting a page. If you have a fixed set of instructions, you can refactor them as a Cypress command so let’s open the cypress/support/commands.js and create our own command to open the menu on a small screen:

Cypress.Commands.add('openMobileProducts', () => {
    cy.viewport('iphone-x');
    cy.get('ion-menu-button').click();
    cy.get('ion-menu-toggle').eq(1).click();
});

Defining a functions like this is enough to make them available for our test cases, and we can now replace the previous test by directly calling our custom command instead:

it('shows 4 jewelery products', () => {
        cy.openMobileProducts();
        cy.get('ion-card').should('have.length', '20');
    });

While this is not a huge benefit in our example, you could also easily interact with something like Capacitor plugins to fake the storage in your app and fill it with some test data like this:

import { Plugins } from '@capacitor/core';
const { Storage } = Plugins;

Cypress.Commands.add('setDummyData', () => {
    cy.visit('/', {
      onBeforeLoad () {
        const arr = ['First Todo', 'another todo'];
        Storage.set({ key: 'todos', value: JSON.stringify(arr) });
      }
    });
});

If you find yourself writing the same chunk of code in your test cases over and over, remember that you should refactor it into a custom command to make life easier!

Testing CSS Values

Besides navigation we can also directly test CSS values and classes with Cypress. You’ve already seen that we can get() all the elements with a little query, and now we can also add some expectations about by using e.g. have.class or have.css.

All these assertions can be found on the Cypress assertion list which uses Chai under the hood.

Let’s make sure our web header gives the active-item class to a button once we navigate there, and we can even check if it has the exact color which should be the Ionic primary color:

describe('Web App Testing', () => {
 
    it('marks the active page', () => {
        cy.visit('/');
        cy.get('.header ion-button').should('have.length', '4');
        cy.get('.header ion-button').eq(0).should('have.class', 'active-item');

        cy.visit('/products');
        cy.get('.header ion-button').eq(1).should('have.class', 'active-item');

        cy.visit('/about');
        cy.get('.header ion-button').eq(2).should('have.class', 'active-item');
    });

    it('has a blue border when active', () => {
        cy.visit('/');
        cy.get('.header ion-button').eq(0).should('have.css', 'border-bottom', '2px solid rgb(56, 128, 255)');
    });
 
});

I encountered some problems with shadow DOM elements before, so be prepared to debug those lines in case your assertions fail.

Testing Responsive Elements

We can’t only test on one viewport size, we can also simply switch between sizes during our tests if we want to test the exact responsive behaviour of our app!

What made these tests work for me was adding a little wait() after changing the viewport.

You could now tests whether certain elements are visible or not. Only testing for their existence is sometimes not enough as they might still linger in the DOM but could be hidden, so a safer test for our responsive functinality could look like this:

describe('Web App Testing', () => {

    it('shows a menu on small screens', () => {
        cy.visit('/');
        cy.get('ion-menu-button').should('be.not.visible');
        cy.viewport('iphone-x');
        cy.wait(200);
        cy.get('ion-menu-button').should('be.visible');
    });
 
    it('shows a menu on small screens', () => {
        cy.visit('/');
        cy.get('.mobile-header').should('be.not.visible');
        cy.get('.header').should('be.visible');

        cy.viewport('iphone-x');
        cy.wait(200);
        cy.get('.mobile-header').should('be.visible');
        cy.get('.header').should('be.not.visible');
    });

});

We also don’t need to have an assertion that checks if something exists in the first place as that’s the default behaviour of Cypress. Your tests will fail anyway if a certain element doesn’t exist.

Accessing Specific Elements

For all the previous tests we tried to access Ionic components by tag or class name, or even by their position inside an array of potential elements.

This is not the recommended behaviour, and instead you should assign unique data-cy tags to elements in your code that you want to test. For example, the src/app/components/header/header.component.html has a button like this:

<ion-button data-cy="btn-cart" fill="clear" color="dark" (click)="openCart()">
      <ion-icon slot="icon-only" name="cart"></ion-icon>
    </ion-button>

Given that name it’s super easy to directly access exactly this button inside a test:

describe('Web App Testing', () => {
    
    it('opens and closes the cart modal', () => {
        cy.visit('/');
        cy.get('[data-cy=btn-cart]').click();
        cy.get('ion-modal').should('exist');
        cy.get('ion-modal ion-header').contains('Cart');

        cy.get('[data-cy=btn-close]').click();
        cy.wait(200);
        cy.get('ion-modal').should('not.exist');
    });

});

Although it might look a bit ugly and blow up your templates code, this is the Cypress recommendation for accessing elements of your DOM.

Intercepting HTTP Calls & Fixtures

I never thought about faking API calls within E2E test but I noticed that it’s really THAT easy with Cypress!

We already got a folder called fixtures inside our Cypress folder which can hold some dummy data. Let’s add a new file cypress/fixtures/data.json in which we define just one result from the API that we use normally in our app:

[{
  "id": 1,
  "title": "Fjallraven - Foldsack No. 1 Backpack, Fits 15 Laptops",
  "price": 109.95,
  "description": "Your perfect pack for everyday use and walks in the forest. Stash your laptop (up to 15 inches) in the padded sleeve, your everyday",
  "category": "men's clothing",
  "image": "https://fakestoreapi.com/img/81fPKd-2AYL._AC_SL1500_.jpg"
}]

To use that information in the right place you need to know when and where your application makes a certain API call, but then you can simply intercept that call and supply your fixture data as a result instead.

You can then give it a name and wat until it’s called to make sure your fake provider was used, and afterwards check if we really only got one product with our dummy data:

describe('Web App Testing', () => {
    
    it('shows local test JSON', () => {
        cy.intercept({method: 'GET', url:'https://fakestoreapi.com/products'}, { fixture: 'data' }).as('getProducts');
        cy.visit('/products');
        cy.wait(['@getProducts']);
        cy.get('ion-card').should('have.length', '1');
    });

});

If you want to have even more power about those calls and return values or wildcards, make sure to check out the official intercept docs on it!

Capturing Screenshots & Videos

Maybe you also want to have a video of the test run in the end, or capture a screenshot in specific places of your test cases to compare it against a previous result later?

Well, Cypress already records a video when running it from the CLI, and to capture a screenshot simply add the screenshot() call in any place you wish:

describe('Mobile App Testing', () => {

    beforeEach(() => {
        cy.viewport('iphone-5');
        cy.visit('/');
    });

    it('shows 4 jewelery products', () => {
        cy.openMobileProducts();
        cy.get('ion-card').should('have.length', '20');
        cy.screenshot();
    });
});

Now this won’t work with the way we ran the tests before, so you need to run Cypress from your CL and since we only want our tests, we can pass the exact spec file we want to run like this:

npx cypress run --spec cypress/integration/tests.spec.js

After the command has finished you should now find a videos and screenshots folder inside the Cypress folder with the result of your test run!

Plugins & Integration

While we haven’t used them in this tutorial, I thought it’s worth mentioning that we can improve the testing environment and setup even more.

First, there’s a good amount of additional plugins for our favourite VSC IDE (or as well for others) like commands to generate test cases and blocks of code.

And since Cypress is basically a Node process there’s a good amount of additional Plugins that you can install to add even more power to your tests and test runner.

Conclusion

I highly enjoyed using Cypress tests for my Ionic project and will continue using it in the future when E2E tests are required.

Since Angular v12 anyway recommends a different framework besides Protractor, perhaps it’s about time you give Cypress a try when you want to automate some users tests for your Ionic app next time.

You can also find a video version of this tutorial below.

The post Ionic E2E Tests with Cypress.io appeared first on Devdactic - Ionic Tutorials.

Building a Gmail Clone with Ionic & Angular

$
0
0

In this new part of the Built with Ionic series we will explore the concepts used in the popular Gmail application to build a somewhat similar clone with Ionic based on dummy JSON data.

Today we will implement the combination of a tab bar and side menu, plus the inbox UI known from Gmail.
gmail-with-ionic

On top of that we will create a custom popover for our account page, and another cool directive that makes our floating search bar show or hide while we scroll in different directions.

For all of this we don’t need any additional package as we’ll rely on the Ionic components and basic CSS!

App Setup & Styling

To get started, create a new Ionic app that already comes with a side menu. I usually opt for the blank template, but this one is gonna save us some time today and we will see how to easily embed a tab bar in that interface later.

Generate some more additional pages for our app and a module with directive for our animation and we are ready:

ionic start devdacticGmail sidemenu --type=angular

ionic g page pages/tabs
ionic g page pages/mail
ionic g page pages/meet
ionic g page pages/account
ionic g page pages/details

ionic g module directives/sharedDirectives --flat
ionic g directive directives/hideHeader

This will mess up your routing a bit, and we will anyway change this completely to initially load only our tab bar routing instead, so change the src/app/app-routing.module.ts to:

import { NgModule } from '@angular/core';
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';

const routes: Routes = [
  {
    path: '',
    redirectTo: 'tabs',
    pathMatch: 'full'
  },
  {
    path: 'tabs',
    loadChildren: () => import('./pages/tabs/tabs.module').then( m => m.TabsPageModule)
  },
];

@NgModule({
  imports: [
    RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })
  ],
  exports: [RouterModule]
})
export class AppRoutingModule {}

As we will load some dummy JSON data, we need to import the according HttpClientModule inside the src/app/app.module.ts:

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 { HttpClientModule } from '@angular/common/http';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule, HttpClientModule],
  providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
  bootstrap: [AppComponent],
})
export class AppModule {}

Finally, let’s also change one color to make everything match the Google UI a bit more so open the src/theme/variables.scss and replace the primary color with:

--ion-color-primary: #F00002;
  --ion-color-primary-rgb: 240,0,2;
  --ion-color-primary-contrast: #ffffff;
  --ion-color-primary-contrast-rgb: 255,255,255;
  --ion-color-primary-shade: #d30002;
  --ion-color-primary-tint: #f21a1b;

That’s all for the basic setup, now on to the navigation!

Side Menu with Tab Bar

Your app has a side menu right now. You don’t see it, but you can actually drag it in from the side!

The reason for this can be found inside the src/app/app.component.html, which holds some dummy data and the structure for an Ionic split pane:

<ion-app>
  <ion-split-pane contentId="main-content">
    <ion-menu contentId="main-content" type="overlay">
      <ion-content>
        <!-- .... -->
      </ion-content>
    </ion-menu>
    <ion-router-outlet id="main-content"></ion-router-outlet>
  </ion-split-pane>
</ion-app>

This page is initially loaded, and whatever the Angular routing think is the information to display for a certain route will be shown inside the ion-router-outlet. In our case, that’s the tab page we generated and connected in our routing.

Now that we got this clear, let’s also add some routes to the src/app/pages/tabs/tabs-routing.module.ts so we can really build that tab bar:

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

import { TabsPage } from './tabs.page';

const routes: Routes = [
  {
    path: '',
    component: TabsPage,
    children: [
      {
        path: 'mail',
        loadChildren: () => import('../mail/mail.module').then(m => m.MailPageModule)
      },
      {
        path: 'mail/:id',
        loadChildren: () => import('../details/details.module').then( m => m.DetailsPageModule)
      },
      {
        path: 'meet',
        loadChildren: () => import('../meet/meet.module').then(m => m.MeetPageModule)
      },
      {
        path: '',
        redirectTo: 'mail',
        pathMatch: 'full'
      }
    ]
  }
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule],
})
export class TabsPageRoutingModule { }

Routing without a template is nothing, and therefore we can now create the tab bar template inside the src/app/pages/tabs/tabs.page.html with two simple tabs that use the above created routes for mail and meet:

<ion-tabs>
  <ion-tab-bar slot="bottom">
    <ion-tab-button tab="mail">
      <ion-icon name="mail"></ion-icon>
      <ion-label>Mail</ion-label>
    </ion-tab-button>
    <ion-tab-button tab="meet">
      <ion-icon name="videocam"></ion-icon>
      <ion-label>Meet</ion-label>
    </ion-tab-button>
  </ion-tab-bar>
</ion-tabs>

Now your app should display the tabs, and you can still pull in the side menu on both tabs as the side menu is basically the parent of the tab bar.

Inbox List UI

Next step is the UI for the inbox of emails, and we start this by loading the dummy data from https://devdactic.fra1.digitaloceanspaces.com/gmail/data.json.

If you inspect the Gmail client you’ll notice that unknown sender have a coloured circle with a letter, and so we create a custom hex code as a color for every email using the intToRGB() function.

Besides that, the other functions are just for some simple testing and don’t do much, so let’s continue with the src/app/pages/mail/mail.page.ts and change it to:

import { HttpClient } from '@angular/common/http';
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { PopoverController } from '@ionic/angular';
import { AccountPage } from '../account/account.page';

@Component({
  selector: 'app-mail',
  templateUrl: './mail.page.html',
  styleUrls: ['./mail.page.scss'],
})
export class MailPage implements OnInit {
  emails = [];

  constructor(private http: HttpClient, private popoverCtrl: PopoverController, private router: Router) { }

  ngOnInit() {
    this.http.get<any[]>('https://devdactic.fra1.digitaloceanspaces.com/gmail/data.json').subscribe(res => {
      this.emails = res;
      for (let e of this.emails) {
        // Create a custom color for every email
        e.color = this.intToRGB(this.hashCode(e.from));
      }
    });
  }

  openDetails(id) {
    this.router.navigate(['tabs', 'mail', id]);
  }

  // https://stackoverflow.com/questions/3426404/create-a-hexadecimal-colour-based-on-a-string-with-javascript
  private hashCode(str) {
    var hash = 0;
    for (var i = 0; i < str.length; i++) {
      hash = str.charCodeAt(i) + ((hash << 5) - hash);
    }
    return hash;
  }

  private intToRGB(i) {
    var c = (i & 0x00FFFFFF)
      .toString(16)
      .toUpperCase();

    return '#' + '00000'.substring(0, 6 - c.length) + c;
  }

  doRefresh(ev) {
    setTimeout(() => {
      ev.target.complete();
    }, 2000);
  }
}

I actually used the JSON generator tool to generate that information – a super helpful tool!

Now that we got our data, let’s create the template for the list. We need to take care of two areas:

  • The floating search bar with menu button and account button
  • The actual list of emails with a star function

While I would usually use ion-item for both cases, we can’t use it today as an overall click handler on the item wouldn’t allow that fine control of different actions within the item.

Therefore, in both cases we instead create a custom div element with ion-row inside to structure our elements.

Inside the search bar we can also simply put the menu button to toggle the side menu – this doesn’t have to be in the standard navigation bar area!

We’ll put a refresher below it, but eventually we’ll have to reposition some items with CSS afterwards as the search area should float above the rest of our app and makes use of the fixed slot, which will put it sticky to the top (with some more CSS).

Inside the list of emails we can now use our custom background color to style a box in the first place, and we’ll only display the first letter of the sender by using the Angular slice pipe.

And we can use that pipe another time when we want to display a preview of the content and add “…” to the end if the text is too long. It might look scary, but it’s basically just an inline if/else to slice the string and add dots at the end if the length is above a certain value.

The star at the end can be toggled and changes both the color and icon name itself, and it’s possible since we don’t have a parent that catches that click event.

Now open the src/app/pages/mail/mail.page.html and change it to:

<ion-content>

  <div class="search-overlay ion-align-items-center" slot="fixed" #search>
    <ion-row>
      <ion-col size="2">
        <ion-menu-button color="dark"></ion-menu-button>
      </ion-col>
      <ion-col size="8">
        <ion-input placeholder="Search in emails"></ion-input>
      </ion-col>
      <ion-col size="2">
        <ion-avatar tappable (click)="openAccount($event)">
          <img src="https://en.gravatar.com/userimage/71535578/a4803efe6592196d7bcda63224972984.jpg" />
        </ion-avatar>
      </ion-col>
    </ion-row>
  </div>

  <ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
    <ion-refresher-content refreshingSpinner="crescent"></ion-refresher-content>
  </ion-refresher>

  <ion-list>

    <ion-list-header>Inbox</ion-list-header>
    <ion-item lines="none" *ngFor="let m of emails" class="email">
      <ion-row class="ion-align-items-center">
        <ion-col size="2" (click)="openDetails(m.id)" class="ion-align-self-center">
          <div class="email-circle" [style.background]="m.color">
            {{ m.from | slice:0:1 }}
          </div>
        </ion-col>
        <ion-col size="8" (click)="openDetails(m.id)">
          <ion-label color="dark" class="ion-text-wrap ion-text-capitalize" [style.font-weight]="!m.read ? 'bold' : ''">
            {{ m.from.split('@')[0] }}
            <p class="excerpt">
              {{ (m.content.length>50)? (m.content | slice:0:50)+'...':(m.content) }}
            </p>
          </ion-label>
        </ion-col>
        <ion-col size="2">
          <div class="ion-text-right" style="z-index: 5;" tappable (click)="m.star = !m.star;">
            <p class="date">{{ m.date | date:'dd. MMM' }}</p>
            <ion-icon [name]="m.star ? 'star' : 'star-outline'" [color]="m.star ? 'warning' : 'medium'"></ion-icon>
          </div>
        </ion-col>
      </ion-row>
    </ion-item>
  </ion-list>
</ion-content>

By now this will look interesting, but it’s not the Gmail style. We need to add padding to our elements to position them below the search bar, and we manually need to style that bar with some shadow.

On top of that we need to make our circle with color look like an actual circle, and position the text inside the middle of it using flex layout properties.

To make our view look more polished, go ahead and change the src/app/pages/mail/mail.page.scss to:

ion-content {
  --padding-top: 40px;
}

.search-overlay {
  margin: 20px;
  width: 90%;

  ion-row {
    margin-top: 40px;
    box-shadow: 0px 2px 3px 0px rgb(0 0 0 / 15%);
    border-radius: 8px;
    background: #fff;
  }
}

ion-list {
  margin-top: 80px;
}

ion-refresher {
  margin-top: 120px;
}

.email {
  margin-bottom: 6px;

  ion-label {
    white-space: pre;
  }

  .excerpt {
    padding-top: 4px;
  }

  .date {
    font-size: small;
  }
}

ion-avatar {
  width: 40px;
  height: 40px;
}

.email-circle {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  color: #e4e4e4;
  text-transform: capitalize;
  font-weight: 500;

  display: flex;
  align-items: center;
  justify-content: center;
}

We’ve taken a big step and the list is basically functional at this point. But there are two more things we want to add.

Creating the Account Popover

First is the popover which can be toggled when clicked on the image inside the search bar. Let’s start by adding a new function to our src/app/pages/mail/mail.page.ts to call our component:

async openAccount(ev) {
  const popover = await this.popoverCtrl.create({
    component: AccountPage,
    event: ev,
    cssClass: 'custom-popover'
  });

  await popover.present();
}

We are also passing in a custom CSS class which we’ll add later.

For now, we should also import the module of that account page inside the src/app/pages/mail/mail.module.ts to prevent any Angular issues:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';

import { IonicModule } from '@ionic/angular';

import { MailPageRoutingModule } from './mail-routing.module';

import { MailPage } from './mail.page';
import { AccountPageModule } from '../account/account.module';
import { SharedDirectivesModule } from '../../directives/shared-directives.module';

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    IonicModule,
    MailPageRoutingModule,
    AccountPageModule,
    SharedDirectivesModule
  ],
  declarations: [MailPage]
})
export class MailPageModule {}

The page we display itself isn’t very special, simply add a short function to the src/app/pages/account/account.page.ts so we can later close it:

import { Component, OnInit } from '@angular/core';
import { PopoverController } from '@ionic/angular';

@Component({
  selector: 'app-account',
  templateUrl: './account.page.html',
  styleUrls: ['./account.page.scss'],
})
export class AccountPage implements OnInit {

  constructor(private popoverCtrl: PopoverController) { }

  ngOnInit() {}

  close() {
    this.popoverCtrl.dismiss();
  }
}

We can also close it with a backdrop tap but there’s a close button inside the page as well.

The page now simply displays some dummy buttons without real functionality, just make it look like Gmail for now by changing the src/app/pages/account/account.page.html to:

<ion-header class="ion-no-border">
  <ion-toolbar class="ion-text-center">
    <ion-buttons slot="start">
      <ion-button (click)="close()" fill="clear" color="dark">
        <ion-icon name="close" slot="icon-only"></ion-icon>
      </ion-button>
    </ion-buttons>
    <img src="./assets/logo.png" [style.width]="'70px'">
  </ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
  <ion-item lines="none">
    <ion-avatar slot="start">
      <img src="https://en.gravatar.com/userimage/71535578/a4803efe6592196d7bcda63224972984.jpg" />
    </ion-avatar>
    <ion-label color="dark">
      Simon Grimm
      <p>saimon@devdactic.com</p>
    </ion-label>
  </ion-item>
  <ion-button expand="full" shape="round" fill="outline" color="dark" class="ion-padding">
    Manage your Google Account
  </ion-button>

  <ion-item class="ion-margin-top" lines="none">
    <ion-icon name="person-add-outline" slot="start"></ion-icon>
    Add another account
  </ion-item>
  <ion-item class="ion-margin-bottom" lines="none">
    <ion-icon name="person-outline" slot="start"></ion-icon>
    Manage accounts on this device
  </ion-item>
</ion-content>

<ion-footer>
  <ion-toolbar>
    <ion-row>
      <ion-col size="6" class="ion-text-right">
        Privacy Policy
      </ion-col>
      <ion-col size="6" class="ion-text-left">
        Terms of Service
      </ion-col>
    </ion-row>
  </ion-toolbar>
</ion-footer>

Finally we need to change the size of the popover with our custom class, and we can do this inside the src/global.scss:

.custom-popover {
    --ion-backdrop-opacity: 0.6;

    .popover-arrow {
        display: none;
    }
    
    .popover-content {
        left: 10px !important;
        width: calc(100% - 20px);
    }
}

Now the little arrow will be hidden, we have a darker background and use all of the available width of the view!

Header Hide Directive

The last missing piece that makes the Gmail inbox special is the floating search bar which disappears when you scroll down, and comes back when you scroll in the opposite direction.

It doesn’t sound complicated from the outside but took me hours to arrive at this final solution..

But let’s start with the easy part, which is making sure your directive is declared and exported correctly inside the src/app/directives/shared-directives.module.ts:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HideHeaderDirective } from './hide-header.directive';

@NgModule({
  declarations: [
    HideHeaderDirective
  ],
  imports: [
    CommonModule
  ],
  exports: [HideHeaderDirective]
})
export class SharedDirectivesModule { }

Now the directive will listen to scroll events and change the appearance of our search bar (header) element.

The logic is based on some calculations and ideas:

  • We need to store the last Y position within saveY to notice in which direction we scroll
  • When we notice that we changed directions, we store that exact position inside previousY so we can use it for our caluclation
  • We will change the top and opacity properties of our search bar
  • The scrollDistance is the value at which the element will be gone completely, so the position is between 0 and -50, while the opacity is between 0 and 1

I tried my best to add comments in all places to understand correctly what is calculated, so go ahead and change your src/app/directives/hide-header.directive.ts to this:

import { Directive, HostListener, Input, Renderer2 } from '@angular/core';
import { DomController } from '@ionic/angular';

enum Direction {
  UP = 1,
  DOWN = 0
}
@Directive({
  selector: '[appHideHeader]'
})
export class HideHeaderDirective {

  @Input('appHideHeader') header: any;
  readonly scrollDistance = 50;
  previousY = 0;
  direction: Direction = Direction.DOWN;
  saveY = 0;

  constructor(
    private renderer: Renderer2,
    private domCtrl: DomController
  ) { }

  @HostListener('ionScroll', ['$event']) onContentScroll($event: any) {

    // Skip some events that create ugly glitches
    if ($event.detail.currentY <= 0 || $event.detail.currentY == this.saveY){
      return;
    }

    const scrollTop: number = $event.detail.scrollTop;
    let newDirection = Direction.DOWN;

    // Calculate the distance from top based on the previousY
    // which is set when we change directions
    let newPosition = -scrollTop + this.previousY;

    // We are scrolling up the page
    // In this case we need to reduce the position first
    // to prevent it jumping from -50 to 0
    if (this.saveY > $event.detail.currentY) {
      newDirection = Direction.UP;
      newPosition -= this.scrollDistance;
    }

    // Make our maximum scroll distance the end of the range
    if (newPosition < -this.scrollDistance) {
      newPosition = -this.scrollDistance;
    }
    
    // Calculate opacity between 0 and 1
    let newOpacity = 1 - (newPosition / -this.scrollDistance);

    // Move and set the opacity of our element
    this.domCtrl.write(() => {
      this.renderer.setStyle(this.header, 'top', Math.min(0, newPosition) + 'px');
      this.renderer.setStyle(this.header, 'opacity', newOpacity);
    });

    // Store the current Y value to see in which direction we scroll
    this.saveY = $event.detail.currentY;

    // If the direction changed, store the point of change for calculation
    if (newDirection != this.direction) {
      this.direction = newDirection;
      this.previousY = scrollTop;
    }

  }
}

Again, it doesn’t sound like a difficult part, but handling the direction switch and not making animations play again (which we also prevent with the initial if) was really challenging.

To put that directive to use, we can now enable scroll events and pass in the template reference to our search element inside the src/app/pages/mail/mail.page.html like this:

<ion-content scrollEvents="true" [appHideHeader]="search">

  <div class="search-overlay ion-align-items-center" slot="fixed" #search>

And with that we are finished with the basic Gmail clone!

Conclusion

We’ve created a cool clone with some special functionalities, and although the result looks pretty cool there are still three things missing:

  1. The FAB button at the bottom which also animates
  2. The cool slide to delete/archive function on an email row
  3. A header shadow animation inside the email details page

If you are interested in this let me know in the comments – most likely I’ll get into this anyway as I kinda want to make this Gmail clone with Ionic complete!

You can also find a video version of this tutorial below.

The post Building a Gmail Clone with Ionic & Angular appeared first on Devdactic - Ionic Tutorials.


Building a Gmail Swipe to Delete Gesture & Animated FAB with Ionic Angular

$
0
0

We have previously created a basic Gmail clone with Ionic, but there are certain UI and especially UX elements missing that make the original app look so amazing.

In this tutorial we will implement an animated fab button and a special behaviour to slide and delete items from an Ionic list.

ionic-gmail-animations

All of this will be possible through the usage of Ionic gestures and the Animation API, which are truly powerful as you will see.

Prerequisite: If you want to follow along exactly, please download the code for the previous tutorial from this Github repository!

Creating a FAB Animation while scrolling

The fist part will be a bit easier, we simply want to expand or shrink a fab button when we scroll up or down. To get started, generate a new directive:

ionic g directive directives/animatedFab

In order to use this directive we need to include it within the src/app/directives/shared-directives.module.ts:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HideHeaderDirective } from './hide-header.directive';
import { AnimatedFabDirective } from './animated-fab.directive';

@NgModule({
  declarations: [
    HideHeaderDirective,
    AnimatedFabDirective
  ],
  imports: [
    CommonModule
  ],
  exports: [
    HideHeaderDirective,
    AnimatedFabDirective
  ]
})
export class SharedDirectivesModule { }

Now we can add the directive to our main page as it will listen to scroll events just like a bunch of other directives we have set up in our Built with Ionic tutorials before.

The actual fab button code goes to the bottom of our page and can be referenced through the #fab template reference, which we pass to our appAnimatedFab directive.

Go ahead and change the src/app/pages/mail/mail.page.html in these places now:

<ion-content scrollEvents="true" [appHideHeader]="search" [appAnimatedFab]="fab">
<!--- all the other code... -->

 <ion-fab vertical="bottom" horizontal="end" slot="fixed">
    <ion-fab-button color="light" #fab>
      <ion-icon name="pencil-outline" color="primary"></ion-icon>
      <span>Compose</span>
    </ion-fab-button>
  </ion-fab>
</ion-content>

The default Ionic fab is just a round button, but we need to make it expanded by default. To do this, we can manually define the width and height, change the border radius so it doesn’t look like an egg and apply a stronger shadow.

For this, open the src/app/pages/mail/mail.page.scss and add:

ion-fab-button {
  width: 140px;
  height: 48px;
  --border-radius: 20px;
  --box-shadow: 5px 12px 30px -8px rgba(0,0,0,0.53);

  ion-icon {
    font-size: 20px;
  }
}

ion-fab-button::part(native) {
  color: var(--ion-color-primary);
  font-weight: 500;
  font-size: 16px;
}

We are also using the shadow part of the fab button here to directly apply a styling to something inside the shadow component without CSS variables!

Now we can create the actual directive, which will get all the scroll events of our content. Basically we want to:

  • Shrink the fab while scrolling down
  • Fade and move out the text inside the span of the fab
  • Reverse this operation when we scroll up again.

And we can actually do all of this by defining two simple Ionic animations! These animate the according elements, and we can combine those two single animations inside one by passing an array of animations to the addAnimation() function of the Animation controller.

Inside of the scroll listener we will also check the direction of scroll and use the expanded variable to make sure we only run each animation once when the direction has changed.

Now open the src/app/directives/animated-fab.directive.ts and change it to:

import { AfterViewInit, Directive, HostListener, Input } from '@angular/core';
import { AnimationController, Animation } from '@ionic/angular';

@Directive({
  selector: '[appAnimatedFab]'
})
export class AnimatedFabDirective implements AfterViewInit {
  @Input('appAnimatedFab') fab: any;

  constructor(private animationCtrl: AnimationController) { }

  shrinkAnimation: Animation;
  expanded = true;

  ngAfterViewInit() {
    this.fab = this.fab.el;
    this.setupAnimation();
  }

  setupAnimation() {
    const textSpan = this.fab.querySelector('span');
    
    const shrink = this.animationCtrl.create('shrink')
      .addElement(this.fab)
      .duration(400)
      .fromTo('width', '140px', '50px');

    const fade = this.animationCtrl.create('fade')
      .addElement(textSpan)
      .duration(400)
      .fromTo('opacity', 1, 0)
      .fromTo('width', '70px', '0px');

    this.shrinkAnimation = this.animationCtrl.create('shrink-animation')
      .duration(400)
      .easing('ease-out')
      .addAnimation([shrink, fade])
  }

  @HostListener('ionScroll', ['$event']) onContentScroll($event: any) {
    if ($event.detail.deltaY > 0 && this.expanded) {
      // Scrolling down
      this.expanded = false;
      this.shrinkFab();
    } else if ($event.detail.deltaY < 0 && !this.expanded) {
      // Scrolling up
      this.expanded = true;
      this.expandFab();
    }
  }

  expandFab() {
    this.shrinkAnimation.direction('reverse').play();
  }

  shrinkFab() {
    this.shrinkAnimation.direction('alternate').play();
  }
}

Initially I thought we need a complete reverse animation, but using direction('reverse') is completely enough to achieve exactly what we need!

Now you can scroll your page up and down, and the fab button will shrink/expand depending on your scroll direction.

Sliding Item to Archive and Delete

Now things will get more challenging but the reward is also bigger. We want to implement a slide to delete behaviour with custom animation and different background colors on both sides of our email, just like inside the Gmail application.

For this we need a custom component with a new module, and we can also install the Capacitor haptics package for some icing on the cake, so go ahead and run:

ionic g module components/sharedComponents --flat
ionic g component components/swipeItem

npm install @capacitor/haptics

First of all we need to declare and export our component in the new module, and also import all necessary other modules we might need.

Therefore go ahead and change the src/app/components/shared-components.module.ts to:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SwipeItemComponent } from './swipe-item/swipe-item.component';
import { IonicModule } from '@ionic/angular';
import { RouterModule } from '@angular/router';

@NgModule({
  declarations: [SwipeItemComponent],
  imports: [
    CommonModule,
    IonicModule,
    RouterModule
  ],
  exports: [SwipeItemComponent]
})
export class SharedComponentsModule { }

Now we can bring over most of the original template for displaying our Gmail styled icon, but we will put another wrapper div in front of it which will act as the background that can be revealed when we swipe the item.

Go ahead and change the src/app/components/swipe-item/swipe-item.component.html to this:

<div class="wrapper" #wrapper>
  <div class="column">
    <ion-icon name="trash-outline" color="light" class="ion-margin-start" #trash></ion-icon>
  </div>
  <div class="column" class="ion-text-right">
    <ion-icon name="archive-outline" color="light" class="ion-margin-end" #archive></ion-icon>
  </div>
</div>
<!-- The actual item -->
<ion-item class="email" lines="none">
  <ion-row class="ion-align-items-center">
    <ion-col size="2" (click)="openDetails(m.id)">
      <div class="email-circle" [style.background]="m.color">
        {{ m.from | slice:0:1 }}
      </div>
    </ion-col>
    <ion-col size="8" (click)="openDetails(m.id)">
      <ion-label color="dark" [style.font-weight]="!m.read ? 'bold' : ''" class="ion-text-capitalize ion-text-wrap">
        {{ m.from.split('@')[0] }}
        <p class="excerpt">
          {{ (m.content.length>50)? (m.content | slice:0:50)+'...' : (m.content) }}
        </p>
      </ion-label>
    </ion-col>
    <ion-col size="2">
      <div class="ion-text-right" tappable (click)="m.star = !m.star;">
        <p class="date">{{ m.date | date:'dd. MMM' }}</p>
        <ion-icon [name]="m.star ? 'star' : 'star-outline'" [color]="m.star ? 'warning' : 'medium'">
        </ion-icon>
      </div>
    </ion-col>
  </ion-row>
</ion-item>

I’ve taken the markup for the item 99% from the mail page, and we can also bring over the original styling for the email. On top of that we make our wrapper class use the same fixed height as our item, and use a flex box layout to align the icons within in the center and make the columns divide that space evenly.

Open the src/app/components/swipe-item/swipe-item.component.scss now and change it to this:

.wrapper {
  background-color: red;
  display: flex;
  flex-direction: row;
  align-items: center;
  width: 100%;
  height: 89px;
  position: absolute;
}

.column {
  display: flex;
  flex-direction: column;
  flex-basis: 100%;
  flex: 1;
}

.rounded {
  border-radius: 10px;
  z-index: 2;
  box-shadow: 0px 6px 13px -5px rgb(0 0 0 / 28%);
}

ion-item {
  height: 89px;
  background: #fff;
  display: flex;
}

// Original styling from first part
// Copied from mail.page.scss
.email {
  .excerpt {
    padding-top: 4px;
  }

  .date {
    font-size: small;
  }
}

.email-circle {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #efefef;
  text-transform: capitalize;
  font-weight: 500;
}

I’ve also added a class rounded which will be added to an item once we start dragging, which will make the current item stand out with additional shadow.

Now it’s time for the logic, and this is a bit more complicated:

  • We define an ANIMATION_BREAKPOINT which is the distance after which an item can be released and will be delete. Until that value, the item will only snap back to it’s original place
  • We need a gesture and handle the move and end of our item
  • On move, we check the direction and color the background of our wrapper red or green depending on the x direction
  • On move, we transform the X coordinates according to the current delta to move the item left or right
  • On move, we animate the trash/archive icon if we crossed the breakpoint and run a function to handle this
  • On end, we check if we are above our breakpoint and slide the item out to the left/right and run a custom delete animation that we define upfront. This function animates the height, so the item flys out and the row collapses at the same time
  • On end, when the animation has finished we emit this to the parent component using an EventEmitter

All of this can be achieved with the Ionic gesture and animation controller, so go ahead and change the src/app/components/swipe-item/swipe-item.component.ts to:

import { Component, Input, ViewChild, ElementRef, AfterViewInit, Output, EventEmitter } from '@angular/core';
import { Router } from '@angular/router';
import { Animation, AnimationController, GestureController, IonItem } from '@ionic/angular';
import { Haptics, ImpactStyle } from '@capacitor/haptics';

const ANIMATION_BREAKPOINT = 70;

@Component({
  selector: 'app-swipe-item',
  templateUrl: './swipe-item.component.html',
  styleUrls: ['./swipe-item.component.scss'],
})
export class SwipeItemComponent implements AfterViewInit {
  @Input('email') m: any;
  @ViewChild(IonItem, { read: ElementRef }) item: ElementRef;
  @ViewChild('wrapper') wrapper: ElementRef;
  @ViewChild('trash', { read: ElementRef, static: false }) trashIcon: ElementRef;
  @ViewChild('archive', { read: ElementRef }) archiveIcon: ElementRef;

  @Output() delete: EventEmitter<any> = new EventEmitter();

  bigIcon = false;

  trashAnimation: Animation;
  archiveAnimation: Animation;
  deleteAnimation: Animation;

  constructor(private router: Router, private gestureCtrl: GestureController, private animationCtrl: AnimationController) { }

  ngAfterViewInit() {
    this.setupIconAnimations();

    const style = this.item.nativeElement.style;
    const windowWidth = window.innerWidth;

    this.deleteAnimation = this.animationCtrl.create('delete-animation')
      .addElement(this.item.nativeElement)
      .duration(300)
      .easing('ease-out')
      .fromTo('height', '89px', '0');

    const moveGesture = this.gestureCtrl.create({
      el: this.item.nativeElement,
      gestureName: 'move',
      threshold: 0,
      onStart: ev => {
        style.transition = '';
      },
      onMove: ev => {
        // Make the item stand out
        this.item.nativeElement.classList.add('rounded');

        if (ev.deltaX > 0) {
          this.wrapper.nativeElement.style['background-color'] = 'var(--ion-color-primary)';
          style.transform = `translate3d(${ev.deltaX}px, 0, 0)`;
        } else if (ev.deltaX < 0) {
          this.wrapper.nativeElement.style['background-color'] = 'green';
          style.transform = `translate3d(${ev.deltaX}px, 0, 0)`;
        }

        // Check if we need to animate trash icon
        if (ev.deltaX > ANIMATION_BREAKPOINT && !this.bigIcon) {
          this.animateTrash(true);
        } else if (ev.deltaX > 0 && ev.deltaX < ANIMATION_BREAKPOINT && this.bigIcon) {
          this.animateTrash(false);
        }

        // Check if we need to animate archive icon
        if (ev.deltaX < -ANIMATION_BREAKPOINT && !this.bigIcon) {
          this.animateArchive(true);
        } else if (ev.deltaX < 0 && ev.deltaX > -ANIMATION_BREAKPOINT && this.bigIcon) {
          this.animateArchive(false);
        }

      },
      onEnd: ev => {
        style.transition = '0.2s ease-out';
        this.item.nativeElement.classList.remove('rounded');

        // Check if we are past the delete or archive breakpoint
        if (ev.deltaX > ANIMATION_BREAKPOINT) {
          style.transform = `translate3d(${windowWidth}px, 0, 0)`;
          this.deleteAnimation.play()
          this.deleteAnimation.onFinish(() => {
            this.delete.emit(true);
          });
        } else if (ev.deltaX < -ANIMATION_BREAKPOINT) {
          style.transform = `translate3d(-${windowWidth}px, 0, 0)`;
          this.deleteAnimation.play()
          this.deleteAnimation.onFinish(() => {
            this.delete.emit(true);
          });
        } else {
          style.transform = '';
        }
      }
    });

    // Don't forget to enable!
    moveGesture.enable(true);
  }

  setupIconAnimations() { }

  animateTrash(zoomIn) { }

  animateArchive(zoomIn) { }

  openDetails(id) { }
}

It’s actually quite straight forward if you get the various if/else statements right!

Now we just need to define the functions for our icons, which will simply scale the icon when we crossed the breakpoint or reverse that animation otherwise.

At this point we can also use the haptics plugin for a real feedback about a change to the user – awesome UX from the Gmail application!

Go ahead and implement the missing functions like this now:

setupIconAnimations() {
  this.trashAnimation = this.animationCtrl.create('trash-animation')
    .addElement(this.trashIcon.nativeElement)
    .duration(300)
    .easing('ease-in')
    .fromTo('transform', 'scale(1)', 'scale(1.5)');

  this.archiveAnimation = this.animationCtrl.create('archive-animation')
    .addElement(this.archiveIcon.nativeElement)
    .duration(300)
    .easing('ease-in')
    .fromTo('transform', 'scale(1)', 'scale(1.5)')
}

animateTrash(zoomIn) {
  this.bigIcon = zoomIn;
  if (zoomIn) {
    this.trashAnimation.direction('alternate').play();
  } else {
    this.trashAnimation.direction('reverse').play();
  }
  Haptics.impact({ style: ImpactStyle.Light });
}

animateArchive(zoomIn) {
  this.bigIcon = zoomIn;
  if (zoomIn) {
    this.archiveAnimation.direction('alternate').play();
  } else {
    this.archiveAnimation.direction('reverse').play();
  }
  Haptics.impact({ style: ImpactStyle.Light });
}

openDetails(id) {
  this.router.navigate(['tabs', 'mail', id]);
}

Now we just need to use our new item instead of the original one in our app.

Using the Swipe Item

To put it to use, bring up the src/app/pages/mail/mail.module.ts and import our new components module:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';

import { IonicModule } from '@ionic/angular';

import { MailPageRoutingModule } from './mail-routing.module';

import { MailPage } from './mail.page';
import { AccountPageModule } from '../account/account.module';
import { SharedDirectivesModule } from '../../directives/shared-directives.module';
import { SharedComponentsModule } from '../../components/shared-components.module';

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    IonicModule,
    MailPageRoutingModule,
    AccountPageModule,
    SharedDirectivesModule,
    SharedComponentsModule
  ],
  declarations: [MailPage]
})
export class MailPageModule {}

We can simply exchange the previous code and continue to use the for loop while also adding the output from our component and calling the removeMail() function when we catch that event.

Therefore, open the src/app/pages/mail/mail.page.html and replace the necessary lines like this:

<ion-list>
  <ion-list-header>Inbox</ion-list-header>
  <app-swipe-item *ngFor="let m of emails; let i = index;" [email]="m" (delete)="removeMail(m.id)"></app-swipe-item>
</ion-list>

On top of that we can remove the item from the data array now and manually update the view using the Angular ChangeDetectorRef since otherwise the view wouldn’t be updated correctly (check out my video below at the end to see the difference).

To do so, simply change the required parts inside the src/app/pages/mail/mail.page.ts to:

// Update constructor
  constructor(private http: HttpClient, private router: Router,
    private popoverCtrl: PopoverController, private changeDetector: ChangeDetectorRef) { }

    // new function
  removeMail(id) {
    this.emails = this.emails.filter(email => email.id != id);
    this.changeDetector.detectChanges();
  }

And with that our custom swipe to delete/archive works, running smoothly and looking 99% like the original Gmail style!

Conclusion

Building advanced UX patterns into your Ionic application is most of the time a straight forward task, and you can implement almost every pattern you see in popular apps.

Usually you can break down an animation into smaller chunks, build each of them separately and then combine them to one epic result.

If you want to see more of these apps, check out the other Built with Ionic tutorials as well!

The post Building a Gmail Swipe to Delete Gesture & Animated FAB with Ionic Angular appeared first on Devdactic - Ionic Tutorials.

How to Build a Capacitor File Explorer with Ionic Angular

$
0
0

Working with the underlying filesystem on iOS and Android has always been challenging given the differences in both platforms, but with Capacitor you can take a simple approach that works for all platforms.

In this tutorial we will build a file explorer to create files and folders, navigate through your different folder levels and add all necessary functions to copy, delete and open files!
capacitor-file-explorer-ionic

The basic operations can all be achieved with the Capacitor filesystem plugin, but we’ll add two more plugins to our app for even better performance and a preview functionality of files!

Setting up the Capacitor file explorer

We start with a blank Ionic app and enable Capacitor. On top of that we need the following plugins for our file explorer:

  • Since Capacitor 3 we need to install the Filesystem plugin from its own package
  • We add the Capacitor blob writer for more efficient write operations
  • We add the the preview-any-file Cordova plugin and the according Ionic native wrapper to open files on a device

Go ahead and install all of that now:

ionic start devdacticExplorer blank --type=angular --capacitor
cd ./devdacticExplorer

// Capacitor plugins
npm i @capacitor/filesystem
npm i capacitor-blob-writer

// Add a Cordova plugin to preview files
npm i @ionic-native/core
npm i cordova-plugin-preview-any-file
npm i @ionic-native/preview-any-file

// Add your platforms
ionic capacitor add ios
ionic capacitor add android

In the end you should also add the ios/android platforms because we need to perform some customisation as well soon.

Since we have added an Ionic native wrapper for the Cordova plugin we also need to add it to the array of providers inside our src/app/app.module.ts:

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 { PreviewAnyFile } from '@ionic-native/preview-any-file/ngx';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule],
  providers: [
    { provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
    PreviewAnyFile
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}

Finally we can change the routing a bit in order to make our default page accessible through different paths. This will help us build the navigation inside our app by passing a folder name for the :folder parameter inside the path.

Open the src/app/app-routing.module.ts and change it to:

import { NgModule } from '@angular/core';
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';

const routes: Routes = [
  { path: '', redirectTo: 'home', pathMatch: 'full' },
  { path: 'home', loadChildren: () => import('./home/home.module').then(m => m.HomePageModule) },
  { path: 'home/:folder', loadChildren: () => import('./home/home.module').then(m => m.HomePageModule) },
];

@NgModule({
  imports: [
    RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })
  ],
  exports: [RouterModule]
})
export class AppRoutingModule { }

Before we dive into the actual application we quickly need to change some settings for Android.

Android permissions

To make our blob writer plugin work, we need to create a new file at android/app/src/main/res/xml/network_security_config.xml with the following content:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="false">localhost</domain>
        <!-- Add your IP if you want to use livereload on a device -->
        <domain includeSubdomains="true">192.168.2.114</domain>
    </domain-config>
</network-security-config>

This is necessary as the writer uses local server under the hood (read more on the Github page) and if you also want to run your app with livereload on your Android device you need to add the local IP of your computer in this file.

Now we need to tell Android about that file, and to make all the other Capacitor filesystem stuff work we also need to set the android:requestLegacyExternalStorage inside the application tag of the android/app/src/main/AndroidManifest.xml:

<application
        android:networkSecurityConfig="@xml/network_security_config"
        android:requestLegacyExternalStorage="true"
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">

For iOS we don’t need any additional plugins right now, so let’s dive into the fun!

Display and create folders

We start with the basics, so first of all we need a way to load all files and folders at a specific location which we can do through the Capacitor filesystem plugin.

I’ve created another APP_DIRECTORY variable so we can use the same directory in our app in all places – feel free to use a different location but usually the Directory.Documents is the best place for user generated content that shouldn’t be cleared from the OS.

Since our home page will be used with a variable in the path when we navigate, we can load the value from the active route and afterwards call the Filesystem.readdir() with our current path.

Creating a folder is likewise easy, since we have the current path level we can simply create a new folder at that location using the Filesystem.mkdir() function.

I’ve created the outline for all the other functions already so you only need to implement them later. Go ahead for now by changing the src/app/home/home.page.ts to:

import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Filesystem, Directory } from '@capacitor/filesystem';
import { AlertController, isPlatform, ToastController } from '@ionic/angular';
import write_blob from 'capacitor-blob-writer';
import { PreviewAnyFile } from '@ionic-native/preview-any-file/ngx';

const APP_DIRECTORY = Directory.Documents;

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})
export class HomePage implements OnInit {
  folderContent = [];
  currentFolder = '';
  copyFile = null;
  @ViewChild('filepicker') uploader: ElementRef;

  constructor(private route: ActivatedRoute, private alertCtrl: AlertController, private router: Router,
    private previewAnyFile: PreviewAnyFile, private toastCtrl: ToastController) { }

  ngOnInit() {
    this.currentFolder = this.route.snapshot.paramMap.get('folder') || '';
    this.loadDocuments();
  }

  async loadDocuments() {
    const folderContent = await Filesystem.readdir({
      directory: APP_DIRECTORY,
      path: this.currentFolder
    });

    // The directory array is just strings
    // We add the information isFile to make life easier
    this.folderContent = folderContent.files.map(file => {
      return {
        name: file,
        isFile: file.includes('.')
      }
    });
  }

  async createFolder() {
    let alert = await this.alertCtrl.create({
      header: 'Create folder',
      message: 'Please specify the name of the new folder',
      inputs: [
        {
          name: 'name',
          type: 'text',
          placeholder: 'MyDir'
        }
      ],
      buttons: [
        {
          text: 'Cancel',
          role: 'cancel'
        },
        {
          text: 'Create',
          handler: async data => {
            await Filesystem.mkdir({
              directory: APP_DIRECTORY,
              path: `${this.currentFolder}/${data.name}`
            });
            this.loadDocuments();
          }
        }
      ]
    });

    await alert.present();
  }

  addFile() { }

  async fileSelected($event) { }

  async itemClicked(entry) { }

  async openFile(entry) { }
  b64toBlob = (b64Data, contentType = '', sliceSize = 512) => { }

  async delete(entry) { }

  startCopy(file) { }

  async finishCopyFile(entry) { }
}

Since the filesystem only returns the file name as string, we manually check if the path contains a dot to set a flag for files/directories as we need to perform different functions later. You could also improve that by using the stat() function, but then you’d have to handle all those async calls correctly.

For now we can also implement the whole view, let’s outline the most important parts:

  • We will change the header color when we start a copy action (later)
  • Use a hidden input to upload files later
  • Iterate through all files of the current level and display an item with basic information
  • Add option buttons to trigger delete and copy
  • Use a fab list to create folders and files

Most of this won’t work yet, but once we got this in place we can focus on the actual functionality so open the src/app/home/home.page.html and change it to:

<ion-header>
  <ion-toolbar [color]="copyFile ? 'secondary' : 'primary'">
    <ion-buttons slot="start" *ngIf="currentFolder != ''">
      <ion-back-button></ion-back-button>
    </ion-buttons>
    <ion-title>
      {{ currentFolder || 'Devdactic Explorer' }}
    </ion-title>
  </ion-toolbar>
</ion-header>
 
<ion-content>
  <!-- For opening a standard file picker -->
  <input hidden type="file" #filepicker (change)="fileSelected($event)" />

  <!-- Info if the directory is empty -->
  <ion-text color="medium" *ngIf="folderContent.length == 0" class="ion-padding ion-text-center">
    <p>No documents found</p>
  </ion-text>
 
  <ion-list>
    <ion-item-sliding *ngFor="let f of folderContent">
      <!-- The actual file/folder item with click event -->
      <ion-item (click)="itemClicked(f)">
        <ion-icon [name]="f.isFile ? 'document-outline' : 'folder-outline'" slot="start"></ion-icon>
        {{ f.name }}
      </ion-item>

      <!-- The start/end option buttons for all operations -->
      <ion-item-options side="start">
        <ion-item-option (click)="delete(f)" color="danger">
          <ion-icon name="trash" slot="icon-only"></ion-icon>
        </ion-item-option>
      </ion-item-options>

      <ion-item-options side="end">
        <ion-item-option (click)="startCopy(f)" color="success">
          Copy
        </ion-item-option>
      </ion-item-options>

    </ion-item-sliding>
    
  </ion-list>
 
  <!-- Fab to add files & folders -->
  <ion-fab vertical="bottom" horizontal="end" slot="fixed">
    <ion-fab-button>
      <ion-icon name="add"></ion-icon>
    </ion-fab-button>
    <ion-fab-list side="top">
      <ion-fab-button (click)="createFolder()">
        <ion-icon name="folder"></ion-icon>
      </ion-fab-button>
      <ion-fab-button (click)="addFile()">
        <ion-icon name="document"></ion-icon>
      </ion-fab-button>
    </ion-fab-list>
  </ion-fab>
 
</ion-content>

At this point you should be able to create folders at the top level, and we will add the navigation later.

Adding files with a standard input

To also add files to our explorer, we can use our hidden file input and simply trigger it – a cool trick that can be helpful in a lot of places.

This will open a file selection inside your browser or native app, so we don’t need to implement the camera capturing today.

Once we select a file, this file is a blob and since we can only write strings to the FS using the Capacitor plugin, we instead use the blob writer plugin that will work more efficiently!

We can use the current path and our overall file directory for this plugin as well, so change the according function to this now:

addFile() {
    this.uploader.nativeElement.click();
  }

  async fileSelected($event) {
    const selected = $event.target.files[0];

    await write_blob({
      directory: APP_DIRECTORY,
      path: `${this.currentFolder}/${selected.name}`,
      blob: selected,
      on_fallback(error) {
        console.error('error: ', error);
      }
    });

    this.loadDocuments();
  }

After selecting a file, we reload the list and it should appear in your folder!

If you want to debug this, simply check out the IndexedDB inside the Chrome dev tools:

capacitor-file-system

From there, you can remove all the generated files and folders from now on!

Navigating through folders

Next step is making sure you can navigate through all your folders, so we update our click function with a few checks. Only if the selected entry is a directory we gonna append that name to our current path and then navigate to it – it’s really that easy, the same page will init again and load the files for that new level:

async itemClicked(entry) {
    if (this.copyFile) {
      // TODO
    } else {
      // Open the file or folder
      if (entry.isFile) {
        this.openFile(entry);
      } else {
        let pathToOpen =
          this.currentFolder != '' ? this.currentFolder + '/' + entry.name : entry.name;
        let folder = encodeURIComponent(pathToOpen);
        this.router.navigateByUrl(`/home/${folder}`);
      }
    }
  }

We will take care of the other functions soon, but now we are able to navigate through the app with just one actual page!

Open a file preview

Opening a preview of files is a bit tricky since this won’t really work inside a browser. As a fallback I added a function to download the file instead, but most likely you will anyway need a different behaviour in your web app.

For native platforms we can now use the Cordova plugin we initially installed. To make it find the right item, we need to retrieve the full URI for a file upfront by calling the Filesystem.getUri() function and the resulting URI can be passed to the plugin like this:

async openFile(entry) {
    if (isPlatform('hybrid')) {
      // Get the URI and use our Cordova plugin for preview
      const file_uri = await Filesystem.getUri({
        directory: APP_DIRECTORY,
        path: this.currentFolder + '/' + entry.name
      });

      this.previewAnyFile.preview(file_uri.uri)
        .then((res: any) => console.log(res))
        .catch((error: any) => console.error(error));
    } else {
      // Browser fallback to download the file
      const file = await Filesystem.readFile({
        directory: APP_DIRECTORY,
        path: this.currentFolder + '/' + entry.name
      });

      const blob = this.b64toBlob(file.data, '');
      const blobUrl = URL.createObjectURL(blob);

      let a = document.createElement('a');
      document.body.appendChild(a);
      a.setAttribute('style', 'display: none');
      a.href = blobUrl;
      a.download = entry.name;
      a.click();
      window.URL.revokeObjectURL(blobUrl);
      a.remove();
    }
  }

Feel free to use the following implementation which I also found only to convert a base64 string back to a blob:

// Helper for browser download fallback
  // https://betterprogramming.pub/convert-a-base64-url-to-image-file-in-angular-4-5796a19fdc21
  b64toBlob = (b64Data, contentType = '', sliceSize = 512) => {
    const byteCharacters = atob(b64Data);
    const byteArrays = [];

    for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
      const slice = byteCharacters.slice(offset, offset + sliceSize);

      const byteNumbers = new Array(slice.length);
      for (let i = 0; i < slice.length; i++) {
        byteNumbers[i] = slice.charCodeAt(i);
      }

      const byteArray = new Uint8Array(byteNumbers);
      byteArrays.push(byteArray);
    }

    const blob = new Blob(byteArrays, { type: contentType });
    return blob;
  }

Again, this is just a browser fallback – for native devices we can directly show a preview with this cool plugin!

Copy and delete files

The last missing action is the delete and copy functionality. For the first part, this is pretty easy.

To delete a file or a folder, just call the according function on the filesystem with the path to either the file or folder and it will be removed. BY passing the recursive flag to the deletion of a folder we can also directly delete all the content of that folder:

async delete(entry) {
    if (entry.isFile) {
      await Filesystem.deleteFile({
        directory: APP_DIRECTORY,
        path: this.currentFolder + '/' + entry.name
      });
    } else {
      await Filesystem.rmdir({
        directory: APP_DIRECTORY,
        path: this.currentFolder + '/' + entry.name,
        recursive: true // Removes all files as well!
      });
    }
    this.loadDocuments();
  }

To copy a file we need two steps. First, we trigger the copy action on a file/directory and set our copyFile to that item, which basically enables the copy action.

With the second click, we can now select a folder and then our finishCopyFile() will be called.

We will also filter out files on the second click as this won’t make any sense, so go ahead and change the click function to:

async itemClicked(entry) {
    if (this.copyFile) {
      // We can only copy to a folder
      if (entry.isFile) {
        let toast = await this.toastCtrl.create({
          message: 'Please select a folder for your operation'
        });
        await toast.present();
        return;
      }
      // Finish the ongoing operation
      this.finishCopyFile(entry);

    } else {
      // Open the file or folder
      if (entry.isFile) {
        this.openFile(entry);
      } else {
        let pathToOpen =
          this.currentFolder != '' ? this.currentFolder + '/' + entry.name : entry.name;
        let folder = encodeURIComponent(pathToOpen);
        this.router.navigateByUrl(`/home/${folder}`);
      }
    }
  }

When we want to finish the copy operation, we could normally just call the copy function the filesystem but with normal strings this didn’t work on Android.

So instead of using that relative path that we used all the time, we get the absolute URI to the file and the destination first by using the getUri() function and then pass those values to the copy function:

startCopy(file) {
    this.copyFile = file;
  }

  async finishCopyFile(entry) {
    // Make sure we don't have any additional slash in our path
    const current = this.currentFolder != '' ? `/${this.currentFolder}` : ''

    const from_uri = await Filesystem.getUri({
      directory: APP_DIRECTORY,
      path: `${current}/${this.copyFile.name}`
    });

    const dest_uri = await Filesystem.getUri({
      directory: APP_DIRECTORY,
      path: `${current}/${entry.name}/${this.copyFile.name}`
    });

    await Filesystem.copy({
      from: from_uri.uri,
      to: dest_uri.uri
    });
    this.copyFile = null;
    this.loadDocuments();
  }

Note that there is no move function in the filesystem plugin right now. But move is basically just a copy operation with a delete of the original file afterwards, so you could easily replicate that behaviour yourself.

And with that last missing piece our Capacitor file explorer is done!

Conclusion

Working with the filesystem used to be challenging, but with the Capacitor plugin (and some additional plugins to make life easier) we can build a full blown file explorer and hopefully get a better understanding about the usage of the filesystem.

The best part is that this really works across all platforms, and you can test all functionality even on your browser and inspect how the IndexedDB is changed!

You can also find a video version of this tutorial below.

The post How to Build a Capacitor File Explorer with Ionic Angular appeared first on Devdactic - Ionic Tutorials.

How to use Native Google Maps with Capacitor and Ionic

$
0
0

If you want to use Google maps with Ionic, you can either go the easy route with the Web SDK or use the Capacitor wrapper to include native Google Maps right in your Ionic app.

In this tutorial we will use the Capacitor Google Maps plugin to include the native SDKs for Google Maps, which allows a more performant map display.

capacitor-google-maps-ionic

However, the plugin currently doesn’t support web so only use this option if you plan to build a pure native app for iOS and Android!

Google Maps project setup

Before we dive into the app, we need to configure a Project in the Google Cloud Platform console. Get started by clicking on your projects at the top and then selecting New Project.

new-google-project
Pick whatever name you like and once the project is created, make sure you select it at the top so you can modify its settings.

Now we need to enable the native SDKs, and we can do this by first selecting APIs & Services from the side and within go to Library.

You can filter for the native iOS and Android SDK, then select each of them and within the details page hit enable for both of them.
maps-enable-native-sdk

If you now navigate to the Google MapsAPI list of your project you should see that both are enabled.

google-enabled-maps-api

Finally we need to create an API key, and for now we can use the same key for iOS and Android.

To create one, simply navigate to Credentials within the APIs & Services menu and from there click Create Credentials. In the following menu select API key, and you are almost ready!

Note: The key is by default unrestricted, but you can restrict it to specific URLs or an app identifier. For production, I recommend to create an iOS and Android key and restrict them to your apps bundle identifier!

On top of that you can also restrict that key by only allowing access to certain Google APIs, like I did in the image below for our case.

google-credentials-key

If you see a warning about the OAuth consent screen you might have to configure it now (just go through the steps). But not 100% sure if it’s actually necessary for the API key!

Ionic Google Maps app

Now we can go into the Ionic app configuration for Google Maps, so create a blank new app and when the app is ready, install the plugin for our native Google Maps. I also added the Capacitor geolocation plugin so we directly work a bit more with our map:

ionic start devdacticMaps blank --type=angular --capacitor
cd ./devdacticMaps

npm i @capacitor-community/capacitor-googlemaps-native
npm install @capacitor/geolocation

ionic cap add ios
ionic cap add android

For now we will leave the app as it is because we need some platform specific changes first.

Configure Google Maps for Android

For Android we first need to supply our API key (the one we created above) inside the android/app/src/main/AndroidManifest.xml like this:

<application>

<!-- Other tags -->

<meta-data
        android:name="com.google.android.geo.API_KEY"
        android:value="MAPS_API_KEY"/>

<!-- Other tags -->

</application>

Because I also added the geolocation plugin to this tutorial, we need to add the required permissions at the bottom of that file where you can find the permissions comment already:

<!-- Permissions -->
    <!-- Geolocation API -->
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-feature android:name="android.hardware.location.gps" />

Although I thought the next step would work automatically with Capacitor 3 it was mentioned as still required, so like before go ahead and import and register the Capacitor plugin inside the android/app/src/main/java/io/ionic/starter/MainActivity.java:

package io.ionic.starter;
import com.hemangkumar.capacitorgooglemaps.CapacitorGoogleMaps;

import com.getcapacitor.BridgeActivity;

public class MainActivity extends BridgeActivity {
    @Override
    public void onCreate(Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);
      registerPlugin(CapacitorGoogleMaps.class);
    }
}

That’s all for Android, next step iOS.

Configure Google maps for iOS

Inside the native iOS project we don’t need to configure anything for native Google Maps! The only reason we apply a change here is the usage of the geolocation plugin.

In order to make the plugin work, open the ios/App/App/Info.plist and add two entries for the required permissions:

<key>NSLocationAlwaysUsageDescription</key>
  <string>We want to track you</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
  <string>We want your current position</string>

To use Google Maps we now add our API key first of all to the src/environments/environment.ts so we can easily access it later:

export const environment = {
  production: false,
  mapsKey: 'MAPS_API_KEY'
};

Although I said we don’t need to configure Google Maps in the native project we still need to inject the API key, but directly from our Ionic app!

Therefore I recommend you either put the initialize() right into the startup of your app, or defer it to a page where you actually use the map.

In our case, let’s open the src/app/app.component.ts and call it right in the beginning so we inject the API key into the native Google Maps plugin:

import { Component } from '@angular/core';
import { CapacitorGoogleMaps } from '@capacitor-community/capacitor-googlemaps-native';
import { environment } from '../environments/environment';

@Component({
  selector: 'app-root',
  templateUrl: 'app.component.html',
  styleUrls: ['app.component.scss'],
})
export class AppComponent {

  constructor() {
    CapacitorGoogleMaps.initialize({
      key: environment.mapsKey
    });
  }
}

Now we can finally work with our map.

Adding a simple native Google map view

To integrate a map we need to add one element to our markup, style it a bit with CSS and create all the logic and setup from code. Let’s start with the easiest part, which is including a map element in our src/app/home/home.page.html:

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>
      Devdactic Maps
    </ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <div id="map" #map></div>
</ion-content>

To give that element a specific size, let’s add the following to the src/app/home/home.page.scss:

#map {
  height: 50vh;
}

Now we can access the map as a ViewChild and use the CapacitorGoogleMaps plugin to define all the specific settings.

Be aware that the DOM element should be already available, so in our case we use the ionViewDidEnter lifecycle event for that.

After defining the map we can directly add an event listener to wait until the map is fully ready. Once the event happens, we will also set the map type and start a function to display our current position (coming in the next step).

For now go ahead and change the src/app/home/home.page.ts to:

import { Component, ElementRef, ViewChild } from '@angular/core';
import { CapacitorGoogleMaps } from '@capacitor-community/capacitor-googlemaps-native';
import { Geolocation } from '@capacitor/geolocation';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})
export class HomePage {

  constructor() { }

  @ViewChild('map') mapView: ElementRef;

  ionViewDidEnter() {
    this.createMap();
  }

  createMap() {
    const boundingRect = this.mapView.nativeElement.getBoundingClientRect() as DOMRect;

    CapacitorGoogleMaps.create({
      width: Math.round(boundingRect.width),
      height: Math.round(boundingRect.height),
      x: Math.round(boundingRect.x),
      y: Math.round(boundingRect.y),
      zoom: 5
    });

    CapacitorGoogleMaps.addListener('onMapReady', async () => {
      CapacitorGoogleMaps.setMapType({
        type: "normal" // hybrid, satellite, terrain
      });
      
      this.showCurrentPosition();
    });
  }

  async showCurrentPosition() {
    // todo
  }

  ionViewDidLeave() {
    CapacitorGoogleMaps.close();
  }

}

At this point you can already run your app, but make sure you deploy it to a device!

To make testing a bit easier, I recommend to run it with livereload like this:

ionic cap run ios --livereload --external --source-map=false

Once you can see the map on your device, you can continue with some more functionality.

Google map marker and user geolocation

We can now combine the map with the additionally installed geolocation plugin. For that, we should first request the necessary permissions (in case you haven’t done this at an earlier time) and then call getCurrentPosition() to get the exact user coordinates.

With that result we can add a marker for the user with some basic information (more to come afaik) and also call setCamera() on our map to focus the camera on the user location.

Go ahead and edit the according function inside the src/app/home/home.page.ts:

async showCurrentPosition() {
    Geolocation.requestPermissions().then(async premission => {
      const coordinates = await Geolocation.getCurrentPosition();
    
      // Create our current location marker
      CapacitorGoogleMaps.addMarker({
        latitude: coordinates.coords.latitude,
        longitude: coordinates.coords.longitude,
        title: 'My castle of loneliness',
        snippet: 'Come and find me!'
      });
    
      // Focus the camera
      CapacitorGoogleMaps.setCamera({
        latitude: coordinates.coords.latitude,
        longitude: coordinates.coords.longitude,
        zoom: 12,
        bearing: 0
      });
    });
  }

If you run into trouble, double check that you are actually getting the coordinates of the user. I’ve used wrong permissions on iOS and you won’t really get a helpful error in that case. Only if you see the permissions dialog pop up you know that you should be fine to get the current user location.

Drawing on the map

On top of those basic things the map plugin allows many more functionalities like drawing lines or objects on the map, just like we did in the geolocation tracking tutorial with Firebase.

A simple example that would draw a straight line somewhere over Germany could look like this:

draw() {
    const points: LatLng[] = [
      {
        latitude: 51.88,
        longitude: 7.60,
      },
      {
        latitude: 55,
        longitude: 10,
      }
    ];

    CapacitorGoogleMaps.addPolyline({
      points,
      color: '#ff00ff',
      width: 2
    });
  }

The only thing you need is an array of coordinates and then feed that information to the addPolyline() function of the plugin.

Listening to map events

In the beginning we already added one event listener to the ready event, but inside the interface definition you will find this block:

addListener(eventName: 'didTap', listenerFunc: (results: any) => void): PluginListenerHandle;
    addListener(eventName: 'dragEnded', listenerFunc: (results: any) => void): PluginListenerHandle;
    addListener(eventName: 'didTapAt', listenerFunc: (results: any) => void): PluginListenerHandle;
    addListener(eventName: 'didTapPOIWithPlaceID', listenerFunc: (results: any) => void): PluginListenerHandle;
    addListener(eventName: 'didChange', listenerFunc: (results: any) => void): PluginListenerHandle;
    addListener(eventName: 'onMapReady', listenerFunc: (results: any) => void): PluginListenerHandle;

That means you can add your own event handlers to certain events when the user clicked on the map. An example for places of interest could look like this:

CapacitorGoogleMaps.addListener('didTapPOIWithPlaceID', async (ev) => {
      const result = ev.results;

      const alert = await this.alertCtrl.create({
        header: result.name,
        message: `Place ID:  ${result.placeID}`,
        buttons: ['OK']
      });

      await alert.present();
    });

You could now use other Google APIs with the place ID to show more information or query the direction service for a route.

Conclusion

Including native Google Maps in your Ionic app can boost the performance as you are using the intended SDKs from Google wrapped inside the Capacitor plugin.

Some parts of the plugin are still WIP but the basic usage is straight forward and works flawless already.

You can also find a video version of this tutorial below.

The post How to use Native Google Maps with Capacitor and Ionic appeared first on Devdactic - Ionic Tutorials.

The Ionic Image Guide with Capacitor (Capture, Store & Upload)

$
0
0

Capturing, storing and uploading image files with Ionic is a crucial task inside many applications, even if it’s just a small avatar of a user. At the same time, the process to handle images and the filesystem can be challenging sometimes.

In this tutorial we will create a simple Ionic image capturing app using Capacitor to first take an image and store it locally, then display all local files and finally offer the ability to upload or delete them.

ionic-image-upload-capacitor

This app will work both inside the browser and as a native iOS and Android app because the Capacitor APIs work mostly the same across the different platforms!

On top of that I’ve added a simple PHP script at the end that will accept images and we can see a list of all the uploaded files when we run that PHP file within a local server!

Let’s have some fun with images today.

Starting our Ionic Image Upload App

We start as always with a blank new Ionic app and install both the Camera and Filesystem plugin from Capacitor in our new app:

ionic start devdacticImages blank --type=angular --capacitor
cd ./devdacticImages

npm i @capacitor/camera @capacitor/filesystem

# For desktop support
npm i @ionic/pwa-elements

# Add native platforms
ionic build
ionic cap add ios
ionic cap add android

After running the first build you can also already add the native platforms that you want to use.

To test the camera directly in our browser we can also install the pwa-elements which makes our life a lot easier.

In order to use that package, we have to add two lines in our src/main.ts right now:

import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
import { defineCustomElements } from '@ionic/pwa-elements/loader';

if (environment.production) {
  enableProdMode();
}

platformBrowserDynamic().bootstrapModule(AppModule)
  .catch(err => console.log(err));

defineCustomElements(window);

Because we are accessing the camera we also need to define the permissions for the native platforms, so let’s start with iOS and add the following permissions (with a good reason in a real app!) to your ios/App/App/Info.plist:

<key>NSCameraUsageDescription</key>
	<string>To capture images</string>
	<key>NSPhotoLibraryAddUsageDescription</key>
	<string>To add images</string>
	<key>NSPhotoLibraryUsageDescription</key>
	<string>To store images</string>

For Android we need to do the same, also because we are using the Filesystem. Therefore, bring up the android/app/src/main/AndroidManifest.xml and after the line that already sets the internet permission add two more lines:

<uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

The last preparation we need is to include the HttpClientModule because our app should make a little request and upload the images in the end, so bring up the src/app/app.module.ts and import the module like this:

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 { HttpClientModule } from '@angular/common/http';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [
    BrowserModule,
    IonicModule.forRoot(),
    AppRoutingModule,
    HttpClientModule,
  ],
  providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
  bootstrap: [AppComponent],
})
export class AppModule {}

Now the app is prepared for capturing images and working with the FS and we can dive into the core functionality.

The View for our Image Upload

Let’s start with the view, because that one is gonna be pretty boring.

We want to display a list of images, an each image element will have the image data (as base64 string), a name and a path to the file. For each of the elements we can trigger the upload or deletion.

At the bottom we will add a button to capture a new image that will then be added to our list.

Therefore, open the src/app/home/home.page.html and change it to:

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>
      Ionic Image Upload
    </ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <h3 *ngIf="images.length == 0" class="ion-padding ion-text-center">Please Select Image!</h3>

  <ion-list>
    <ion-item *ngFor="let file of images; let i = index">
      <ion-thumbnail slot="start">
        <ion-img [src]="file.data"></ion-img>
      </ion-thumbnail>
      <ion-label class="ion-text-wrap">
        {{ file.name }}
      </ion-label>
      <ion-button slot="end" fill="clear" (click)="startUpload(file)">
        <ion-icon slot="icon-only" name="cloud-upload"></ion-icon>
      </ion-button>
      <ion-button slot="end" fill="clear" (click)="deleteImage(file)">
        <ion-icon slot="icon-only" name="trash"></ion-icon>
      </ion-button>
    </ion-item>
  </ion-list>
</ion-content>

<ion-footer>
  <ion-toolbar color="primary">
    <ion-button fill="clear" expand="full" color="light" (click)="selectImage()">
      <ion-icon slot="start" name="camera"></ion-icon>
      Select Image
    </ion-button>
  </ion-toolbar>
</ion-footer>

It will underline all the missing functionality but we’ll now develop those important bits one by one.

Loading stored image files

To begin, we want to load all stored images from the filesystem. In the previous article about Ionic image handling with Cordova we also used Ionic Storage to store the file references and additional information, however we don’t really need that addition if we simply want to load all files!

The reason is simple, we can call readdir() to get the content of a folder and because we are storing all captured images in one folder, that’s enough to later resolve the image names to their data!

Note: If you want to store additional information with the image like a description, text, anything you want, then adding Ionic Storage to keep track of that information might make sense.

After reading the files from a directory we either get a list of file names or an error – because when you first run the app the folder (with the name stored in IMAGE_DIR) doesn’t even exist.

So if there’s an error, we simply create that folder with mkdir().

If we get a list of file names, we can continue by resolving the plain name to the actual content of each image inside the loadFileData() function.

This function will now iterate all the names, construct the real path by putting our folder name in front of it and then calling the readFile() function to get the content of that file.

The result will be pushed to our local array as a base64 string plus the name and the path to that image which will come in handy at a later point.

Now get started with the first changes in our src/app/home/home.page.ts:

import { Component, OnInit } from '@angular/core';
import { Filesystem, Directory } from '@capacitor/filesystem';
import { HttpClient } from '@angular/common/http';
import { LoadingController, Platform, ToastController } from '@ionic/angular';

const IMAGE_DIR = 'stored-images';

interface LocalFile {
  name: string;
  path: string;
  data: string;
}

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})
export class HomePage implements OnInit {
  images: LocalFile[] = [];

  constructor(
    private plt: Platform,
    private http: HttpClient,
    private loadingCtrl: LoadingController,
    private toastCtrl: ToastController
  ) { }

  async ngOnInit() {
    this.loadFiles();
  }

  async loadFiles() {
    this.images = [];

    const loading = await this.loadingCtrl.create({
      message: 'Loading data...',
    });
    await loading.present();

    Filesystem.readdir({
      path: IMAGE_DIR,
      directory: Directory.Data,
    }).then(result => {
      this.loadFileData(result.files);
    },
      async (err) => {
        // Folder does not yet exists!
        await Filesystem.mkdir({
          path: IMAGE_DIR,
          directory: Directory.Data,
        });
      }
    ).then(_ => {
      loading.dismiss();
    });
  }

  // Get the actual base64 data of an image
  // base on the name of the file
  async loadFileData(fileNames: string[]) {
    for (let f of fileNames) {
      const filePath = `${IMAGE_DIR}/${f}`;

      const readFile = await Filesystem.readFile({
        path: filePath,
        directory: Directory.Data,
      });

      this.images.push({
        name: f,
        path: filePath,
        data: `data:image/jpeg;base64,${readFile.data}`,
      });
    }
  }

  // Little helper
  async presentToast(text) {
    const toast = await this.toastCtrl.create({
      message: text,
      duration: 3000,
    });
    toast.present();
  }

  async selectImage() {
    // TODO
  }

  async startUpload(file: LocalFile) {
    // TODO
  }

  async deleteImage(file: LocalFile) {
    // TODO
  }
}

This will make your app work for now, but since we haven’t captured any image the view will still be blank.

Capture and Store images with Capacitor

Now it’s time to get some images into our filesystem, and we can use the Capacitor camera plugin for this.

We are using the CameraResultType.Uri because I usually had better performance with that, but you can also give it a try and directly use base64 as a result type for getPhoto() which might make your life easier in some places.

Once we’ve captured an image that way it’s time to store it using the Capacitor Filesystem plugin.

Because we can only write strings to a new file with this plugin, we need to convert our image URI to a base64 string (you see why it might make sense to use base64 right away).

The process to read that file as a base64 string is a bit different for the web and native platforms, so we add a switch inside the readAsBase64() and either read that URI ior on the web, simply use the fetch API! The second requires an additional helper function for the blob conversion.

After getting all that data we can finally call writeFile() to create a new file inside our specific folder and then update our local list by calling the initial load again.

Note: If you simply retrieve the information for that new file you can directly add it to the local array, which would prevent the app from flicker during that reload. I just wanted to keep it simple for now, but that’s definitely a recommendation!

Now we can go ahead and replace the empty functions in our src/app/home/home.page.ts with:

// Add the new import
import { Camera, CameraResultType, CameraSource, Photo } from '@capacitor/camera';

async selectImage() {
    const image = await Camera.getPhoto({
        quality: 90,
        allowEditing: false,
        resultType: CameraResultType.Uri,
        source: CameraSource.Photos // Camera, Photos or Prompt!
    });

    if (image) {
        this.saveImage(image)
    }
}

// Create a new file from a capture image
async saveImage(photo: Photo) {
    const base64Data = await this.readAsBase64(photo);

    const fileName = new Date().getTime() + '.jpeg';
    const savedFile = await Filesystem.writeFile({
        path: `${IMAGE_DIR}/${fileName}`,
        data: base64Data,
        directory: Directory.Data
    });

    // Reload the file list
    // Improve by only loading for the new image and unshifting array!
    this.loadFiles();
}

  // https://ionicframework.com/docs/angular/your-first-app/3-saving-photos
  private async readAsBase64(photo: Photo) {
    if (this.plt.is('hybrid')) {
        const file = await Filesystem.readFile({
            path: photo.path
        });

        return file.data;
    }
    else {
        // Fetch the photo, read as a blob, then convert to base64 format
        const response = await fetch(photo.webPath);
        const blob = await response.blob();

        return await this.convertBlobToBase64(blob) as string;
    }
}

// Helper function
convertBlobToBase64 = (blob: Blob) => new Promise((resolve, reject) => {
    const reader = new FileReader;
    reader.onerror = reject;
    reader.onload = () => {
        resolve(reader.result);
    };
    reader.readAsDataURL(blob);
});

You can now already run the app, capture images both on the web and inside a native app and see the result in your list – even after reloading the app!

Upload Images and Delete Files

The last step is to perform some actions on our stored files, and the easy part of this is deleting a file.

For the deletion, all we have to do is call deleteFile() with the right path to our file (which is present in our LocalFile interface after loading the data) and the file is gone.

Uploading the file (in our case) requires the conversion to a blob: This might be different for you, but for the simple PHP script that I will show you in the end a blob is expected. Also, this is the usual way of handling a file upload in any backend, so it should fit most cases.

Once we got the blob from again using the fetch API on our base64 string of the image, we can create a FormData element and append our blob.

This data can be added to a simple POST request to your API endpoint, and that’s already the whole magic of uploading a locally stored file with Ionic!

Therefore bring up the src/app/home/home.page.ts one last time and change the last functions to:

// Add one more import
import { finalize } from 'rxjs/operators';

// Convert the base64 to blob data
// and create  formData with it
async startUpload(file: LocalFile) {
    const response = await fetch(file.data);
    const blob = await response.blob();
    const formData = new FormData();
    formData.append('file', blob, file.name);
    this.uploadData(formData);
}

// Upload the formData to our API
async uploadData(formData: FormData) {
    const loading = await this.loadingCtrl.create({
        message: 'Uploading image...',
    });
    await loading.present();

    // Use your own API!
    const url = 'http://localhost:8888/images/upload.php';

    this.http.post(url, formData)
        .pipe(
            finalize(() => {
                loading.dismiss();
            })
        )
        .subscribe(res => {
            if (res['success']) {
                this.presentToast('File upload complete.')
            } else {
                this.presentToast('File upload failed.')
            }
        });
}

async deleteImage(file: LocalFile) {
    await Filesystem.deleteFile({
        directory: Directory.Data,
        path: file.path
    });
    this.loadFiles();
    this.presentToast('File removed.');
}

If you now serve your application you can trigger the deletion and upload of files (if your API is running) but be aware that once you deploy your Ionic app to a real device, using localhost as the API URL won’t work anymore and you need to use the local IP of your computer instead.

The PHP Upload Logic

Now I’m not a PHP expert so I’ll make this as quick as possible.

If you have a server you can use that one, otherwise I simply recommend to download XAMPP and install it local.

I’m not going to cover that process since this is about Ionic image upload and not how to configure PHP. If you have set it up, you can first of all create a upload.php to accept uploads:

<?php
header('Access-Control-Allow-Origin: *');
$target_path = "uploads/";
 
$target_path = $target_path . basename( $_FILES['file']['name']);
 
if(move_uploaded_file($_FILES['file']['tmp_name'], $target_path)) {
    header('Content-type: application/json');
    $data = ['success' => true, 'message' => 'Upload and move success'];
    echo json_encode( $data );
} else{
    header('Content-type: application/json');
    $data = ['success' => false, 'message' => 'There was an error uploading the file, please try again!'];
    echo json_encode( $data );
}

?>

Also, make sure to create a uploads folder next to this file, as it will copy the images into that folder and fail if it doesn’t exist.

Additionally, to see the results of our hard work, I created a little HTML file that will scan the uploads folder and show them so we can directly see if our upload worked, create this as index.php next to the previous file and insert:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width">
  <title>Devdactic Image Upload</title>
</head>
<body>
<h1>Ionic Image Upload</h1>
  <?php
  $scan = scandir('uploads');
  foreach($scan as $file)
  {
    if (!is_dir($file))
    {
        echo '<h3>'.$file.'</h3>';
      echo '<img src="uploads/'.$file.'" style="width: 400px;"/><br />';
    }
  }
  ?>
</body>
</html>

You can now start your local MAMP server and navigate to http://localhost:8888 which will display your Ionic Images overview.

Again, in our example we used this URL for the upload, but this will only work if you run the app on the simulator. If you deploy the app to your iOS or Android device you need to change the URL to the IP of your computer!

Conclusion

Handling files and especially images with Ionic is crucial to most apps, and while we haven’t created an Ionic file explorer with Capacitor the functionality we added should be sufficient for most apps.

If you want to store more information with the image, you should also add Ionic Storage to keep track of that information and probably the path to each image instead of reading the filesystem.

But otherwise, I don’t recommend to store the image data within Ionic Storage and instead write that information to files (like we did) to keep your app as performant as possible!

You can also find a video version of this tutorial below.

The post The Ionic Image Guide with Capacitor (Capture, Store & Upload) appeared first on Devdactic - Ionic Tutorials.

Build Your First Ionic App with Firebase using AngularFire 7

$
0
0

Using Firebase as the backend for your Ionic apps is a great choice if you want to build a robust system with live data fast, and by using AngularFire you can use a simple wrapper around the official Firebase JS SDK.

In this tutorial we will use the currently latest version 7 of AngularFire which uses the Firebase JS SDK version 9.

We will build a simple note taking app and include all essential Firebase CRUD functions!

ionic-firebase-angularfire

User authentication, file upload, security rules and more topics are covered in the courses of the Ionic Academy!

Creating the Firebase Project

Before we dive into the Ionic app, we need to make sure we actually have a Firebase app configured. If you already got something in place you can of course skip this step.

Otherwise, make sure you are signed up (it’s free) and then hit Add project inside the Firebase console. Give your new app a name, select a region and then create your project!

Once you have created the project you need to find the web configuration which looks like this:

ionic-4-firebase-add-to-app

If it’s a new project, click on the web icon below “Get started by adding Firebase to your app” to start a new web app and give it a name, you will see the configuration in the next step now.

Leave this config block open (or copy it already) until our app is ready so we can insert it in our environment!

Additionally we have to enable the database, so select Firestore Database from the menu and click Create database.

ionic-4-firestore

Here we can set the default security rules for our database and because this is a simple tutorial we’ll roll with the test mode which allows everyone access.

Starting our Ionic App & Firebase Integration

Now we are ready to setup the Ionic app, so generate a new app with an additional page and service for our logic and then use the AngularFire schematic to add all required packages and changes to the project:

ionic start devdacticFire blank --type=angular --capacitor
cd ./devdacticFire

# Generate a page and service
ionic g page modal
ionic g service services/data

# Install Firebase and AngularFire
ng add @angular/fire

Now we need the configuration from Firebase that you hopefully kept open in your browser, and we can add it right inside our environments/environment.ts like this:

export const environment = {
  production: false,
  firebase: {
    apiKey: "",
    authDomain: "",
    projectId: "",
    storageBucket: "",
    messagingSenderId: "",
    appId: ""
  }
};

Finally we set up the connection to Firebase by passing in our configuration. This looks different from previous versions as we are now using factory functions to setup all services that we need, like in our example the getFirestore().

Go ahead and change the src/app/app.module.ts to:

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 { environment } from '../environments/environment';
import { provideFirebaseApp, initializeApp } from '@angular/fire/app';
import { getFirestore, provideFirestore } from '@angular/fire/firestore';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule,
    provideFirebaseApp(() => initializeApp(environment.firebase)),
    provideFirestore(() => getFirestore())
  ],
  providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
  bootstrap: [AppComponent],
})
export class AppModule { }

Injecting more services like authentication or file storage basically follow the same setup!

Working with Firestore Docs & Collections

As a first step we will create a service that interacts with Firebase and loads our data. It’s always a good idea to outsource your logic into a service!

To work with Firestore in the latest version we need to inject the Firestore instance into every call, so we import it within our constructor and later use it in our CRUD functions. On top of that we simply create a reference to the path in our Firestore database to either a collection or document.

Let’s go through each of them:

  • getNotes: Access the notes collection and query the data using collectionData()
  • getNoteById: Access one document in the notes collection and return the data with docData()
  • addNote: With a reference to the notes collection we use addDoc() to simply push a new document to a collection where a unique ID is generated for it
  • deleteNote: Delete a document at a specific path using deleteDoc()
  • updateNote: Create a reference to one document and update it through updateDoc()

For our first functions we also pass in an options object that contains idField, which helps to easily include the ID of a document in the response!

Now let’s go ahead and change the src/app/services/data.service.ts to:

import { Injectable } from '@angular/core';
import { Firestore, collection, collectionData, doc, docData, addDoc, deleteDoc, updateDoc } from '@angular/fire/firestore';
import { Observable } from 'rxjs';

export interface Note {
  id?: string;
  title: string;
  text: string;
}

@Injectable({
  providedIn: 'root'
})
export class DataService {

  constructor(private firestore: Firestore) { }

  getNotes(): Observable {
    const notesRef = collection(this.firestore, 'notes');
    return collectionData(notesRef, { idField: 'id'}) as Observable;
  }

  getNoteById(id): Observable {
    const noteDocRef = doc(this.firestore, `notes/${id}`);
    return docData(noteDocRef, { idField: 'id' }) as Observable;
  }

  addNote(note: Note) {
    const notesRef = collection(this.firestore, 'notes');
    return addDoc(notesRef, note);
  }

  deleteNote(note: Note) {
    const noteDocRef = doc(this.firestore, `notes/${note.id}`);
    return deleteDoc(noteDocRef);
  }

  updateNote(note: Note) {
    const noteDocRef = doc(this.firestore, `notes/${note.id}`);
    return updateDoc(noteDocRef, { title: note.title, text: note.text });
  }
}

With all of that in place we are ready to build some functionality on top of our service.

Load and add to Firestore Collections

First of all we want to display a list with the collection data, so let’s create a quick template first. We add a click event to every item, and additionally use a FAB button to create new notes for our collection.

Get started by changing the src/app/home/home.page.html to:

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>
      Devdactic Notes
    </ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>

  <ion-list>
    <ion-item *ngFor="let note of notes" (click)="openNote(note)">
      <ion-label>
        {{ note.title }}
      </ion-label>
    </ion-item>
  </ion-list>

  <ion-fab vertical="bottom" horizontal="end" slot="fixed">
    <ion-fab-button (click)="addNote()">
      <ion-icon name="add"></ion-icon>
    </ion-fab-button>
  </ion-fab>
  
</ion-content>

Since we have created the logic to load the data before, we now simply load the data from our service and assign it to our local notes array.

To add a new note we can use the Ionic alert controller and two simple inputs to capture a title and text for a new note.

With that information we can call addNote() from our service to create a new note in our Firestore collection.

We don’t need any additional logic to reload the collection data – since we are subscribed to an Observable that returns our collection data we will automatically receive the new data!

To show the details for a note (as a little exercise) we create a new modal using the Ionic 6 sheet version with breakpoints which looks pretty cool. We pass in the ID of the note we want to open, so we can later load its data through our service.

For now open the src/app/home/home.page.ts and change it to:

import { ChangeDetectorRef, Component } from '@angular/core';
import { AlertController, ModalController } from '@ionic/angular';
import { DataService, Note } from '../services/data.service';
import { ModalPage } from '../modal/modal.page';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss']
})
export class HomePage {
  notes: Note[] = [];

  constructor(private dataService: DataService,  private cd: ChangeDetectorRef, private alertCtrl: AlertController, private modalCtrl: ModalController) {
    this.dataService.getNotes().subscribe(res => {
      this.notes = res;
      this.cd.detectChanges();
    });
  }

  async addNote() {
    const alert = await this.alertCtrl.create({
      header: 'Add Note',
      inputs: [
        {
          name: 'title',
          placeholder: 'My cool note',
          type: 'text'
        },
        {
          name: 'text',
          placeholder: 'Learn Ionic',
          type: 'textarea'
        }
      ],
      buttons: [
        {
          text: 'Cancel',
          role: 'cancel'
        }, {
          text: 'Add',
          handler: res => {
            this.dataService.addNote({ text: res.text, title: res.title });
          }
        }
      ]
    });

    await alert.present();
  }

  async openNote(note: Note) {
    const modal = await this.modalCtrl.create({
      component: ModalPage,
      componentProps: { id: note.id },
      breakpoints: [0, 0.5, 0.8],
      initialBreakpoint: 0.8
    });

    await modal.present();
  }
}

Note: Initially I had to use the Angular ChangeDetectorRef and manually trigger a change detection to update the view, in later tests it worked without. See what works for you, most likely you don’t need that part.

Now we just need to implement the modal with some additional functionality.

Update and Delete Firestore Documents

The last step is loading the detail data of a document, which you can do by using the ID that we define as @Input() and getting the document data from our service.

The other functions to delete and update a document work the same, simply by calling our service functionalities.

Therefore quickly open the src/app/modal/modal.page.ts and change it to:

import { Component, Input, OnInit } from '@angular/core';
import { Note, DataService } from '../services/data.service';
import { ModalController, ToastController } from '@ionic/angular';

@Component({
  selector: 'app-modal',
  templateUrl: './modal.page.html',
  styleUrls: ['./modal.page.scss'],
})
export class ModalPage implements OnInit {
  @Input() id: string;
  note: Note = null;

  constructor(private dataService: DataService, private modalCtrl: ModalController, private toastCtrl: ToastController) { }

  ngOnInit() {
    this.dataService.getNoteById(this.id).subscribe(res => {
      this.note = res;
    });
  }

  async deleteNote() {
    await this.dataService.deleteNote(this.note)
    this.modalCtrl.dismiss();
  }

  async updateNote() {
    await this.dataService.updateNote(this.note);
    const toast = await this.toastCtrl.create({
      message: 'Note updated!.',
      duration: 2000
    });
    toast.present();

  }
}

The cool thing is that our document is also updated in realtime, just like the list based on the collection on our previous page.

So since we can now connect our ngModel input fields with our note, you could directly update the data inside Firestore and see the change in your Ionic app.

For the other direction, we still need to press the update button first so let’s wrap up the tutorial by adding the last items to show the input fields and two buttons to trigger all actions inside the src/app/modal/modal.page.html:

<ion-header>
  <ion-toolbar color="secondary">
    <ion-title>Details</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <div *ngIf="note">
    <ion-item>
      <ion-label position="stacked">Title</ion-label>
      <ion-input [(ngModel)]="note.title"></ion-input>
    </ion-item>

    <ion-item>
      <ion-label position="stacked">Note</ion-label>
      <ion-textarea [(ngModel)]="note.text" rows="8"></ion-textarea>
    </ion-item>
  </div>

  <ion-button expand="block" color="success" (click)="updateNote()">
    <ion-icon name="save" slot="start"></ion-icon>
    Update
  </ion-button>
  <ion-button expand="block" color="danger" (click)="deleteNote()">
    <ion-icon name="trash" slot="start"></ion-icon>
    Delete
  </ion-button>

</ion-content>

And with that you have successfully finished the basic Firebase integration on which you could now add all further functionalities like user authentication or file upload.

Conclusion

Firebase remains one of the most popular choices for a cloud backend besides the upcoming star Supabase, which is an open source Firebase alternative.

I’ve personally used both and enjoy the different philosophies of SQL and NoSQL, but you can certainly build powerful apps with both cloud backend solutions!

You can also find a video version of this tutorial below.

The post Build Your First Ionic App with Firebase using AngularFire 7 appeared first on Devdactic - Ionic Tutorials.

How to Build a Native App from Angular Projects with Capacitor

$
0
0

The popularity of Capacitor is constantly rising, and given the fact that you can turn basically any web project into a native mobile app with Capacitor is the simple reason for Capacitors fame.

This blog is usually about Ionic, but I’ve used plain Angular web projects for many of my own projects in the past.

So how can we transform a web project that’s using Angular into a native mobile app?

angular-capacitor-app

We’ll go through all necessary steps in this tutorial and finally even add on device live reload to our Angular Capacitor app on both iOS and Android!

Setting up the Angular App

If you already got your Angular app you don’t need to start a new one, otherwise simply follow along for testing and generate a new one using the Angular CLI:

ng new angularCapacitor --routing --style=scss
cd ./angularCapacitor

# Add Capacitor
npm install @capacitor/core
npm install @capacitor/cli --save-dev

# Setup the Capacitor config
npx cap init

After creating the Angular app we have now installed two packages for Capacitor:

  • @capacitor/core: The core package necessary for adding any other plugin
  • @capacitor/cli: The CLI as a dev dependency to run Capacitor commands in our project

After the installation I’ve triggered the init command using npx to run a local script, which will ask you some general questions about the app:

angular-capacitor-init

Don’t worry too much about this, you could easily see and change this after this afterwwards inside your capacitor.config.ts, the file that was created within this step:

import { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  appId: 'com.devdactic.angular',
  appName: 'Angular App',
  webDir: 'dist/angularCapacitor',
  bundledWebRuntime: false
};

export default config;

This file contains information about the native iOS and Android project like the appId, which is used as the bundle identifier for your app.

Note: I have changed the webDir because in plain Angular projects the output of your project is in a subfolder of dist, so make sure you use the right path to the output of your usual build command in here!

Otherwise Capacitor won’t be able to find your web assets and your native app will be blank.

Adding Native iOS & Android

So far we’ve only installed the core packages for Capacitor, but how do we turn our app into a native app?

This can be done by first of all installing the according packages for iOS and Android (if you want both!) and then running the Capacitor CLI commands to add the native projects to your app:

npm install @capacitor/ios @capacitor/android
npx cap add ios
npx cap add android

As a result, this will add two new folders in your project. These folders contain native projects, just like you would have when developing a native iOS or Android app!

angular-capacitor-structure

Note: While you can change some of the settings through the previously generated Capacitor config, usually Capacitor won’t overwrite your native project settings as that’s one of the core Capacitor philosophies!

Now that we got those folders, it’s time to build our Angular app the usual way and then sync our changes to the native projects by running:

# Build the Angular app
ng build --prod

# Sync the build folder to native projects
npx cap sync

This will copy over the build folder in the right place of the iOS/Android project, and you can finally see the result of your work on a device.

How?

The Capacitor CLI got you again. There’s a command to directly open Android Studio or Xcode (install them if you haven’t used them before!) with your project:

npx cap open ios
npx cap open android

If you never touched mobile development, make sure you set up your environment now with all necessary packages!

You can now use the native tooling of AS and Xcode to deploy your app directly to a connected device.

Using Native Device Features: The Camera

So far we have only used Capacitor to build our native app, but you can use Capacitor as well to access native device functionality, which is the second big reason to use Capacitor.

Let’s try and add the Camera plugin so we can easily capture images within our app.

Since Capacitor 3 we need to install every plugin separately, so go ahead and run:

npm install @capacitor/camera

# If used inside a PWA also install
npm install @ionic/pwa-elements

The second installation of pwa-elements is only necessary if you want a nice little camera overlay in your PWA or for testing locally. To use the package you would also need to define it inside the src/main.ts like this:

import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppModule } from './app/app.module';
import { environment } from './environments/environment';

import { defineCustomElements } from '@ionic/pwa-elements/loader';

if (environment.production) {
  enableProdMode();
}

platformBrowserDynamic().bootstrapModule(AppModule)
  .catch(err => console.error(err));

  defineCustomElements(window);

Again, not necessary if you only want to use the camera on iOS and Android.

Let’s now quickly throw in a button to trigger a function in the automatically generated src/app/app.component.html, somewhere inside the content area:

<div class="card-container">
    <button class="card card-small" (click)="captureImage()">
      <span>Capture image</span>
    </button>
  </div>

  <img [src]="image" *ngIf="image" [style.width]="'300px'">

On click we will now call the Capacitor camera to capture an image and hopefully assign the result to the image variable.

The call to native plugins is always async, so we await the getPhoto() call and use the resultType base64 in our example.

After we get the result, we simply assign the value to our image including the base64 information and hopefully the image comes up.

Go ahead and change the src/app/app.component.ts to:

import { Component } from '@angular/core';
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  title = 'angularCapacitor';
  image = '';

  async captureImage() {
    const image = await Camera.getPhoto({
      quality: 90,
      allowEditing: true,
      source: CameraSource.Prompt,
      resultType: CameraResultType.Base64
    });
  
    if (image) {
      this.image = `data:image/jpeg;base64,${image.base64String}`!;
    }
  }
}

Since you are now adding functionality that runs inside a native mobile app you need to think about permissions as well.

For the camera plugin, we need to change the ios/App/App/Info.plist of our iOS project to include information why we want to use this particular functionality:

<key>NSCameraUsageDescription</key>
		<string>To capture images</string>
	<key>NSPhotoLibraryAddUsageDescription</key>
		<string>To add images</string>
	<key>NSPhotoLibraryUsageDescription</key>
		<string>To select images</string>

Make sure you use reasonable information in here, otherwise your app has a high chance of being rejected (not kidding).

Same is true for Android, here we need to touch the bottom of the android/app/src/main/AndroidManifest.xml and include two new permissions for the storage to use the camera:

<!-- Permissions -->

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

You can now run another build of your app and sync the changes to your native projects and run the app on a device again or…

Or is there maybe a faster way to achieve all of this?

Capacitor Live Reload

By now you are used to have live reload with all modern frameworks, and we can have the same functionality even on a mobile device with minimum effort!

Usually this can be achieved quite easily with the Ionic CLI, however I wanted to find a more Capacitor like way without adding another dependency and turns out, it’s possible.

The idea is to make your locally served app with live reload available on your network, and the Capacitor app will simply load the content from that URL.

First step is figuring out your local IP, which you can get on a Mac by running:

ipconfig getifaddr en0

On Windows, run ipconfig and look for the IPv4 address.

With that information you can now tell Angular to use it directly as a host (instead of the keyword localhost) or you can simply use 0.0.0.0 which did the same in my test:

ng serve -o --host 0.0.0.0

# Alternative
ng serve -o --host 192.168.x.xx

Now we only need to tell Capacitor to load the app directly from this server, which we can do right in our capacitor.config.ts with another entry:

import { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  appId: 'com.devdactic.angular',
  appName: 'Angular App',
  webDir: 'dist/angularCapacitor',
  bundledWebRuntime: false,
  server: {
    url: 'http://192.168.x.xx:4200',
    cleartext: true
  },
};

export default config;

Make sure you use the right IP and port, I’ve simply used the default Angular port in here.

To apply those changes we can now copy over the changes to our native project:

npx cap copy

Copy is mostly like sync, but will only copy over the changes of the web folder and config, not update the native project.

Now you can deploy your app one more time through Android Studio or Xcode and then change something in your Angular app – the app will automatically reload and show the changes!

Caution: If you install new plugins like the camera, this still requires a rebuild of your native project because native files are changed which can’t be done on the fly.

What about Ionic UI components?

At this point you have a native Angular app which can access native device functionality – pretty impressive already!

If you now also want a more native like styling of components, you could still add Ionic to your project as it’s basically a UI toolkit for developing mobile apps.

This step is completely optional, but if you want to follow along, simply install the according Ionic package for Angular:

npm i @ionic/angular

To use it we need to add the import of the Ionic module to our root module in src/app/app.module.ts:

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { IonicModule } from '@ionic/angular';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    IonicModule.forRoot()
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Since Ionic components rely heavily on custom styling for iOS and Android, you should also import the default Ionic styling files inside your src/styles.scss like this:

/* Core CSS required for Ionic components to work properly */
@import "~@ionic/angular/css/core.css";

/* Basic CSS for apps built with Ionic */
@import "~@ionic/angular/css/normalize.css";
@import "~@ionic/angular/css/structure.css";
@import "~@ionic/angular/css/typography.css";
@import '~@ionic/angular/css/display.css';

/* Optional CSS utils that can be commented out */
@import "~@ionic/angular/css/padding.css";
@import "~@ionic/angular/css/float-elements.css";
@import "~@ionic/angular/css/text-alignment.css";
@import "~@ionic/angular/css/text-transformation.css";
@import "~@ionic/angular/css/flex-utils.css";

What’s missing is the import for Ionic variables for default colors, which are normally included in Ionic projects. That means right now only the default styling is used, but you could add those CSS variables as well to use them just like inside any other Ionic project!

If you want to test wether your Ionic UI integration works, simple add a card or other component to your markup like this:

<ion-card>
    <ion-card-header>
      <ion-card-title>My Ionic card</ion-card-title>
    </ion-card-header>
    <ion-card-content>
      This really works!
    </ion-card-content>
    <ion-button expand="full" color="secondary">My Button</ion-button>
  </ion-card>

If you see a styled card that looks different on iOS and Android you’ve finished your integration!

Tip: To quickly check out different styling you can enable the device preview inside the developer tools of your browser and switch between iOS and Android devices. Make sure you reload the page once when changing platforms to see the new styling in action!

Conclusion

As Capacitor is growing in popularity, it becomes clear why people choose it to build native apps: The ease of adding Capacitor to any web app (Angular, React, Vue…) and the access to native device functionality makes it super easy for web developers to get into mobile app development.

And as a result you have one codebase for web and native iOS + Android.

If you enjoy working with native apps and want to learn more about it, check out the Ionic Academy – my online school that helps you build epic mobile apps using Ionic and Capacitor!

You can also find a video version of this tutorial below.

The post How to Build a Native App from Angular Projects with Capacitor appeared first on Devdactic - Ionic Tutorials.

How to Setup Deep Links With Capacitor (iOS & Android)

$
0
0

Taking your users directly into your app with a universal link on iOS or App Link on Android is one of the easiest ways to open a specific app of your Ionic page, and becomes a super easy task with Capacitor.

In this post we will integrate these links, also known as deep links for both platforms so we are able to jump right into the app if it is installed.

capacitor-deep-links

For this we won’t need a lot of code, but a bit of additional setup to make those deep links work correctly. In the end, you will be able to open a URL like “www.yourdomain.com/details/22” in the browser and your app will automatically open the right page!

Ionic Deeplink Setup

Let’s begin with the easiest part, which is actually setting up a new Ionic app and generating one details page for testing:

ionic start devdacticLinks blank --type=angular
cd ./devdacticLinks
ionic g page details

ionic build
ionic cap add ios
ionic cap add android

You can also create the native builds after creating the app since we will have to work with the native files later as well. For this I recommend you put your correct bundle ID into the capacitor.config.json or TS file, because it will be used only during this initial setup.

In my case I used “com.devdactic.deeplinks” as the ID like this inside the file:

{
  "appId": "com.devdactic.deeplinks",
  "appName": "devdacticLinks",
  "webDir": "www",
  "bundledWebRuntime": false
}

Next step is some basic routing, and we will simply make our details page accessible with a wildcard in the URL that we will later use in the deep link to see that our setup works.

Go ahead now and change the src/app/app-routing.module.ts to:

import { NgModule } from '@angular/core';
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';

const routes: Routes = [
  {
    path: 'home',
    loadChildren: () =>
      import('./home/home.module').then((m) => m.HomePageModule),
  },
  {
    path: 'details/:id',
    loadChildren: () =>
      import('./details/details.module').then((m) => m.DetailsPageModule),
  },
  {
    path: '',
    redirectTo: 'home',
    pathMatch: 'full',
  },
];

@NgModule({
  imports: [
    RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules }),
  ],
  exports: [RouterModule],
})
export class AppRoutingModule {}

Now we can retrieve the ID information from the URL just like we do in a normal Angular routing scenario on our src/app/details/details.page.ts:

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';

@Component({
  selector: 'app-details',
  templateUrl: './details.page.html',
  styleUrls: ['./details.page.scss'],
})
export class DetailsPage implements OnInit {
  id = '';

  constructor(private route: ActivatedRoute) {}

  ngOnInit() {
    this.id = this.route.snapshot.paramMap.get('id');
  }
}

Finally let’s also display the information we got from the URL on the src/app/details/details.page.html:

<ion-header>
  <ion-toolbar color="primary">
    <ion-buttons slot="start">
      <ion-back-button defaultHref="/"></ion-back-button>
    </ion-buttons>
    <ion-title>Details</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content> My ID: {{ id }} </ion-content>

All of this was basic Ionic Angular stuff and by no means related to deep links at all!

The magic now comes from handling the appUrlOpen event, which we can do easily by using Capacitor.

We simply add a listener on this event and from there get access to the URL with which our app was opened from the outside!

Since that URL contains your domain as well, we need to split the URL to remove that part, and then use the rest of the URL for our app routing.

This might be different for your own app since you have other pages or a different routing, but you could also simply add some logic in there and check the different path components of the URL and then route the user to the right place in your app!

Go ahead and change the src/app/app.component.ts to this now:

import { Component, NgZone } from '@angular/core';
import { Router } from '@angular/router';
import { App, URLOpenListenerEvent } from '@capacitor/app';

@Component({
  selector: 'app-root',
  templateUrl: 'app.component.html',
  styleUrls: ['app.component.scss'],
})
export class AppComponent {
  constructor(private router: Router, private zone: NgZone) {
    this.initializeApp();
  }

  initializeApp() {
    App.addListener('appUrlOpen', (event: URLOpenListenerEvent) => {
      this.zone.run(() => {
        const domain = 'devdactic.com';

        const pathArray = event.url.split(domain);
        // The pathArray is now like ['https://devdactic.com', '/details/42']

        // Get the last element with pop()
        const appPath = pathArray.pop();
        if (appPath) {
          this.router.navigateByUrl(appPath);
        }
      });
    });
  }
}

Also notice that we didn’t install a single additional plugin? No more Cordova plugins with specific parameters, everything we need is already available inside the Capacitor App package!

But this was the easy part – now we need some customisation for iOS and Android to actually make deep links work.

iOS Configuration

If you don’t have an app ID created inside your iOS Developer account, now is the time.

First of all you need to be enrolled in the Apple Developer Program, which is also necessary to submit your apps in the end.

Your app needs a valid identifier that you also always need when you submit your app. If you want to create a new one, just go to the identifiers list inside your account and add a new App id.

ios-app-id-deep-links

It’s important to enable Associated Domains for your app id in this screen!

In that screen you need to note 2 things (which you can see in the image above):

  • The bundle id (app id) you specified
  • Your Team id

Now we need to create another validation file, which is called apple-app-site-association. Without any ending, only this name!

The content should look like this, but of course insert your team id and bundle ID, for example “12345.com.devdactic.wpapp”:

{
    "applinks": {
        "apps": [],
        "details": [
            {
                "appID": "YOURTEAMID.com.your.bundleid",
                "paths": ["*"]
            }
        ]
    }
}

You can create that file simply anywhere on your computer, it doesn’t have to be inside the project. It doesn’t matter, because it actually needs to be served on your domain!

So the next step is upload the validation file to your hosting.

You can add the file to the same .well-known folder, and your file needs to be accessible on your domain.

You can find my file here: http://devdactic.com/.well-known/apple-app-site-association

The file validates your domain for iOS, and you can also specify which paths should match. I used the * wildcard to match any routes, but if you only want to open certain paths directly in the app you could specify something like “products/*” or event multiple different paths!

If you think you did everything correctly, you can insert your data in this nice testing tool for iOS.

The last step is to add the domains to your Xcode plist. You can do this directly inside Xcode by adding a new entry and using the format “applinks:yourdomain.com“.

ios-capabilities-deep-links

At the end of this tutorial I also share another way to do this kind of customisation directly from code with a cool Capacitor tool.

Note: After going through this process I noticed a bug with Chrome on iOS which you should keep an eye on.

Anyway, that’s already everything we need to create universal links for iOS with Ionic!

Android Configuration

Now we want to make our links work on Android, where the name for these special links is App Links.

We need to take a few steps to verify that we own a URL (just like we did for iOS) and that we have a related app:

  1. Generate a keystore file used to sign your apps (if you haven’t already)
  2. Get the fingerprint from the keystore file
  3. Create/generate an assetlinks.json file
  4. Upload the file to your server

So first step is to create a keystore file and get the fingerprint data. This file is used to sign your app, so perhaps you already have it. Otherwise, go ahead with these:

keytool -genkey -v -keystore my-release-key.keystore -alias alias_name -keyalg RSA -keysize 2048 -validity 10000
keytool -list -v -keystore my-release-key.keystore

Now we can use the cool tool right here to generate our file by adding your domain data and fingerprint data.

android-deep-link-tester

You can paste the generated information into an assetlinks.json file that you need to upload to your domain. The file content has this form:

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.devdactic.deeplinks",
      "sha256_cert_fingerprints": [
        "CB:2B:..."
      ]
    }
  }
]

In my case, you can see the file at https://devdactic.com/.well-known/assetlinks.json and you need to upload it to the path on your domain of course as well.

Once you have uploaded the file, you can test if everything is fine right within the testing tool again and the result should be a green circle!

The last step is to change your android/app/src/main/AndroidManifest.xml and include and additional intent-filter inside the activity element:

<activity ....>
            <intent-filter android:autoVerify="true">
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data android:scheme="https" android:host="devdactic.com" />
            </intent-filter>
</activity>

Now you just need to build your app and sign it, since I found issues when not signing my app. You can do this by running:

cd ./android
./gradlew assembleRelease 
cd ..

jarsigner -verbose -sigalg SHA1withRSA -digestalg SHA1 -keystore my-release-key.keystore ./android/app/build/outputs/apk/release/app-release-unsigned.apk alias_name
zipalign -v 4 ./android/app/build/outputs/apk/release/app-release-unsigned.apk devdactic.apk

adb install devdactic.apk

Run all the commands and the app will be installed on your connected device.

You could also create a signed APK from Android Studio, just make sure you specify the same keystore file for signing as you used for generating the SHA information in the beginning.

Capacitor Configure for native project settings

We have applied a custom setting for iOS by changing it inside Xcode, but you can also automate a bunch of things with a new tool as well.

If you want to do this in a cooler way, I highly recommend you integrate the new Capacitor configure package and do this from the command line instead. It’s a super helpful tool for customising your native iOS and Android projects!

Get started by installing it inside your project first:

npm i @capacitor/configure

Now you can create a config.yaml at the root level of your project with the following content_

vars:
  BUNDLE_ID:
    default: com.devdactic.deeplinks
  PACKAGE_NAME:
    default: com.devdactic.deeplinks

platforms:
  ios:
    targets:
      App:
        bundleId: $BUNDLE_ID

        entitlements:
          - com.apple.developer.associated-domains: ["applinks:devdactic.com"]
  android:
    packageName: $PACKAGE_NAME

Of course you should use your own bundle ID and package name, and insert your domain name for the entitlements.

All you need to do now is run the configure tool with this config by executing:

npx cap-config run config.yaml

And voila, the settings you specified in the YAML file are applied to your native projects!

Conclusion

Compared to deep links with Cordova, the process for links with Capacitor is a lot easier since we don’t need any additional plugin and only the core Capacitor functionalities.

Still, the important part remains the setup and verification of your domains for both iOS and Android, so make sure the according testing tools show a green light after uploading your file.

If that’s not the case, this is the place to debug and potentially fix permissions or serving headers for your files so that Android and Apple accept your domain as authorised for deep links!

You can also find a video version of this tutorial below.

The post How to Setup Deep Links With Capacitor (iOS & Android) appeared first on Devdactic - Ionic Tutorials.


Building an Ionic App with Firebase Authentication & File Upload using AngularFire 7

$
0
0

If you want a full blown cloud backend for your Ionic app, Firebase offers everything you need out of the box so you can setup your Ionic app with authentication and file upload in minutes!

In this tutorial we will implement the whole flow from connecting our Ionic app to Firebase, adding user authentication and protecting pages after a login to finally selecting an image with Capacitor and uploading it to Firebase cloud storage!

ionic-firebase-9-auth

All of this might sound intimidating but you will see, it’s actually a breeze with these tools. To handle our Firebase interaction more easily we will use the AngularFire library version 7 which works with the Firebase SDK version 9 which I used in this post.

Note: At the time writing there was a problem when running the app on iOS devices – read until the end for a solution!

Creating the Firebase Project

Before we dive into the Ionic app, we need to make sure we actually have a Firebase app configured. If you already got something in place you can of course skip this step.

Otherwise, make sure you are signed up (it’s free) and then hit Add project inside the Firebase console. Give your new app a name, select a region and then create your project!

Once you have created the project you can see the web configuration which looks like this:

ionic-4-firebase-add-to-app

If it’s a new project, click on the web icon below “Get started by adding Firebase to your app” to start a new web app and give it a name, you will see the configuration in the next step now.

Leave this config block open just for reference, it will hopefully be copied automatically later by a schematic.

Additionally we have to enable the database, so select Firestore Database from the menu and click Create database.

ionic-4-firestore

Here we can set the default security rules for our database and because this is a simple tutorial we’ll roll with the test mode which allows everyone access.

Because we want to work with users we also need to go to the Authetnication tab, click Get started again and activate the Email/Password provider. This allows us to create user with a standard email/ps combination.

firebase-auth-provider

The last step is enabling Storage in the according menu entry as well, and you can go with the default rules because we will make sure users are authenticated at the point when they upload or read files.

The rules should look like this:

rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    match /{allPaths=**} {
      allow read, write: if request.auth != null;
    }
  }
}

Note: For real applications you need to create secure rules for storage and your Firestore database, otherwise people can easily access all your data from the outside!

You can learn more about Firebase and security rules inside the Ionic Academy.

Starting our Ionic App & Firebase Integration

Now we can finally begin with the actual Ionic app, and all we need is a blank template, an additional page and two services for the logic in our app:

ionic start devdacticFire blank --type=angular
cd ./devdacticFire

ionic g page login
ionic g service services/auth
ionic g service services/avatar

# For image upload with camera
npm i @capacitor/camera
npm i @ionic/pwa-elements

ng add @angular/fire

Besides that we can already install the Capacitor camera package to capture images later (and the PWA elements for testing on the browser).

To use those PWA elements, quickly bring up your src/main.ts and import plus call the defineCustomElements function:

import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
import { defineCustomElements } from '@ionic/pwa-elements/loader';

if (environment.production) {
  enableProdMode();
}

platformBrowserDynamic()
  .bootstrapModule(AppModule)
  .catch((err) => console.log(err));

defineCustomElements(window);

The last command is the most important as it starts the AngularFire schematic, which has become a lot more powerful over the years! You should select the according functions that your app needs, in our case select Cloud Storage, Authentication and Firestore.

ionic-firebase-add-cli

After that a browser will open to log in with Google, which hopefully reads your list of Firebase apps so you can select the Firebase project and app your created in the beginning!

As a result the schematic will automatically fill your environments/environment.ts file – if bot make sure you manually add the Firebase configuration from the first step like this:

export const environment = {
  production: false,
  firebase: {
    apiKey: "",
    authDomain: "",
    projectId: "",
    storageBucket: "",
    messagingSenderId: "",
    appId: ""
  }
};

On top of that the schematic injected everything necessary into our src/app/app.module.ts using the new Firebase 9 modular approach:

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

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [
    BrowserModule,
    IonicModule.forRoot(),
    AppRoutingModule,
    provideFirebaseApp(() => initializeApp(environment.firebase)),
    provideAuth(() => getAuth()),
    provideFirestore(() => getFirestore()),
    provideStorage(() => getStorage()),
  ],
  providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
  bootstrap: [AppComponent],
})
export class AppModule {}

Again, if the schematic failed for some reason that’s how your module should look like before you continue!

Now we can also quickly touch the routing of our app to display the login page as the first page, and use the default home page for the inside area.

We don’t have authentication implemented yet, but we can already use the AngularFire auth guards in two cool ways:

  • Protect access to “inside” pages by redirecting unauthorized users to the login
  • Preventing access to the login page for previously authenticated users, so they are automatically forwarded to the “inside” area of the app

This is done with the helping pipes and services of AngularFire that you can now add inside the src/app/app-routing.module.ts:

import { NgModule } from '@angular/core';
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';
import {
  redirectUnauthorizedTo,
  redirectLoggedInTo,
  canActivate,
} from '@angular/fire/auth-guard';

const redirectUnauthorizedToLogin = () => redirectUnauthorizedTo(['']);
const redirectLoggedInToHome = () => redirectLoggedInTo(['home']);

const routes: Routes = [
  {
    path: '',
    loadChildren: () =>
      import('./login/login.module').then((m) => m.LoginPageModule),
    ...canActivate(redirectLoggedInToHome),
  },
  {
    path: 'home',
    loadChildren: () =>
      import('./home/home.module').then((m) => m.HomePageModule),
    ...canActivate(redirectUnauthorizedToLogin),
  },
  {
    path: '**',
    redirectTo: '',
    pathMatch: 'full',
  },
];

@NgModule({
  imports: [
    RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules }),
  ],
  exports: [RouterModule],
})
export class AppRoutingModule {}

Now we can begin with the actual authentication of users!

Building the Authentication Logic

The whole logic will be in a separate service, and we need jsut three functions that simply call the according Firebase function to create a new user, sign in a user or end the current session.

For all these calls you need to add the Auth reference, which we injected inside the constructor.

Since these calls sometimes fail and I wasn’t very happy about the error handling, I wrapped them in try/catch blocks so we have an easier time when we get to our actual page.

Let’s begin with the src/app/services/auth.service.ts now and change it to:

import { Injectable } from '@angular/core';
import {
  Auth,
  signInWithEmailAndPassword,
  createUserWithEmailAndPassword,
  signOut
} from '@angular/fire/auth';

@Injectable({
  providedIn: 'root',
})
export class AuthService {
  constructor(private auth: Auth) {}

  async register({ email, password }) {
    try {
      const user = await createUserWithEmailAndPassword(
        this.auth,
        email,
        password
      );
      return user;
    } catch (e) {
      return null;
    }
  }

  async login({ email, password }) {
    try {
      const user = await signInWithEmailAndPassword(this.auth, email, password);
      return user;
    } catch (e) {
      return null;
    }
  }

  logout() {
    return signOut(this.auth);
  }
}

That’s already everything in terms of logic. Now we need to capture the user information for the registration, and therefore we import the ReactiveFormsModule in our src/app/login/login.module.ts now:

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

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    IonicModule,
    LoginPageRoutingModule,
    ReactiveFormsModule,
  ],
  declarations: [LoginPage],
})
export class LoginPageModule {}

Since we want to make it easy, we’ll handle both registration and signup with the same form on one page.

But since we added the whole logic already to a service, there’s not much left for us to do besides showing a casual loading indicator or presenting an alert if the action failed.

If the registration or login is successful and we get back an user object, we immediately route the user forward to our inside area.

Go ahead by changing the src/app/login/login.page.ts to:

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { AlertController, LoadingController } from '@ionic/angular';
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;

  constructor(
    private fb: FormBuilder,
    private loadingController: LoadingController,
    private alertController: AlertController,
    private authService: AuthService,
    private router: Router
  ) {}

  // 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 register() {
    const loading = await this.loadingController.create();
    await loading.present();

    const user = await this.authService.register(this.credentials.value);
    await loading.dismiss();

    if (user) {
      this.router.navigateByUrl('/home', { replaceUrl: true });
    } else {
      this.showAlert('Registration failed', 'Please try again!');
    }
  }

  async login() {
    const loading = await this.loadingController.create();
    await loading.present();

    const user = await this.authService.login(this.credentials.value);
    await loading.dismiss();

    if (user) {
      this.router.navigateByUrl('/home', { replaceUrl: true });
    } else {
      this.showAlert('Login failed', 'Please try again!');
    }
  }

  async showAlert(header, message) {
    const alert = await this.alertController.create({
      header,
      message,
      buttons: ['OK'],
    });
    await alert.present();
  }
}

The last missing piece is now our view, which we connect with the formGroup we defined in our page. On top of that we can show some small error messages using the new Ionic 6 error slot.

Just make sure that one button inside the form has the submit type and therefore triggers the ngSubmit action, while the other has the type button if it should just trigger it’s connected click event!

Bring up the src/app/login/login.page.html now and change it to:

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>My App</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
  <form (ngSubmit)="login()" [formGroup]="credentials">
    <ion-item fill="solid" class="ion-margin-bottom">
      <ion-input type="email" placeholder="Email" formControlName="email"></ion-input>
      <ion-note slot="error" *ngIf="(email.dirty || email.touched) && email.errors">Email is invalid</ion-note>
    </ion-item>
    <ion-item fill="solid" class="ion-margin-bottom">
      <ion-input type="password" placeholder="Password" formControlName="password"></ion-input>
      <ion-note slot="error" *ngIf="(password.dirty || password.touched) && password.errors">Password needs to be 6 characters</ion-note>
    </ion-item>

    <ion-button type="submit" expand="block" [disabled]="!credentials.valid">Log in</ion-button>
    <ion-button type="button" expand="block" color="secondary" (click)="register()">Create account</ion-button>
  </form>
</ion-content>

And at this point we are already done with the first half of our tutorial, since you can now really register users and also log them in.

You can confirm this by checking the Authentication area of your Firebase console and hopefully a new user was created in there!

ionic-firebase-user

For a more extensive login and registration UI tutorial you can also check out the Ionic App Navigation with Login, Guards & Tabs Area tutorial!

Uploading image files to Firebase with Capacitor

Just like before we will now begin with the service implementation, which makes life really easy for our page later down the road.

The service should first of all return the document of a user in which we plan to store the file reference/link to the user avatar.

In many tutorials you directly create a document inside your Firestore database for a user right after the sign up, but it’s also no problem that we haven’t done by now.

The data can be retrieved using the according docData() function – you can learn more about the way of accessing collections and documents with Firebase 9 here.

Besides that we can craft our uploadImage() function and expect a Photo object since this is what we get back from the Capacitor camera plugin.

Now we just need to create a path to where we want to upload our file and a reference to that path within Firebase storage.

With that information we can trigger the uploadString() function since we simply upload a base64 string this time. But there’s also a function to upload a Blob in case you have some raw data.

When the function is finished, we need to call another getDownloadURL() function to get the actual path of the image that we just uploaded.

This information is now written to the user document so we can later easily retrieve it.

All of that sounds challenging, but it’s actually just a few lines of code inside our src/app/services/avatar.service.ts:

import { Injectable } from '@angular/core';
import { Auth } from '@angular/fire/auth';
import { doc, docData, Firestore, setDoc } from '@angular/fire/firestore';
import {
  getDownloadURL,
  ref,
  Storage,
  uploadString,
} from '@angular/fire/storage';
import { Photo } from '@capacitor/camera';

@Injectable({
  providedIn: 'root',
})
export class AvatarService {
  constructor(
    private auth: Auth,
    private firestore: Firestore,
    private storage: Storage
  ) {}

  getUserProfile() {
    const user = this.auth.currentUser;
    const userDocRef = doc(this.firestore, `users/${user.uid}`);
    return docData(userDocRef, { idField: 'id' });
  }

  async uploadImage(cameraFile: Photo) {
    const user = this.auth.currentUser;
    const path = `uploads/${user.uid}/profile.png`;
    const storageRef = ref(this.storage, path);

    try {
      await uploadString(storageRef, cameraFile.base64String, 'base64');

      const imageUrl = await getDownloadURL(storageRef);

      const userDocRef = doc(this.firestore, `users/${user.uid}`);
      await setDoc(userDocRef, {
        imageUrl,
      });
      return true;
    } catch (e) {
      return null;
    }
  }
}

In the end, we should therefore see an entry inside Firestore with the unique user ID inside the path and the image stored for that user like in the image below.

ionic-firebase-firestore-image

Now let’s put that service to use in our page!

First, we subscribe to the getUserProfile() function as we will then get the new value whenever we change that image.

Besides that we add a logout function, and finally a function that calls the Capacitor camera plugin. The image result will be passed to our service which handles all the rest – we just need some loading and error handling in here again!

Therefore go ahead now and change the src/app/home/home.page.ts to:

import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
import { AlertController, LoadingController } from '@ionic/angular';
import { AuthService } from '../services/auth.service';
import { AvatarService } from '../services/avatar.service';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})
export class HomePage {
  profile = null;

  constructor(
    private avatarService: AvatarService,
    private authService: AuthService,
    private router: Router,
    private loadingController: LoadingController,
    private alertController: AlertController
  ) {
    this.avatarService.getUserProfile().subscribe((data) => {
      this.profile = data;
    });
  }

  async logout() {
    await this.authService.logout();
    this.router.navigateByUrl('/', { 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();
      await loading.present();

      const result = await this.avatarService.uploadImage(image);
      loading.dismiss();

      if (!result) {
        const alert = await this.alertController.create({
          header: 'Upload failed',
          message: 'There was a problem uploading your avatar.',
          buttons: ['OK'],
        });
        await alert.present();
      }
    }
  }
}

We’re almost there!

Now we need a simple view to display either the user avatar image if it exists, or just a placeholder if we don’t have a user document (or avatar image) yet.

That’s done pretty easily by changing our src/app/home/home.page.html to:

<ion-header>
  <ion-toolbar color="primary">
    <ion-buttons slot="start">
      <ion-button (click)="logout()">
        <ion-icon slot="icon-only" name="log-out"></ion-icon>
      </ion-button>
    </ion-buttons>
    <ion-title> My Profile </ion-title>
  </ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
  <div class="preview">
    <ion-avatar (click)="changeImage()">
      <img *ngIf="profile?.imageUrl; else placheolder_avatar;" [src]="profile.imageUrl" />
      <ng-template #placheolder_avatar>
        <div class="fallback">
          <p>Select avatar</p>
        </div>
      </ng-template>
    </ion-avatar>
  </div>
</ion-content>

To make everything centered and look a bit nicer, just put the following quickly into your src/app/home/home.page.scss:

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

And BOOM – you are done and have the whole flow from user registration, login to uploading files as a user implemented with Ionic and Capacitor!

You can check if the image was really uploaded by also taking a look at the Storage tab of your Firebase project.

ionic-firebase-storage-files

If you want the preview to show up correctly in there, just supply the right metadata during the upload task, but the image will be displayed inside your app no matter what.

Native iOS and Android Changes

To make all of this also work nicely on your actual native apps, we need a few changes.

First, go ahead and add those platforms:

ionic build

ionic cap add ios
ionic cap add android

Because we are accessing the camera we also need to define the permissions for the native platforms, so let’s start with iOS and add the following permissions (with a good reason in a real app!) to your ios/App/App/Info.plist:

<key>NSCameraUsageDescription</key>
	<string>To capture images</string>
	<key>NSPhotoLibraryAddUsageDescription</key>
	<string>To add images</string>
	<key>NSPhotoLibraryUsageDescription</key>
	<string>To store images</string>

For Android we need to do the same. Therefore, bring up the android/app/src/main/AndroidManifest.xml and after the line that already sets the internet permission add two more lines:

<uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

Finally when I ran the app on my device, I just got a white screen of death.

There was a problem with the Firebase SDK and Capacitor, but there’s actually an easy fix.

We only need to change our src/app/app.module.ts and use the native authentication directly from the Firebase SDK when our app runs as a native app:

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 { Capacitor } from '@capacitor/core';
import { indexedDBLocalPersistence, initializeAuth } from 'firebase/auth';
import { getApp } from 'firebase/app';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [
    BrowserModule,
    IonicModule.forRoot(),
    AppRoutingModule,
    provideFirebaseApp(() => initializeApp(environment.firebase)),
    provideAuth(() => {
      if (Capacitor.isNativePlatform()) {
        return initializeAuth(getApp(), {
          persistence: indexedDBLocalPersistence,
        });
      } else {
        return getAuth();
      }
    }),
    provideFirestore(() => getFirestore()),
    provideStorage(() => getStorage()),
  ],
  providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
  bootstrap: [AppComponent],
})
export class AppModule {}

Because of the modular approach this change is super easy to add, and now you can also enjoy the Firebase app with upload on your iOS device!

Conclusion

Firebase remains one of the best choices as a cloud backend for your Ionic application if you want to quickly add features like user authentication, database or file upload.

For everyone more into SQL, you should also check out the rising star Supabase which offers already all essential functionality that Firebase has in an open source way.

You can also find a video version of this tutorial below.

The post Building an Ionic App with Firebase Authentication & File Upload using AngularFire 7 appeared first on Devdactic - Ionic Tutorials.

Building the YouTube UI with Ionic

$
0
0

We are once again building a popular UI with Ionic, and this time it’s the YouTube home video feed!

You like to see popular apps built with Ionic? Check out my latest eBook Built with Ionic for even more real world app examples!

Today we will focus only on the the tab bar setup and the home page of the YouTube app – leave a comment if you would like to see an example of the video details page as well!

ionic-youtube-ui

Additionally we need to create a directive that scrolls our header out or in while also moving the content, so we got quite a challenge today!

Starting the YouTube App with Ionic

To get started we generate a new Ionic app using the tabs layout and generate a few additional pages that we will need. On top of that we generate a module and directive which we will need for our header animation in the end!

ionic start youtube tabs --type=angular

ionic g page tab4
ionic g page sheet

ionic g module directives/sharedDirectives --flat
ionic g directive directives/hideHeader

Additionally I created some mock data that you can download from Github and place inside the assets folder of your new app!

Because we want to load some local JSON data with Angular, we need to add two properties to our tsconfig.json:

"compilerOptions": {
    "resolveJsonModule": true,
    "allowSyntheticDefaultImports": true
    ...
}

To apply the Ionic typical styling for the application we can also change the defaul colors inside the src/theme/variables.scss now:

:root {
  --ion-color-primary: #000000;
  --ion-color-primary-rgb: 0, 0, 0;
  --ion-color-primary-contrast: #ffffff;
  --ion-color-primary-contrast-rgb: 255, 255, 255;
  --ion-color-primary-shade: #000000;
  --ion-color-primary-tint: #1a1a1a;

  --ion-color-secondary: #ff0000;
  --ion-color-secondary-rgb: 255, 0, 0;
  --ion-color-secondary-contrast: #ffffff;
  --ion-color-secondary-contrast-rgb: 255, 255, 255;
  --ion-color-secondary-shade: #e00000;
  --ion-color-secondary-tint: #ff1a1a;

  --ion-color-tertiary: #5260ff;
  --ion-color-tertiary-rgb: 82, 96, 255;
  --ion-color-tertiary-contrast: #ffffff;
  --ion-color-tertiary-contrast-rgb: 255, 255, 255;
  --ion-color-tertiary-shade: #4854e0;
  --ion-color-tertiary-tint: #6370ff;

  --ion-color-success: #2dd36f;
  --ion-color-success-rgb: 45, 211, 111;
  --ion-color-success-contrast: #000000;
  --ion-color-success-contrast-rgb: 0, 0, 0;
  --ion-color-success-shade: #28ba62;
  --ion-color-success-tint: #42d77d;

  --ion-color-warning: #ffc409;
  --ion-color-warning-rgb: 255, 196, 9;
  --ion-color-warning-contrast: #000000;
  --ion-color-warning-contrast-rgb: 0, 0, 0;
  --ion-color-warning-shade: #e0ac08;
  --ion-color-warning-tint: #ffca22;

  --ion-color-danger: #eb445a;
  --ion-color-danger-rgb: 235, 68, 90;
  --ion-color-danger-contrast: #ffffff;
  --ion-color-danger-contrast-rgb: 255, 255, 255;
  --ion-color-danger-shade: #cf3c4f;
  --ion-color-danger-tint: #ed576b;

  --ion-color-medium: #92949c;
  --ion-color-medium-rgb: 146, 148, 156;
  --ion-color-medium-contrast: #000000;
  --ion-color-medium-contrast-rgb: 0, 0, 0;
  --ion-color-medium-shade: #808289;
  --ion-color-medium-tint: #9d9fa6;

  --ion-color-light: #ffffff;
  --ion-color-light-rgb: 255, 255, 255;
  --ion-color-light-contrast: #000000;
  --ion-color-light-contrast-rgb: 0, 0, 0;
  --ion-color-light-shade: #e0e0e0;
  --ion-color-light-tint: #ffffff;
}

We got the basics in place – let’s get started with the custom tab bar!

Building the Tab Bar

The YouTube app comes with a slightly different tab bar as. we have 5 buttons, of which 4 lead to a different tab and the button in the center calls a different function.

ionic-yt-tabbar

We can move in that direction by first of all integrating the tab we generated into the src/app/tabs/tabs-routing.module.ts like this:

const routes: Routes = [
  {
    path: 'tabs',
    component: TabsPage,
    children: [
      {
        path: 'tab1',
        loadChildren: () =>
          import('../tab1/tab1.module').then((m) => m.Tab1PageModule),
      },
      {
        path: 'tab2',
        loadChildren: () =>
          import('../tab2/tab2.module').then((m) => m.Tab2PageModule),
      },
      {
        path: 'tab3',
        loadChildren: () =>
          import('../tab3/tab3.module').then((m) => m.Tab3PageModule),
      },
      {
        path: 'tab4',
        loadChildren: () =>
          import('../tab4/tab4.module').then((m) => m.Tab4PageModule),
      },
      {
        path: '',
        redirectTo: '/tabs/tab1',
        pathMatch: 'full',
      },
    ],
  },
  {
    path: '',
    redirectTo: '/tabs/tab1',
    pathMatch: 'full',
  },
];

Now we can add two new buttons to the tab bar setup, and the button in the center won’t be linked to a tab but instead simply come with a click handler to trigger the bottom sheet later!

Besides that we are adding a template reference to every tab, and then use the selected property of a button to conditionally show different icons.

You will notice that in many apps the icons change from outline to a filled style when selected, and that’s what we are replicating here.

Go ahead now and change the src/app/tabs/tabs.page.html to:

<ion-tabs>
  <ion-tab-bar slot="bottom">
    <ion-tab-button tab="tab1" #tab1>
      <ion-icon [name]="tab1.selected ? 'home' : 'home-outline'"></ion-icon>
      <ion-label>Home</ion-label>
    </ion-tab-button>

    <ion-tab-button tab="tab2" #tab2>
      <ion-icon [name]="tab2.selected ? 'videocam' : 'videocam-outline'"></ion-icon>
      <ion-label>Shorts</ion-label>
    </ion-tab-button>

    <ion-tab-button (click)="add()">
      <ion-icon name="add-circle-outline"></ion-icon>
    </ion-tab-button>

    <ion-tab-button tab="tab3" #tab3>
      <ion-icon [name]="tab3.selected ? 'albums' : 'albums-outline'"></ion-icon>
      <ion-label>Subscriptions</ion-label>
    </ion-tab-button>

    <ion-tab-button tab="tab4" #tab4>
      <ion-icon [name]="tab4.selected ? 'library' : 'library-outline'"></ion-icon>
      <ion-label>Library</ion-label>
    </ion-tab-button>
  </ion-tab-bar>
</ion-tabs>

The bar should also come with a white background, and we can even fine tune the color of the icons and the stroke width of an Ionicon by changing these things within our src/app/tabs/tabs.page.scss like this:

ion-tab-bar {
  --background: #fff;
}

ion-tab-button {
  --color: var(--ion-color-primary);
  ion-icon {
    --ionicon-stroke-width: 16px;
  }
}

To open the modal in a bottom sheet way we just need to pass in the breakpoints and initialBreakpoint properties and Ionic will do the magic, so let’s display the modal on click within our src/app/tabs/tabs.page.ts:

import { Component } from '@angular/core';
import { ModalController } from '@ionic/angular';
import { SheetPage } from '../sheet/sheet.page';

@Component({
  selector: 'app-tabs',
  templateUrl: 'tabs.page.html',
  styleUrls: ['tabs.page.scss'],
})
export class TabsPage {
  constructor(private modalCtrl: ModalController) {}

  async add() {
    const modal = await this.modalCtrl.create({
      component: SheetPage,
      breakpoints: [0.5],
      initialBreakpoint: 0.5,
      handle: false,
    });

    await modal.present();
  }
}

Now within that sheet we just want to display a few items with icon, but to make the life inside the template easier we can define those different items simply as an array inside our modal page at src/app/sheet/sheet.page.ts:

import { Component, OnInit } from '@angular/core';
import { ModalController } from '@ionic/angular';

@Component({
  selector: 'app-sheet',
  templateUrl: './sheet.page.html',
  styleUrls: ['./sheet.page.scss'],
})
export class SheetPage implements OnInit {
  items = [
    {
      text: 'Create a Short',
      icon: 'videocam-outline',
    },
    {
      text: 'Upload a video',
      icon: 'push-outline',
    },
    {
      text: 'Go live',
      icon: 'radio-outline',
    },
    {
      text: 'Add to your story',
      icon: 'add-circle-outline',
    },
    {
      text: 'Create a post',
      icon: 'create-outline',
    },
  ];

  constructor(private modalCtrl: ModalController) {}

  ngOnInit() {}

  dismiss() {
    this.modalCtrl.dismiss();
  }
}

The modal page itself comes with some custom styling so the header doesn’t look to much like a header and more like a title. We achieve a left hand position by simply setting the mode of the title to md because the title is always on the left on Android!

Besides that we just got the iteration of items, so let’s change the src/app/sheet/sheet.page.html to:

<ion-header class="ion-no-border">
  <ion-toolbar color="light">
    <ion-title mode="md">Create</ion-title>
    <ion-buttons slot="end">
      <ion-button (click)="dismiss()" fill="clear">
        <ion-icon name="close" slot="icon-only"></ion-icon>
      </ion-button>
    </ion-buttons>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-item *ngFor="let item of items" lines="none">
    <ion-icon [name]="item.icon" slot="start"></ion-icon>
    <ion-label> {{ item.text }} </ion-label>
  </ion-item>
</ion-content>

Finally those items need more room to breath – therefore we add some more padding and margin and apply a nice round background to our icons inside the src/app/sheet/sheet.page.scss:

ion-item {
  margin-top: 10px;
  margin-bottom: 10px;
  ion-icon {
    background: #f2f2f2;
    padding: 10px;
    border-radius: 50%;
    --ionicon-stroke-width: 16px;
  }
}

ionic-yt-bottom-sheet

And with that our YouTube tab bar setup including custom button to trigger a bottom drawer component is already done!

Basic Home Screen

Now we can focus on the home screen, for which we want to achieve a few different things:

  • Create the header with buttons and additional scrollable segments row
  • Show skeleton views while the (fake) data is loading
  • Build the video feed list

First of all we can setup some data for the segments and the video items, which we create right here or import from our dummy JSON data. On top of that we can add functionality that will only set this data after 1.5 seconds so we can actually see our loading skeletons – in a normal app you would make an API call and display them while you are loading of course!

Additionally we can add a function to select on segment from our items, and a fake function to complete an Ionic refresher event after a second.

Now bring up the src/app/tab1/tab1.page.ts and change it to:

import { Component } from '@angular/core';
import { RefresherCustomEvent } from '@ionic/angular';
import homeData from '../../assets/data/home.json';

@Component({
  selector: 'app-tab1',
  templateUrl: 'tab1.page.html',
  styleUrls: ['tab1.page.scss'],
})
export class Tab1Page {
  videos = [];
  segments: any[] = [];

  constructor() {
    this.segments = [
      'All',
      'New to you',
      'Gaming',
      'Sitcoms',
      'Computer program',
      'Documentary',
      'Music',
    ].map((val) => ({
      title: val,
      selected: false,
    }));
    setTimeout(() => {
      this.selectSegment(0);
      this.videos = homeData;
    }, 1000);
  }

  doRefresh(event: RefresherCustomEvent) {
    setTimeout(() => {
      event.target.complete();
    }, 1500);
  }

  selectSegment(i) {
    this.segments.map((item) => (item.selected = false));
    this.segments[i].selected = true;
  }
}

Now we can start with the template and craft the header by simply using two ion-toolbar elements inside the header below each other!

The first holds the logo and some small buttons, while the second toolbar holds our custom segment. We create it like this because customising the Ionic segment would take mostly the same time if not longer, and it’s quite easy to create a horizontal scrollable segment view.

Those segment buttons get a conditional class based on the selected property and trigger the function we created before.

Therefore continue with the src/app/tab1/tab1.page.html and change the header area to:

<ion-header>
  <ion-toolbar color="light">
    <img src="./assets/data/logo.png" width="100px" />

    <ion-buttons slot="end">
      <ion-button size="small"> <ion-icon name="tv-outline"></ion-icon> </ion-button>
      <ion-button size="small"> <ion-icon name="notifications-outline"></ion-icon> </ion-button>
      <ion-button size="small"> <ion-icon name="search-outline"></ion-icon> </ion-button>
      <ion-button size="small"> <ion-icon name="person-circle-outline"></ion-icon> </ion-button>
    </ion-buttons>
  </ion-toolbar>
  <ion-toolbar color="light">
    <div class="button-bar">
      <ion-button
        size="small"
        shape="round"
        *ngFor="let seg of segments; let i = index;"
        [ngClass]="{'activated': seg.selected, 'inactive': !seg.selected}"
        (click)="selectSegment(i)"
      >
        {{ seg.title }}
      </ion-button>
    </div>
  </ion-toolbar>
</ion-header>

Add this point it’s not a horizontal list, but we can make it a flex layout and scrollable quite fast by adding the following to our src/app/tab1/tab1.page.scss:

.button-bar {
  display: flex;
  overflow-x: scroll;
}

::-webkit-scrollbar {
  display: none;
}

.activated {
  --background: #606060;
  --color: #fff;
}

.inactive {
  --background: #edefef;
  --color: var(--ion-color-primary);
}

Additionally this hides the scrollbar which you can see in the preview normally!

Now the official YouTube app displays some placeholder images while its loading data, and we can mimic the same behaviour using the ion-skeleton-text element like this when we don’t have any video data in our array yet:

<ion-content>
  <div *ngIf="!videos.length">
    <div *ngFor="let i of [].constructor(4)" class="ion-margin-bottom">
      <ion-skeleton-text animated style="width: 100%; height: 30vh !important"></ion-skeleton-text>
      <ion-skeleton-text style="width: 75%; height: 20px !important; margin: 10px"></ion-skeleton-text>
      <ion-skeleton-text style="width: 40%; height: 20px !important; margin: 10px"></ion-skeleton-text>
    </div>
  </div>
</ion-content>

This will now show for a second because we used setTimeout() but I recommend you remove that delay while working on the video list for now.

Our next step is the list of video elements for which we need:

  • The preview image
  • The channel image
  • The title and author name
  • The duration floating above the video

On top of that we can add a simple refresher that calls the function we added before when we pull it!

Now we can generate those video items and use the Ionic grid layout to setup a row and different columns for the video information below the actual poster image.

The duration is on top of all those things, but we will have to reposition it with CSS to make it appear above the image in the next step.

For now you can add the following below the previous skeleton list:

<ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
  <ion-refresher-content refreshingSpinner="crescent" pullingIcon="refresh-outline"></ion-refresher-content>
</ion-refresher>

<div *ngFor="let video of videos" class="video ion-margin-bottom">
  <div class="duration">{{ video.duration * 1000 | date:'mm:ss' }}</div>

  <img [src]="'./assets/data/' + video.id + '.jpeg'" />
  <ion-row>
    <ion-col size="2" class="ion-align-items-center">
      <ion-avatar>
        <ion-img [src]="'./assets/data/' + video.id + '-channel.jpeg'"></ion-img>
      </ion-avatar>
    </ion-col>
    <ion-col size="8">
      <ion-text>{{ video.title }}</ion-text>
      <div>
        <ion-text color="medium" style="font-size: small"> {{ video.author }} · {{ video.views }} views · {{ video.ago }} ago </ion-text>
      </div>
    </ion-col>
    <ion-col size="2" class="ion-text-right">
      <ion-button size="small" fill="clear"><ion-icon name="ellipsis-vertical"></ion-icon></ion-button>
    </ion-col>
  </ion-row>
</div>

In reality those images will actually start playing, something I have implemented inside the Netflix app of the Built with Ionic book!

If we simply give the duration an absolute position, all duration elements would be stacked in one place as they use the position relative to the whole view.

To overcome this, we can set the position of the parent .video element to relative instead, which will make the duration start their position calculation based on the actual border of the parent element.

This is a really simple yet powerful construct to understand in order to position items correctly.

With that information go ahead and add the following to the src/app/tab1/tab1.page.scss:

.video {
  position: relative;
}

.duration {
  position: absolute;
  right: 15px;
  top: 175px;
  color: #fff;
  font-weight: 500;
  background: #000;
  padding: 4px;
}

ionic-yt-home-feed

And now we already got a fully functional home feed with videos and the right layout for all elements. including the previous header area we created.

Animated Header Bar

The one more thing of this tutorial is to implement the functionality to hide the header on scroll, and bring it back in when the user scrolls in the opposite direction again.

For this we will borrow some code from my previous Ionic Gmail clone and extend the code as we need even more functionality.

The idea is actually simple:

  • We listen to the scroll events of our content
  • We change the position of our header to move it out or in while scrolling

Additionally we also need to take care of the ion-content element as we need to reposition it as well. The best way to see how all of this comes together is actually watching the video (at least the important part) that’s linked at the bottom of this tutorial!

But let’s begin easy by adding the generated directive from the beginning to the src/app/directives/shared-directives.module.ts and making sure it is exported:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HideHeaderDirective } from './hide-header.directive';

@NgModule({
  declarations: [HideHeaderDirective],
  imports: [CommonModule],
  exports: [HideHeaderDirective],
})
export class SharedDirectivesModule {}

To use our directive we also need to import this now in our src/app/tab1/tab1.module.ts like this:

import { IonicModule } from '@ionic/angular';
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Tab1Page } from './tab1.page';
import { ExploreContainerComponentModule } from '../explore-container/explore-container.module';

import { Tab1PageRoutingModule } from './tab1-routing.module';
import { SharedDirectivesModule } from '../directives/shared-directives.module';

@NgModule({
  imports: [
    IonicModule,
    CommonModule,
    FormsModule,
    ExploreContainerComponentModule,
    Tab1PageRoutingModule,
    SharedDirectivesModule,
  ],
  declarations: [Tab1Page],
})
export class Tab1PageModule {}

Although we haven’t created the directive yet you can already apply it within the src/app/tab1/tab1.page.html and add a template reference to the header so we get access to that element later as well:

<ion-header #header>
</ion-header>

<ion-content [appHideHeader]="header" scrollEvents="true">
</ion-content>

The logic is based on some calculations and ideas:

  • We need to store the last Y position within saveY to notice in which direction we scroll
  • When we notice that we changed directions, we store that exact position inside previousY so we can use it for our calculation
  • We will change the top and opacity properties of our search bar
  • The scrollDistance is the value at which the element will be gone completely, which is different for iOS and Android../li>

On top of that we need to calculate the safe area at the top, because otherwise our component would still be slightly visible sometimes.

To achieve this, we can get the value of a CSS variable inside the ngAfterViewInit() by accessing the document and using getComputedStyle().

At that point we also set the ion-content to an absolute position with the right distance from top, as we can then later reposition it easily inside the logic that calculates the new value for the header and content element when we scroll.

I tried my best to add comments in all places to understand correctly what is calculated, so go ahead and change your src/app/directives/hide-header.directive.ts to this:

import { DOCUMENT } from '@angular/common';
import {
  AfterViewInit,
  Directive,
  ElementRef,
  HostListener,
  Inject,
  Input,
  Renderer2,
} from '@angular/core';
import { DomController, isPlatform } from '@ionic/angular';

enum Direction {
  downup = 1,
  down = 0,
}
@Directive({
  selector: '[appHideHeader]',
})
export class HideHeaderDirective implements AfterViewInit {
  @Input('appHideHeader') header: any;
  content: any;

  scrollDistance = isPlatform('ios') ? 88 : 112;
  previousY = 0;
  direction: Direction = Direction.down;
  saveY = 0;

  constructor(
    private renderer: Renderer2,
    private domCtrl: DomController,
    private elRef: ElementRef,
    @Inject(DOCUMENT) private document: Document
  ) {}

  @HostListener('ionScroll', ['$event']) onContentScroll($event: any) {
    // Skip some events that create ugly glitches
    if ($event.detail.currentY <= 0 || $event.detail.currentY === this.saveY) {
      return;
    }

    const scrollTop: number = $event.detail.scrollTop;
    let newDirection = Direction.down;

    // Calculate the distance from top based on the previousY
    // which is set when we change directions
    let newPosition = -scrollTop + this.previousY;

    // We are scrolling downup the page
    // In this case we need to reduce the position first
    // to prevent it jumping from -50 to 0
    if (this.saveY > $event.detail.currentY) {
      newDirection = Direction.downup;
      newPosition -= this.scrollDistance;
    }

    // Make our maximum scroll distance the end of the range
    if (newPosition < -this.scrollDistance) {
      newPosition = -this.scrollDistance;
    }

    const contentPosition = this.scrollDistance + newPosition;

    // Move and set the opacity of our element
    this.domCtrl.write(() => {
      this.renderer.setStyle(
        this.header,
        'top',
        Math.min(0, newPosition) + 'px'
      );

      this.renderer.setStyle(
        this.content,
        'top',
        Math.min(this.scrollDistance, contentPosition) + 'px'
      );
    });

    // Store the current Y value to see in which direction we scroll
    this.saveY = $event.detail.currentY;

    // If the direction changed, store the point of change for calculation
    if (newDirection !== this.direction) {
      this.direction = newDirection;
      this.previousY = scrollTop;
    }
  }

  ngAfterViewInit(): void {
    this.header = this.header.el;
    this.content = this.elRef.nativeElement;

    this.renderer.setStyle(this.content, 'position', `absolute`);
    this.renderer.setStyle(this.content, 'top', `${this.scrollDistance}px`);

    // Add the safe area top to completely fade out the header
    const safeArea = getComputedStyle(
      this.document.documentElement
    ).getPropertyValue('--ion-safe-area-top');

    const safeAreaValue = +safeArea.split('px')[0];
    this.scrollDistance = this.scrollDistance + safeAreaValue;
  }
}

All this results in a smooth hide and appear whenever we scroll our apps view – and you can easily reuse this directive in your own app as you just need to pass in the reference to the header element and the component will do the rest!

Teardown

We’ve done it and cloned another popular UI, but we haven’t finished the details page. If you are also interested in that UI and the gestures around the video player, leave a comment below.

And of course if you got a request for a future tutorial in the Built with Ionic series just let me know!

You can also find a video version of this tutorial below.

The post Building the YouTube UI with Ionic appeared first on Devdactic - Ionic Tutorials.

Celebrating 5 Years Ionic Academy

$
0
0

Exactly today the Ionic Academy started 5 years ago – and to kick off the anniversary celebration week I’m offering the initial monthly discount from 5 years ago again!

Only until the end of March 2022 you can grab the monthly Ionic Academy membership for just $19 instead of the usual $25:

Get the monthly Ionic Academy Membership for 25% off

That means you can get access to everything within the Academy for just $19 per month – that’s like paying 63 cents per day for Ionic support 🤯

I’ve also just released a video covering the evolution of the Ionic Academy and where it stands now (and in the future) so if you’ve been a member in the past, go check out what changed during those 5 years!

I’m beyond grateful for over 5000 developers that have gone through the Ionic Academy during that time, and I still love helping each new member just as much as 5 years ago.

Looking forward to seeing you inside soon!

The post Celebrating 5 Years Ionic Academy appeared first on Devdactic - Ionic Tutorials.

How to Secure your App with Ionic Identity Vault

$
0
0

If you are serious about your Ionic app development and want the most secure functionality when working with user data and APIs, you should take a closer look at Ionic Identity Vault.

Ionic Identity Vault is basically an all-in-one solution for managing sensitive data on your device.

  • Want to protect data when the app goes to background?
  • Want to easily show native biometric authentication dialogs?
  • Protect the privacy of your app when put to background?
  • Automatically log access to private data after time in background?

All of these things can be accomplished with other solutions, but Ionic Identity Vault combines 12 APIs into one plugin, so you get the best possible security for your enterprise data.

I could test Ionic Identity Vault for free as an Ionic community expert, but otherwise it’s a paid plugin for which you need a dedicated subscription.

ionic-identity-vault

The idea is basically to let Ionic do the hard work while you can rest assured that your data is protected in the best possible way.

Let’s create a simple Ionic app and test drive some Ionic Identity Vault functionality!

Starting a new Ionic App

We can start as always with a blank Ionic app and add one additional page and service:

ionic start identityApp blank --type=angular
cd ./identityApp

ionic g page login 
ionic generate service services/authVault

Before you add your native iOS and Android project you should also define your app ID inside the capacitor.config.json:

{
  "appId": "com.devdactic.identityapp",
  "appName": "Devdactic Identity",
  "webDir": "www",
  "bundledWebRuntime": false
}

Finally we can also change our routing so we start on the dummy login page first. For this, bring up the src/app/app-routing.module.ts and change it to:

import { NgModule } from '@angular/core';
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';

const routes: Routes = [
  {
    path: '',
    loadChildren: () =>
      import('./login/login.module').then((m) => m.LoginPageModule),
  },
  {
    path: 'home',
    loadChildren: () =>
      import('./home/home.module').then((m) => m.HomePageModule),
  },
];

@NgModule({
  imports: [
    RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules }),
  ],
  exports: [RouterModule],
})
export class AppRoutingModule {}

At this point you can also create the first build of your app and add the native platforms:

ionic build
ionic cap add android
ionic cap add ios

Now we can integrate Identity Vault into our app.

Ionic Identity Vault Integration

To install Identity Vault you need to use your enterprise key from your Ionic dashboard, which you need to assign to one of your apps first.

ionic-identity-vault-key

So before installing the actual Identity Vault package, run the register function and insert your Native Plugin Key if you never did this before:

# If you never did this
ionic enterprise register

npm install @ionic-enterprise/identity-vault

To use the biometric functionality we now also need to add the permission for iOS inside the ios/App/App/Info.plist:

<key>NSFaceIDUsageDescription</key>
<string>Use Face ID to authenticate yourself and login</string>

Now we are ready to use the package and integrate it in our app flow!

Creating a Vault Service

We begin by creating a service that interacts with the actual vault, so we have all data storing and locking mechanism in one central service and not scattered around our pages.

To get started we need a configuration to initialise our vault. In here we define how the vault behaves:

  • The key identifies the vault, and you could have multiple vaults with different keys
  • The type defines how your data is protected and stored on the device
  • The deviceSecurityType defines which authentication is used to unlock the vault

Additionally there are some more settings which are pretty easy to understand, and you can find all possible values in the Identity Vault docs as well!

On top of that we keep track of the state or session with another variable, so we keep the data in memory as well. This is optional, but helps to make working with the stored data easier as you don’t need to make a call to retrieve a value from the vault all the time while the app is unlocked!

Since Identity Vault 5 there’s also a great fallback for the web which doesn’t exactly work like the native implementation, but it allows us to build the app more easily on the browser. For this, we create the right type of Vault in our constructor with a simple check.

Let’s begin our service now by changing the src/app/servies/auth-vault.service.ts to:

import { Injectable, NgZone } from '@angular/core';
import { Capacitor } from '@capacitor/core';
import {
  BrowserVault,
  Device,
  DeviceSecurityType,
  IdentityVaultConfig,
  Vault,
  VaultType,
} from '@ionic-enterprise/identity-vault';
import { NavController } from '@ionic/angular';

const DATA_KEY = 'mydata';
const TOKEN_KEY = 'mytoken';

@Injectable({
  providedIn: 'root',
})
export class AuthVaultService {
  vault: Vault | BrowserVault;

  config: IdentityVaultConfig = {
    key: 'com.devdactic.myvault',
    type: VaultType.DeviceSecurity,
    deviceSecurityType: DeviceSecurityType.Both,
    lockAfterBackgrounded: 100,
    shouldClearVaultAfterTooManyFailedAttempts: true,
    customPasscodeInvalidUnlockAttempts: 2,
    unlockVaultOnLoad: false,
  };

  state = {
    isLocked: false,
    privateData: '',
  };

  constructor(private ngZone: NgZone, private navCtrl: NavController) {
    this.vault =
      Capacitor.getPlatform() === 'web'
        ? new BrowserVault(this.config)
        : new Vault(this.config);
    this.init();
  }
}

After this setup we need to define some more things, especially how the vault behaves when we lock or unlock it later.

Those are the points where you clear the stored data in memory, or set the value by using the getValue() method of the vault to read stored data, just like you would do with Ionic Storage!

We also got access to a special Device object from the Identity Vault package through which we can trigger or define specific native functionalities. In our case, we define that whenever our app is put in the background, it will show the splash screen instead of a screenshot from the app.

This is another level of privacy so you can’t see the content of the app when going through the app windows on your device!

Continue with the service and add the following function now:

async init() {
  this.state.isLocked = await this.vault.isLocked();

  Device.setHideScreenOnBackground(true);

  // Runs when the vault is locked
  this.vault.onLock(() => {
    this.ngZone.run(() => {
      this.state.isLocked = true;
      this.state.privateData = undefined;
    });
  });

  // Runs when the vault is unlocked
  this.vault.onUnlock(() => {
    this.ngZone.run(async () => {
      this.state.isLocked = false;
      this.state.privateData = await this.vault.getValue(DATA_KEY);
    });
  });
}

Finally we need some more helper functionalities in order to test our vault from our pages.

We can directly call the lock() or unlock() methods, which in response will trigger the events we defined before to clear or read the data.

On a possible login we will also directly store a token – one of the most common operations you would use vault for as this makes sure any kind of token that was issued by your server is stored safely.

By using isEmpty() we can check if there are any values in the vault even without authentication, which will be used later to automatically log in users if there is any stored data (which might require a bit more logic to check the value in a real world scenario).

If we log out the user, we would also clear() all data from the vault and our state object so there is no piece of sensitive information left anywhere when the user really ends the session!

Now finish our service by adding these additional functions:

lockVault() {
  this.vault.lock();
}

unlockVault() {
  this.vault.unlock();
}

async login() {
  // Store your session token upon successful login
  return this.vault.setValue(TOKEN_KEY, 'JWT-value-1123');
}

async setPrivateData(data: string) {
  await this.vault.setValue(DATA_KEY, data);
  this.state.privateData = data;
}

async isEmpty() {
  return this.vault.isEmpty();
}

async logout() {
  // Remove all stored data
  this.state.privateData = undefined;
  await this.vault.clear();

  this.navCtrl.navigateRoot('/', { replaceUrl: true });
}

With all of this in place we can finally build our pages and run through the different functions.

Creating the Dummy Login

We are not using a real API in this example, but you could easily imagine another call to your server with your users credentials upon which your app receives some kind of token.

This token would be stored inside the vault, and if we enter the login page and notice that there is data inside the vault, we can directly try to unlock the vault which will trigger the defined biometric/passcode authentication and skip to the inside area of our app.

Again, this is a bit simplified as you might have other information inside the vault next to the actual token for your API, or you might have to get a new access token based on the refresh token in your app.

Bring up the src/app/login/login.page.ts and insert our dummy login functions:

import { Component, OnInit } from '@angular/core';
import { NavController } from '@ionic/angular';
import { AuthVaultService } from '../servies/auth-vault.service';

@Component({
  selector: 'app-login',
  templateUrl: './login.page.html',
  styleUrls: ['./login.page.scss'],
})
export class LoginPage implements OnInit {
  state = {
    email: '',
    password: '',
  };

  constructor(
    private authVaultService: AuthVaultService,
    private navCtrl: NavController
  ) {}

  async ngOnInit() {}

  async ionViewWillEnter() {
    // Check if we have data in our vault and skip the login
    const isEmpty = await this.authVaultService.isEmpty();

    if (!isEmpty) {
      await this.authVaultService.unlockVault();
      this.navCtrl.navigateForward('/home', { replaceUrl: true });
    }
  }

  async login() {
    await this.authVaultService.login();
    this.navCtrl.navigateForward('/home', { replaceUrl: true });
  }
}

The template for this page will be very minimal so we can just trigger the login, the fields are actually not really necessary in our dummy.
Anyway, open the src/app/login/login.page.html and change it to:

<ion-header>
  <ion-toolbar>
    <ion-title>Login</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-item>
    <ion-label position="floating">E-Mail</ion-label>
    <ion-input type="email" [(ngModel)]="state.email"></ion-input>
  </ion-item>
  <ion-item>
    <ion-label position="floating">Password</ion-label>
    <ion-input type="password" [(ngModel)]="state.password"></ion-input>
  </ion-item>
  <ion-button (click)="login()" expand="full">Login</ion-button>
</ion-content>

Now we can already move to the inside area, and here we want to interact a bit more with our vault.

Working with identity Vault

At this point we want to show the secret data that a user might have inside the store, so we create a connection to the state object of our service and display the values which it contains in our template next.

Besides that we just need functions to call the according methods of our service, so let’s prepare the src/app/home/home.page.ts like this:

import { Component } from '@angular/core';
import { AuthVaultService } from '../servies/auth-vault.service';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})
export class HomePage {
  myInput = '';
  state = undefined;

  constructor(private authVaultService: AuthVaultService) {
    this.state = this.authVaultService.state;
  }

  async logout() {
    await this.authVaultService.logout();
  }

  savePrivateData() {
    this.authVaultService.setPrivateData(this.myInput);
  }

  lockVault() {
    this.authVaultService.lockVault();
  }

  unlockVault() {
    this.authVaultService.unlockVault();
  }
}

Now we need to react to the isLocked state of our service, which is updated whenever the vault is locked or unlocked.

Therefore we display either a lock or an unlock button first of all, which will help us to unlock the vault if it’s currently locked.

We could also define an automatic unlock behaviour by setting the unlockVaultOnLoad flag inside the vault config to true instead!

Below that we display a little input field so we can type some data and then store it away in our vault, plus a card that shows the current value of that field from the state object.

Wrap up our app by changing the src/app/home/home.page.html to this:

<ion-header>
  <ion-toolbar>
    <ion-title> Home </ion-title>
    <ion-buttons slot="end">
      <ion-button (click)="logout()">
        <ion-icon slot="icon-only" name="log-out"></ion-icon>
      </ion-button>
    </ion-buttons>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-button
    expand="full"
    (click)="unlockVault()"
    *ngIf="state.isLocked"
    color="success"
  >
    <ion-icon name="lock-open-outline" slot="start"></ion-icon>
    Unlock vault</ion-button
  >
  <ion-button
    expand="full"
    (click)="lockVault()"
    *ngIf="!state.isLocked"
    color="warning"
  >
    <ion-icon name="lock-closed-outline" slot="start"></ion-icon>
    Lock vault</ion-button
  >

  <div *ngIf="!state.isLocked">
    <ion-item>
      <ion-label position="floating">Private</ion-label>
      <ion-textarea [(ngModel)]="myInput"></ion-textarea>
    </ion-item>
    <ion-button expand="full" (click)="savePrivateData()">Save data</ion-button>

    <ion-card>
      <ion-card-content> {{ state.privateData }}</ion-card-content>
    </ion-card>
  </div>
</ion-content>

Now we got our simply vault in place and we can play around with it to see how the page and app reacts to locking the vault, putting the app in the background for some time or even killing the app!

Teardown

Ionic Identity Vault is a great solution that combines a ton of different APIs and plugins to make storing secure and sensitive data inside your Ionic application a breeze.

However, it comes with a decent price so it might only appeal to enterprise customers at this point, but if you care about the privacy of your users and API, investing this money should be a no brainer.

In the end, Identity Vault will make your life a lot easier, protect your data in the best possible way and do all of that with a simple API so you don’t need to piece together different plugins and somehow build your own solution.

You can also see a detailed explanation of this tutorial in the video below!

The post How to Secure your App with Ionic Identity Vault appeared first on Devdactic - Ionic Tutorials.

How to Write Unit Tests for your Ionic Angular App

$
0
0

Did you ever wonder what the *.spec file that is automatically generated for your pages and services is useful for? Then this tutorial is exactly for you!

The spec file inside your Angular project describes test cases, more specific unit tests for specific functionalities (“units”) of your code.

Running the tests is as easy as writing one command, but writing the tests looks a bit different and requires some general knowledge.

In our Ionic Angular app, everything is set up automatically to use Jasmine as a behaviour driven testing framework that gives us the tool to write easy test cases.

On top of that our tests are executed with the help of Karma, a test runner that runs our defined cases inside a browser window.

Why Unit Tests?

We have one spec file next to every page, component, service or whatever you generate with the Ionic CLI.

The reason is simple: We want to test a specific piece of code within a unit test, and not how our whole system operates!

That means, we should test the smallest possible unit (usually a function) within unit tests, which in the end results in a code coverage percentage that describes how much of our code is covered in unit tests.

In the end, this also means you can rely on those functions to be correct – or if you want to use Test Driven Development (TDD) you could come up with the tests first to describe the behaviour of your app, and then implement the actual code.

However you approach it, writing unit tests in your Ionic Angular application makes your code less prone to errors in the future when you change it, and removes any guessing about whether it works or not.

Enough of the theory, let’s dive into some code!

Creating an Ionic App for testing

We start with a blank new Ionic app and add a few services and a page so we can take a look at different test cases:

ionic start devdacticTests blank --type=angular
cd ./devdacticTests

ionic g page list
ionic g service services/api
ionic g service services/data
ionic g service services/products

npm install @capacitor/storage

I’ve also installed the Capacitor storage plugin so we can test a real world scenario with dependencies to another package.

Now you can already run the tests, which will automatically update when you change your code:

npm test

This will run the test script of your package.json and open a browser when ready!

ionic-jasmine-tests

We will go through different cases and increase the difficulty along the way while adding the necessary code that we can test.

Testing a Simple Service

The easiest unit test is definitely for a service, as by default the service functionalities usually have a defined scope.

Begin by creating a simple dummy service like this inside the src/app/services/data.service.ts:

import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class DataService {
  constructor() {}

  // Basic Testing
  getTodos(): any[] {
    const result = JSON.parse(localStorage.getItem('todos'));
    return result || [];
  }
}

We can now test whether the function returns the right elements by changing local storage from our test cases.

Before we get into the actual cases, we need to understand the Angular TestBed: This is almost like a ngModule, but this one is only for testing like a fake module.

We create this module beforeEach so before every test case runs, and we call the inject function to add the service or class we want to test. Later inside pages this will come with some more settings, but for a single service that’s all we need at this point.

Test cases are written almost as you would speak: it should do something and we expect a certain result.

Let’s go ahead and change the src/app/services/data.service.spec.ts to this now:

import { TestBed } from '@angular/core/testing';
import { DataService } from './data.service';

describe('DataService', () => {
  let service: DataService;

  beforeEach(() => {
    TestBed.configureTestingModule({});
    service = TestBed.inject(DataService);
  });

  afterEach(() => {
    service = null;
    localStorage.removeItem('todos');
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  it('return an empty array', () => {
    expect(service.getTodos()).toEqual([]);
  });

  it('return an array with one object', () => {
    const arr = ['First Todo'];
    localStorage.setItem('todos', JSON.stringify(arr));

    expect(service.getTodos()).toEqual(arr);
    expect(service.getTodos()).toHaveSize(1);
  });

  it('return the correct array size', () => {
    const arr = [1, 2, 3, 4, 5];
    localStorage.setItem('todos', JSON.stringify(arr));

    expect(service.getTodos()).toHaveSize(arr.length);
  });
});

We added three cases, in which we test the getTodos() of our service.

There are several Jasmine matchers and we have only used a few of them to compare the result that we get from the service to a value we expect.

At this point you should see the new cases inside your browser window (if you haven’t started the test command, go back to the beginning), and all of them should be green and just fine.

This is not what I recommend!

If your tests don’t fail in the first place, you can never be sure that you wrote them correctly. They could be green because you made a mistake and they don’t really work like they should. Therefore:

Always make your tests fail first, then add the expected value!

Testing a Service with Promises

The previous case was pretty easy with synchronous functions, but that’s very rarely the reality unless you develop a simple calculator for your CV.

Now we add some more dummy code to the src/app/services/api.service.ts so we can also test asynchronous operations:

import { Injectable } from '@angular/core';
import { Storage } from '@capacitor/storage';

@Injectable({
  providedIn: 'root',
})
export class ApiService {
  constructor() {}

  async getStoredTodos(): Promise<any[]> {
    const data = await Storage.get({ key: 'mytodos' });

    if (data.value && data.value !== '') {
      return JSON.parse(data.value);
    } else {
      return [];
    }
  }

  async addTodo(todo) {
    const todos = await this.getStoredTodos();
    todos.push(todo);
    return await Storage.set({ key: 'mytodos', value: JSON.stringify(todos) });
  }

  async removeTodo(index) {
    const todos = await this.getStoredTodos();
    todos.splice(index, 1);
    return await Storage.set({ key: 'mytodos', value: JSON.stringify(todos) });
  }
}

Again, just testing this service in isolation is quite easy as we can simply await the results of those calls just like we would do when we call them inside a page.

Go ahead and change the src/app/services/api.service.spec.ts to include some new test cases that handle a Promise:

import { TestBed } from '@angular/core/testing';

import { ApiService } from './api.service';
import { Storage } from '@capacitor/storage';

describe('ApiService', () => {
  let service: ApiService;

  beforeEach(async () => {
    TestBed.configureTestingModule({});
    service = TestBed.inject(ApiService);
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  afterEach(async () => {
    await Storage.clear();
    service = null;
  });

  it('should return an empty array', async () => {
    const value = await service.getStoredTodos();
    expect(value).toEqual([]);
  });

  it('should return the new item', async () => {
    await service.addTodo('buy milk');
    const updated = await service.getStoredTodos();
    expect(updated).toEqual(['buy milk']);
  });

  it('should remove an item', async () => {
    await service.addTodo('buy milk');
    await service.addTodo('buy coffee');
    await service.addTodo('buy ionic');

    const updated = await service.getStoredTodos();
    expect(updated).toEqual(['buy milk', 'buy coffee', 'buy ionic']);

    await service.removeTodo(1);

    const newValue = await service.getStoredTodos();
    expect(newValue).toEqual(['buy milk', 'buy ionic']);
  });
});

Again, it’s easy in this case without any dependencies or long running operations, but we already have a dependency to Capacitor Storage which does work fine, but imagine a usage of the camera – there is no camera when you test!

In those cases you could inject plugin mocks for different services to encapsulate the behaviour and make sure you are testing this specific function without outside dependencies!

Testing a Basic Ionic Page

Now we move on to testing an actual page, so let’s change our src/app/home/home.page.ts in order for it to have at least one function to test:

import { Component } from '@angular/core';
import { DataService } from '../services/data.service';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})
export class HomePage {
  todos = [];

  constructor(private dataService: DataService) {}

  loadTodos() {
    this.todos = this.dataService.getTodos();
  }
}

The setup for the test is now a bit longer, but the default code already handles the injection into the TestBed for us.

We now also create a fixture element, which has a reference to both the class and the template!

Therefore we are able to extract the component from this fixture after injecting it with createComponent().

Our test cases itself are pretty much the same, as we simply call the function of the page and fake some values for storage.

Go ahead with the src/app/home/home.page.spec.ts and add this now:

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;

  beforeEach(waitForAsync(() => {
    TestBed.configureTestingModule({
      declarations: [HomePage],
      imports: [IonicModule.forRoot()],
    }).compileComponents();

    fixture = TestBed.createComponent(HomePage);
    component = fixture.componentInstance;
    fixture.detectChanges();
  }));

  afterEach(() => {
    localStorage.removeItem('todos');
    component = null;
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('get an empty array', () => {
    component.loadTodos();
    expect(component.todos).toEqual([]);
  });

  it('set an array with objects', () => {
    const arr = [1, 2, 3, 4, 5];
    localStorage.setItem('todos', JSON.stringify(arr));
    component.loadTodos();
    expect(component.todos).toEqual(arr);
    expect(component.todos).toHaveSize(arr.length);
  });
});

This is once again a very simplified test, and there’s something we need to be careful about:

We want to test the page, not the service – therefore we should in reality fake the behaviour of the service and the value that is returned.

And we can do this using a spy, but before we get into that let’s quickly venture almost into end to end testing…

Testing Pages with Ionic UI elements

As said before, we can access both the class and the template from our fixture element – that means we can also query view elements from our unit test!

To try this, let’s work on our second page and change the src/app/list/list.page.ts to this:

import { Component, OnInit } from '@angular/core';
import { ApiService } from '../services/api.service';

@Component({
  selector: 'app-list',
  templateUrl: './list.page.html',
  styleUrls: ['./list.page.scss'],
})
export class ListPage implements OnInit {
  todos = [];

  constructor(private apiService: ApiService) {}

  ngOnInit() {
    this.loadStorageTodos();
  }

  async loadStorageTodos() {
    this.todos = await this.apiService.getStoredTodos();
  }
}

Additionally we create a very simple UI with a card for an empty list or an iteration of all the items like this inside the src/app/list/list.page.html:

<ion-header>
  <ion-toolbar>
    <ion-title>My List</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-card *ngIf="!todos.length">
    <ion-card-content> No todos found </ion-card-content>
  </ion-card>

  <ion-list *ngIf="todos.length > 0">
    <ion-item *ngFor="let t of todos">
      <ion-label>{{ t }}</ion-label>
    </ion-item>
  </ion-list>
</ion-content>

In our tests we can now access the debugElement of the fixture and run different queries against it to see if certain UI elements are present, or even which text exists inside them!

Let’s do this by changing our src/app/list/list.page.spec.ts to this:

import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { IonCard, IonicModule, IonItem } from '@ionic/angular';

import { ListPage } from './list.page';

describe('ListPage', () => {
  let component: ListPage;
  let fixture: ComponentFixture<ListPage>;

  beforeEach(waitForAsync(() => {
    TestBed.configureTestingModule({
      declarations: [ListPage],
      imports: [IonicModule.forRoot()],
    }).compileComponents();

    fixture = TestBed.createComponent(ListPage);
    component = fixture.componentInstance;
    fixture.detectChanges();
  }));

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('should show a card if we have no todos', () => {
    const el = fixture.debugElement.query(By.directive(IonCard));
    expect(el).toBeDefined();
    expect(el.nativeNode.textContent.trim()).toBe('No todos found');
  });

  it('should show todos after setting them', () => {
    const arr = [1, 2, 3, 4, 5];

    let el = fixture.debugElement.query(By.directive(IonCard));
    expect(el).toBeDefined();
    expect(el.nativeNode.textContent.trim()).toBe('No todos found');

    component.todos = arr;

    // Important!
    fixture.detectChanges();

    el = fixture.debugElement.query(By.directive(IonCard));
    expect(el).toBeNull();

    const items = fixture.debugElement.queryAll(By.directive(IonItem));
    expect(items.length).toBe(arr.length);
  });
});

And there’s again something to watch out for in our second test case: We need to trigger the change detection manually!

Normally the view of your app updates when the data changes, but that’s not the case inside a unit test.

In our case, we set the array of todos inside the page, and therefore expect that the view now shows a list of IonItem nodes.

However, this only happens after we call detectChanges() on the fixture, so be careful when you access any DOM elements like this.

Overall I don’t think you should have massive UI tests in your unit tests. You can test your Ionic app more easily using Cypress end to end tests!

Testing Pages with Spy

Now we are coming back to the idea from before that we should actually fake the return values of our service to minimize any external dependencies.

The idea is to create a spy for a specific function of our service, and define which result will be returned. When we now call the function of our page that uses the getStoredTodos() from a service, the test will actually use the spy instead of the real service!

That means, we don’t need to worry about the service dependency anymore at this point!

We continue with the testing file for our list from before and now take a look at three different ways to handle asynchronous code using a spy:

  • Use the Jasmine done() callback to end a Promise
  • Run our code inside the waitForAsync() zone and use whenStable()
  • Run our code inside the fakeAsync() zone and manually trigger a tick()

Let’s see the code for this first of all by changing the src/app/list/list.page.spec.ts to this (I simply removed the UI tests):

import {
  ComponentFixture,
  fakeAsync,
  TestBed,
  tick,
  waitForAsync,
} from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { IonCard, IonicModule, IonItem } from '@ionic/angular';
import { ApiService } from '../services/api.service';
import { ListPage } from './list.page';

describe('ListPage', () => {
  let component: ListPage;
  let fixture: ComponentFixture<ListPage>;
  let service: ApiService;

  beforeEach(waitForAsync(() => {
    TestBed.configureTestingModule({
      declarations: [ListPage],
      imports: [IonicModule.forRoot()],
    }).compileComponents();

    fixture = TestBed.createComponent(ListPage);
    component = fixture.componentInstance;
    fixture.detectChanges();

    service = TestBed.inject(ApiService);
  }));

  it('should load async todos', (done) => {
    const arr = [1, 2, 3, 4, 5];
    const spy = spyOn(service, 'getStoredTodos').and.returnValue(
      Promise.resolve(arr)
    );
    component.loadStorageTodos();

    spy.calls.mostRecent().returnValue.then(() => {
      expect(component.todos).toBe(arr);
      done();
    });
  });

  it('waitForAsync should load async todos', waitForAsync(() => {
    const arr = [1, 2, 3];
    const spy = spyOn(service, 'getStoredTodos').and.returnValue(
      Promise.resolve(arr)
    );
    component.loadStorageTodos();

    fixture.whenStable().then(() => {
      expect(component.todos).toBe(arr);
    });
  }));

  it('fakeAsync should load async todos', fakeAsync(() => {
    const arr = [1, 2];
    const spy = spyOn(service, 'getStoredTodos').and.returnValue(
      Promise.resolve(arr)
    );
    component.loadStorageTodos();
    tick();
    expect(component.todos).toBe(arr);
  }));
});

The first way is basically the default Jasmine way, and the other two are more Angular like.

Both of them are just fine, the waitForAsync simply waits until all Promises are finished and then we can run our matcher.

In the fakeAsync we manually trigger the passage of time, and the code flow now looks more like when you are using async/await.

Feel free to try both and use the one you feel more comfortable with!

PS: You could already inject the spy directly into the TestBed to define functions that the spy will mock!

Testing Services with Http Calls

Alright we increased the complexity and difficulty along this tutorial and now reach the end, which I’m pretty sure you were waiting for!

To test HTTP calls we first of all need to write some code that actually performs a call, so begin by updating the src/app/app.module.ts to inject the HttpClientModule as always (this is not related to the actual test case!):

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 { HttpClientModule } from '@angular/common/http';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [
    BrowserModule,
    IonicModule.forRoot(),
    AppRoutingModule,
    HttpClientModule,
  ],
  providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
  bootstrap: [AppComponent],
})
export class AppModule {}

Now bring up the src/app/services/products.service.ts and add this simple HTTP call:

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class ProductsService {
  constructor(private http: HttpClient) {}

  getProducts() {
    return this.http.get('https://fakestoreapi.com/products');
  }
}

To test this function, we ned to create a whole HTTP testing environment because we don’t want to perform an actual HTTP call inside our test! This would take time and change your backend, and we just want to make sure the function sends out the call and returns some kind of data that we expect from the API.

To get started we now need to import the HttpClientTestingModule in our TestBed, and also inject the HttpTestingController to which we keep a reference.

Now we can define a mockResponse that will be sent back from our fake HTTp client, and then simply call the according getProducts() like we would normally and handle the Observable.

Inside the subscribe block we can compare the result we get to our mock response, because that’s what we will actually receive in this test case!

How?

The magic is in the lines below it, but let’s add the code to our src/app/services/products.service.spec.ts first of all:

import { TestBed } from '@angular/core/testing';

import { ProductsService } from './products.service';
import {
  HttpClientTestingModule,
  HttpTestingController,
} from '@angular/common/http/testing';
import { HttpClient } from '@angular/common/http';

describe('ProductsService', () => {
  let service: ProductsService;
  let httpClient: HttpClient;
  let httpTestingController: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
    });
    service = TestBed.inject(ProductsService);
    httpClient = TestBed.inject(HttpClient);
    httpTestingController = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpTestingController.verify();
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  it('should make an API call', () => {
    const mockResponse = [
      {
        id: 1,
        title: 'Simons Product',
        price: 42.99,
        description: 'Epic product test',
      },
    ];

    service.getProducts().subscribe((res) => {
      expect(res).toBeTruthy();
      expect(res).toHaveSize(1);
      const product = res[0];
      expect(product).toBe(mockResponse[0]);
    });

    const mockRequest = httpTestingController.expectOne(
      'https://fakestoreapi.com/products'
    );

    expect(mockRequest.request.method).toEqual('GET');

    // Resolve with our mock data
    mockRequest.flush(mockResponse);
  });
});

We can create a fake request using the httpTestingController and already add one expectation about the URL that should be called and the request method.

Finally we can let the client return our mockResponse by calling the flush() function.

So what’s happening under the hood?

  • The getProducts() from our service is called
  • The function wants to make an HTTP call to the defined URL
  • The HTTP testing module intercepts this call
  • The HTTP testing controller returns some fake data
  • The getProducts() returns this data thinking it made a real API call
  • We can compare the mock response to the result of the service function!

It’s all a bit tricky, but a great way to even test the API call functions of your app.

Get you Ionic Test Code Coverage

Finally if you’re interested in metrics or want to present them for your team, you should run the following command:

ng test --no-watch --code-coverage

This will generate a nice code coverage report in which you can see how much of your code is covered by tests.
ionic-angular-code-coverage

In our simple example we managed to get 100% coverage – how much will your company get?

You can also find a video version of this tutorial below.

The post How to Write Unit Tests for your Ionic Angular App appeared first on Devdactic - Ionic Tutorials.

Viewing all 183 articles
Browse latest View live