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

The Push Notifications Guide for Ionic & Capacitor

$
0
0

To send push notifications to your users with Ionic and Capacitor you don’t need a specific service, but you need to configure a bunch of things upfront to make it work.

In this tutorial we will integrate Push Notifications using Firebase, since Firebase is basically required for Android anyway and you can easily use it to send our notifications, even without using the database at all!

ionic-capacitor-push

Another way to integrate push would be to use a service like OneSignal, which is a great alternative as well!

Ionic Capacitor Setup

First of all we will create the Ionic app with Capacitor enabled and directly specify our package id which is the ID you usually used within your config with Cordova, and what you use within your iOS Developer Portal and for Android as well. It’s basically the unique identifier for your app.

We can also build our app and add the native platforms already since we need to work on them in the next steps, so go ahead and run:

ionic start pushApp blank --type=angular --capacitor --package-id=com.devdactic.devpush
cd ./pushApp
ionic build
npx cap add ios
npx cap add android

If you already have an app you can also simply change the capacitor.config.json to include your appId (which is automatically set with our command above), but if your native folders already exist you would have to replace the id in all files where it appears as well since Capacitor only creates the folder once, and won’t update the id itself!

Inside the capacitor.config.json you can also specify to update the badge count of your app, play sound on push and show an alert when a notification arrives, so that’s what we specify additionally inside the plugins block:

{
  "appId": "com.devdactic.devpush",
  "appName": "pushApp",
  "bundledWebRuntime": false,
  "npmClient": "npm",
  "webDir": "www",
  "plugins": {
    "SplashScreen": {
      "launchShowDuration": 0
    },
    "PushNotifications": {
      "presentationOptions": ["badge", "sound", "alert"]
    }
  },
  "cordova": {}
}

Let’s continue with our push configuration outside the app now.

Firebase Configuration

Get started by creating a new Firebase project or use an existing project that you already created for your app.

Simply specify a name and the default options to get started with a new project.

If you have a blank new app you should now see “Get started by adding Firebase to your app” in the dashboard of your app, otherwise you can also simply click the gear icon and move to the project settings from where you can also add an app.

The dialog looks basically the same for both iOS and Android like in the image below, and the only import thing here is to use your package id for the apps!

firebase-app-setup-ios

After the initial step you can download a file, which you can for now simply download anywhere. The files are:

  • google-services.json file for Android
  • GoogleService-info.plist file for iOS

Now we can configure our two platforms, for which one is a lot easier..

Android Push Preparation

There is only one thing to do for Android, and that’s moving the google-services.json that you downloaded to the android/app/ folder like in the image below.

android-push-file

Really that’s all, you could now start to send out push notifications to Android devices but we also want iOS, which takes a bit more time.

iOS Push Preparation

This part is going to be a lot more complicated. First of all, you need to create an App ID for your app within the identifiers list of your Apple Developer account.

Maybe you’ve already done this for your app, if not simply add an app and make sure you select the Push Notifications capability from the list!

ionic-ios-push-id

The Bundle ID here should be what you used before as your App ID within Capacitor and Firebase.

Now you could create a Certificate for push notifications, but the easier way is actually to create a Key instead.

So create a new key and enable the Apple Push Notifications service (APNs). If you have already reached the maximum amount of keys, you can also use an existing key or use a certificate instead, but the process then is a bit more complicated.

ios-developer-push-key

After downloading this .p8 file, we need to upload it to Firebase.

To do so, open the Cloud Messaging tab inside your Firebase project settings, upload the file and enter the details for the Key ID (which is already inside the name of the file) and your Team ID from iOS (you can find it usually in the top right corner below your name).

firebase-upload-ios-key

Now we are done in the developer portal and Firebase and need to apply some changes to our Xcode project, so simply run the following to open it:

npx cap open ios

First of all we need to copy our GoogleService-Info.plist that we downloaded in the beginning from firebase into our iOS project, and you should drag the file into the Xcode project right inside the app/app folder like in the image below.

ios-capacitor-push

It’s important to do this inside Xcode and select Copy items if needed so the file is really added to your target.

Next step is adding a new Pod, which is basically like an additional dependency for our iOS project. Therefore, open the ios/App/Podfile and within the existing block add the highlighted line below the comment:

target 'App' do
  capacitor_pods
  # Add your Pods here
  pod 'Firebase/Messaging'
end

This makes sure the Firebase Pod is installed, and to perform the installation we need to update our native platform with this command:

npx cap update ios

Apparently that’s not all, we also need to change a bit of the native Swift code in order to register with Firebase and to return the correct token to our app.

Therefore, open the ios/App/App/AppDelegate.swift and apply the following changes:

import UIKit
import Capacitor
import Firebase

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

  var window: UIWindow?


  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Override point for customization after application launch.
    FirebaseApp.configure()
    return true
  }

  // All the existing functions
  // ...

  // Update this one:
    func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        Messaging.messaging().apnsToken = deviceToken
        InstanceID.instanceID().instanceID { (result, error) in
            if let error = error {
                NotificationCenter.default.post(name: Notification.Name(CAPNotifications.DidFailToRegisterForRemoteNotificationsWithError.name()), object: error)
            } else if let result = result {
                NotificationCenter.default.post(name: Notification.Name(CAPNotifications.DidRegisterForRemoteNotificationsWithDeviceToken.name()), object: result.token)
            }
        }
    }
}

The changes are basically:

  1. Import Firebase in the file
  2. Call the configure function when the app starts
  3. Return the token correctly to our app after registration with Firebase

The last part is achieve by overriding one of the existing functions that you already have in that file at the bottom!

Now the last step is to add the Capability for Push Notifications within your Xcode project, so open the Signing & Capabilities tab and add it.

capacitor-xcode-capability

That’s everything for our iOS configuration – a lot more than Android but now we can finally build our app!

Ionic Push Notification Integration

Inside our app we want to perform all the push logic inside a service, and we will also add another page so we can implement some deeplink behaviour with push notifications for which we don’t really need any additional plugin!

Go ahead and run inside your Ionic project:

ionic g service services/fcm
ionic g page pages/details

To include our new page in the routing, we will change the default entry to contain a dynamic id inside the path like this inside our app/app-routing.module.ts:

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: '',
    redirectTo: 'home',
    pathMatch: 'full'
  },
  {
    path: 'home/:id',
    loadChildren: () => import('./pages/details/details.module').then( m => m.DetailsPageModule)
  },
];

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

The service we created takes care of asking for permission to send a push, registers our device and catches a bunch of events with additional listeners.

In detail, these listeners are:

  • registration: Executed after a successful registration, and the place where you receive back the token of the device to which you can send a push
  • registrationError: Shouldn’t happen and indicates something went wrong!
  • pushNotificationReceived: Triggered whenever a notification was catched from your app
  • pushNotificationActionPerformed: Called when a user really taps on a notification when it pops up or from the notification center

Go ahead now with our services/fcm.service.ts:

import { Injectable } from '@angular/core';
import {
  Plugins,
  PushNotification,
  PushNotificationToken,
  PushNotificationActionPerformed,
  Capacitor
} from '@capacitor/core';
import { Router } from '@angular/router';

const { PushNotifications } = Plugins;

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

  constructor(private router: Router) { }

  initPush() {
    if (Capacitor.platform !== 'web') {
      this.registerPush();
    }
  }

  private registerPush() {
    PushNotifications.requestPermission().then((permission) => {
      if (permission.granted) {
        // Register with Apple / Google to receive push via APNS/FCM
        PushNotifications.register();
      } else {
        // No permission for push granted
      }
    });

    PushNotifications.addListener(
      'registration',
      (token: PushNotificationToken) => {
        console.log('My token: ' + JSON.stringify(token));
      }
    );

    PushNotifications.addListener('registrationError', (error: any) => {
      console.log('Error: ' + JSON.stringify(error));
    });

    PushNotifications.addListener(
      'pushNotificationReceived',
      async (notification: PushNotification) => {
        console.log('Push received: ' + JSON.stringify(notification));
      }
    );

    PushNotifications.addListener(
      'pushNotificationActionPerformed',
      async (notification: PushNotificationActionPerformed) => {
        const data = notification.notification.data;
        console.log('Action performed: ' + JSON.stringify(notification.notification));
        if (data.detailsId) {
          this.router.navigateByUrl(`/home/${data.detailsId}`);
        }
      }
    );
  }
}

Inside the pushNotificationActionPerformed listener you can now also see our deeplinkish behaviour: When the payload of the push notification contains some data that we loog for (in this case a detailsId) we will use the router to navigate to a specific page and pass the information in the URL that we created previously.

This service has basically only one public function that we can call from the outside, which will trigger all the permission and registration, and for us a good place is the app/app.component.ts:

import { Component } from '@angular/core';

import { Platform } from '@ionic/angular';
import { SplashScreen } from '@ionic-native/splash-screen/ngx';
import { StatusBar } from '@ionic-native/status-bar/ngx';
import { FcmService } from './services/fcm.service';

@Component({
  selector: 'app-root',
  templateUrl: 'app.component.html',
  styleUrls: ['app.component.scss']
})
export class AppComponent {
  constructor(
    private platform: Platform,
    private splashScreen: SplashScreen,
    private statusBar: StatusBar,
    private fcmService: FcmService
  ) {
    this.initializeApp();
  }

  initializeApp() {
    this.platform.ready().then(() => {
      this.statusBar.styleDefault();
      this.splashScreen.hide();

      // Trigger the push setup 
      this.fcmService.initPush();
    });
  }
}

If you don’t want to perform this in your app right in the beginning, simply call it when it’s a better time in your user flow!

The last part now is to handle the information on our details page, so not really related to push anymore but general Angular routing.

We can catch the details inside our pages/details/details.page.ts like this:

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Plugins } from '@capacitor/core';
const { PushNotifications } = Plugins;

@Component({
  selector: 'app-details',
  templateUrl: './details.page.html',
  styleUrls: ['./details.page.scss'],
})
export class DetailsPage implements OnInit {
  id = null;

  constructor(private route: ActivatedRoute) { }

  ngOnInit() {
    this.route.paramMap.subscribe(params => {
      this.id = params.get('id');
    });
  }

  resetBadgeCount() {
    PushNotifications.removeAllDeliveredNotifications();
  }

}

Additionally we might wanna clear the badge of our app during testing, which is what we do with our resetBadgeCount function! Normally you would call this when a user has seen/read all the relevant notifications of course.

Now just finish everything by displaying our details inside the pages/details/details.page.html:

<ion-header>
  <ion-toolbar>
    <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 from push: {{ id }}

  <ion-button (click)="resetBadgeCount()" expand="block">
    Reset Badge Count
  </ion-button>
</ion-content>

And that’s it for the Ionic code – now you just need to build the app, sync your changes and deploy your app to your device.

ionic build
npx cap sync

Open either Android Studio or Xcode and deploy to your device, then head on to sending a push.

Sending Push Notifications with Firebase

There are now many ways to send a push notification with Firebase, let’s take a look at them in detail.

Specific Device Test

When you deploy your app to a device you can catch the console logs and see the token being logged after registration.

With this token you are able to send a targeted test push to confirm your integration is working.

Simply navigate to Cloud Messaging and select Send test message within Firebase and add the device token you got from the logs.

firebase-test-push

If you did everything correctly before you should now see a push notification on your device!

Push Message with Payload

If you want to test a more realistic push with additional information, you can simply follow the wizard on the same page to first specify some general information and select the platform that you want to target.

Additionally we now want to pass some additional options as a payload of our push, and that’s the place where we make use of the detailsId that we used before in our code, so simply add some value to it like in the image below.

firebase-additional-options

When you now receive a push and click it, your app should take you to the details page and display the value you added for this key – easy deeplink with the Angular router!

Conclusion

Push notifications are mandatory for a lot of apps, and while the steps look like a lot of work, it’s actually done in an hour and you have a powerful feature for your app.

There’s also an API for Firebase that you could use from your own server, so if you want to see some additional material on that topic, let me know in the comments!

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

The post The Push Notifications Guide for Ionic & Capacitor appeared first on Devdactic.


The Ultimate Ionic Server Side Rendering Guide (SSR)

$
0
0

The topic of server side rendering for Angular (and other client side frameworks) has been available for quite some time, but only recently this become more useful with Ionic thanks to a new package by the Ionic team.

In this tutorial we will dive into everything SSR related and host our Ionic application on Heroku. We will include SEO tags to rank higher and allow social card previews, and also get deeper into this topic by inspecting the Transfer State API.

ionic-ssr-guide

After this tutorial you will be able to host your Ionic application with SSR and all the benefits it brings to your app!

There’s now also a whole course on the topic of SSR and building websites with Ionic inside the Ionic Academy!

Why Server Side Rendering?

The idea is simple: Return a pre rendered HTML file from the server that already contains enough data to quickly present the page to your users. This is not really relevant for your native app, but even more for websites or a PWA.

By using SSR, a server will handle incoming requests and create an HTML string that is returned to the user so the first paint of your page becomes faster (in theory).

After the page has loaded on the client side, Angular will take over when the loading is finished and the page continues to work like a regular SPA we are used to, while the end user should hopefully not notice anything.

As an example, I have hosted the code from this tutorial here: https://devdactic-ssr.herokuapp.com

You can check out the page and view the source, and you will find all the information we will talk about in the following sections.

Starting an Ionic app and adding Angular Universal

To get started with your own app, simply run the standard command for a blank new Ionic app and optionally add two more pages so we can include some cool functionality:

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

ionic g page pages/details
ionic g page pages/second

npm install @angular/animations @ionic/angular-server
ng add @nguniversal/express-engine

The last two lines make our app SSR ready. The animations package is needed for Angular universal in general, and the special Ionic angular-server package is required to hydrate our web components on the server side.

The schematic will add a bunch of files and change some files, so if you want to know more about this also check out the video at the bottom of this tutorial!

In general, it adds an Express server that we can run in a Node environment. That also means, we can’t just throw the build folder into our web hosting like usually, and that’s why we will host everything on Heroku in the end.

To use this package in our app, we need to change one of the many server files that were added by the Angular schematic to also include the new module, so change the app/app.server.module.ts to this:

import { NgModule } from '@angular/core';
import { ServerModule } from '@angular/platform-server';

import { AppModule } from './app.module';
import { AppComponent } from './app.component';

import { IonicServerModule } from '@ionic/angular-server';

@NgModule({
  imports: [
    AppModule,
    ServerModule,
    IonicServerModule
  ],
  bootstrap: [AppComponent],
})
export class AppServerModule {}

Since we will later also perform HTTP requests, go open your app/app.module.ts and include it right now:

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router';

import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
import { SplashScreen } from '@ionic-native/splash-screen/ngx';
import { StatusBar } from '@ionic-native/status-bar/ngx';

import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';

import { HttpClientModule } from '@angular/common/http';

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

Now your app is actually SSR ready and you can start a dev server (with livereload!) by running:

npm run dev:ssr

How do you notice any difference?

Usually, your Ionic (Angular) apps source code in the browser looks like this:

ionic-ssr-default

All the DOM population is done by Angular on the client side, which makes it hard for search engines to correctly crawl your page. Basically only Google can do it to some degree.

If you now inspect your source code you will see a completely different picture:

ionic-ssr-code

Everything is included in the HTML! Styling, components, everything needed to present and render the page. All of this is returned by the server, which has hydrated our app and injected all the relevant information for us into the website.

Since we added the special Ionic package for web components, you could even disabled Javascript in the debugger section (cmd+shift+p) and reload the page, and it still works! If you leave out the Ionic package, you would see an empty view instead.

Now that we got SSR in place, let’s add some more cool features to it.

Adding more Routes

We only have a home page, but already added some packages. Let’s quickly change our routing to this now inside the app/app-routing.module.ts:

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

const routes: Routes = [
  {
    path: '',
    loadChildren: () => import('./home/home.module').then( m => m.HomePageModule)
  },
  {
    path: 'home/:id',
    loadChildren: () => import('./pages/details/details.module').then( m => m.DetailsPageModule)
  },
  {
    path: 'second',
    loadChildren: () => import('./pages/second/second.module').then( m => m.SecondPageModule)
  },
];

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

To navigate around we also need some simple buttons, so open the home/home.page.html and change it to:

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

<ion-content>
  <ion-item>
    <ion-label position="stacked">Enter Pokemon ID</ion-label>
    <ion-input [(ngModel)]="id"></ion-input>
  </ion-item>
  <ion-button expand="full" routerLink="second">Open Second Page</ion-button>
  <ion-button expand="full" [routerLink]="['home', id]">Open Details Page</ion-button>
</ion-content>

Really just a super simple example with an input field so we can potentially go to our details route with a dynamic value. To make TS happy also quickly bring up the home/home.page.ts and add the variable to your class:

import { Component } from '@angular/core';

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

  constructor() {}

}

This wasn’t really exciting, but now we can implement some more great features!

Using SEO Tags with Ionic

You’ve been waiting for this part, right? SEO ranking is important, and including the right tags in your page can help to rank your (or your clients!) page better on Google.

There has always been a service to set meta tags, but since the JS would only execute on the client side itself, most crawlers would never find your tags.

Enter SSR: How about including the SEO meta tags right in your HTML?

We can set our tags using the meta and title service like this right inside our: pages/second/second.page.ts

import { Component, OnInit } from '@angular/core';
import { Title, Meta } from '@angular/platform-browser';

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

  constructor(private titleService: Title, private metaService: Meta) { }

  ngOnInit() {
    this.titleService.setTitle('Devdactic SSR');
    this.metaService.updateTag({ name: 'description', content: 'The Devdactic SSR Page' });
    // Twitter
    this.metaService.updateTag({ property: 'twitter:card', content: 'summary_large_image' });
    this.metaService.updateTag({ property: 'twitter:title', content: 'NEW ARTICLE OUT NOW' });
    this.metaService.updateTag({ property: 'twitter:description', content: 'Check out this cool article' });
    this.metaService.updateTag({ property: 'twitter:image', content: 'https://i0.wp.com/devdactic.com/wp-content/uploads/2020/05/ionic-in-app-purchase-capacitor.png?w=1620&ssl=1' });
    // Facebook
    this.metaService.updateTag({ property: 'og:url', content: '/second' });
    this.metaService.updateTag({ property: 'og:type', content: 'website' });
    this.metaService.updateTag({ property: 'og:description', content: 'My Ionic SSR Page' });
    this.metaService.updateTag({ property: 'og:title', content: 'My SSR Title!' });
    this.metaService.updateTag({ property: 'og:image', content: 'https://i0.wp.com/devdactic.com/wp-content/uploads/2020/05/ionic-in-app-purchase-capacitor.png?w=1620&ssl=1' });
  }

}

Again, you could have done this before, but the result would have been very different.

The result in our case is that the page is rendered on the server, and all the tags are now present when you inspect the source code of your page.

ionic-seo-sourcecode

Another big gain from this approach is that you can set the information that are used when your content is shared on social media.

After deployment (not working with your localhost dev environment right now) you can use some SEO tag checker and enter your URL and voila – beautiful social media cards right from your Ionic app!

ionic-seo-tag-preview

This might be one of the biggest benefits for people that need to rank on Google or where people share their content, since everything else is these days pretty unacceptable.

Now let’s dive even a bit deeper in one more SSR concept.

Using the State Transfer API

The State Transfer API allows to seamlessly transfer the server state to the client side (in easy words). Imagine the following:

Your page makes an API call, which is now performed on the server. After the HTML is delivered to the client and Angular takes over, the code runs again and you make a second API request, resulting in perhaps another loading indicator and in general, wasted time.

To fix this issue, we can implement a behaviour in which only the server performs the API call, adds the result as stringified JSON to the HTML and the client extracts it from there – and all of this in a pretty easy way!

We need to inject the module into both our server and the client, so first open the app/app.server.module.ts and change it to:

import { NgModule } from '@angular/core';
import { ServerModule, ServerTransferStateModule } from '@angular/platform-server';

import { AppModule } from './app.module';
import { AppComponent } from './app.component';

import { IonicServerModule } from '@ionic/angular-server';

@NgModule({
  imports: [
    AppModule,
    ServerModule,
    IonicServerModule,
    ServerTransferStateModule
  ],
  bootstrap: [AppComponent],
})
export class AppServerModule {}

Now we also need to inject the appropriate module into the client side, therefore open the app/app.module.ts and add the BrowserTransferStateModule:

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router';

import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
import { SplashScreen } from '@ionic-native/splash-screen/ngx';
import { StatusBar } from '@ionic-native/status-bar/ngx';

import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';

import { HttpClientModule } from '@angular/common/http';
import { BrowserTransferStateModule } from '@angular/platform-browser';

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

With that in place we can construct our HTTP call and for now we will simply query the awesome PokeAPI.

The idea is to fetch the data from the API on the server, then write it to the TransferState with a specific key related to that request (in our case using the ID). We can then always check for this key in our TransferState, and if the data exists, we can simply assign it from there and afterwards remove it.

The TransferState has a pretty simple API, so open your pages/details/details.page.ts now and change it to:

import { Component, OnInit, Inject, PLATFORM_ID } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { ActivatedRoute } from '@angular/router';
import { TransferState, makeStateKey } from '@angular/platform-browser';
import { isPlatformServer } from '@angular/common';

@Component({
  selector: 'app-details',
  templateUrl: './details.page.html',
  styleUrls: ['./details.page.scss'],
})
export class DetailsPage implements OnInit {
  id = null;
  data = null;

  constructor(
    private http: HttpClient,
    private route: ActivatedRoute,
    @Inject(PLATFORM_ID) private platformId,
    private transferState: TransferState
  ) {
    this.id = this.route.snapshot.paramMap.get('id');
    this.loadata();
  }

  ngOnInit() {}

  loadata() {
    const DATA_KEY = makeStateKey('pokemon-' + this.id);
    if (this.transferState.hasKey(DATA_KEY)) {
      console.log('Fetch data from State!');
      this.data = this.transferState.get(DATA_KEY, null);
      this.transferState.remove(DATA_KEY);
    } else {
      console.log('Get Data from API...');
      this.http
        .get(`https://pokeapi.co/api/v2/pokemon/${this.id}`)
        .subscribe((res) => {
          if (isPlatformServer(this.platformId)) {
            this.transferState.set(DATA_KEY, res);
          } else {
            this.data = res;
          }
        });
    }
  }
}

By using the PLATFORM_ID you can also identify if the code is currently running on the server or client side, which makes it easy to perform certain operations only on one side.

To quickly display the information, change the pages/details/details.page.html to:

<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>
  <h2>{{ id }}: {{ data?.name }}</h2>
</ion-content>

It works, but you don’t really see the difference first. But you can see the result again in the source code of your page:

ionic-ssr-transfer-state

That means, the whole API response was added to the HTML that the server returned, and the app simply read the data from there.

Of course this is not a solution in every case since you don’t want to add your whole API response for every request with potential sensitive information.

Also, you will lose the benefit of SSR if you include tons of JSON data in the HTML page which then takes longer to load on the client side for the first paitn!

Hosting Your Ionic App

As said in the beginning, you now need to run the Express server on your hosting, which is usually not possible if you have just some standard web hosting.

A good alternative is Heroku, on which you can deploy your app for free in the beginning (paid if you want more power later).

For Heroku you need a Git repository that you basically push to Heroku to perform a new build, so I recommend you create a free repository on Github now.

Once you created a repository, you already get the commands you should run in your local repository.

In my example it looks like this:

git add .
git commit -am 'Implement SSR.'
git remote add origin https://github.com/saimon24/devdactic-ssr.git
git push -u origin master

This adds all your files to Git, creates a new commit, adds the Github remote and finally pushes your code.

Congrats if this is your first ever Github repository!

Now we need to connect it with Heroku, and therefore create a Heroku account and click on create new app.

heroku-create-app

Simply set a name and region and your app is ready here as well.

Now we need to add this Heroku remote to our local repository, and this could look like this:

heroku git:remote -a devdactic-ssr
# Verify everything is fine
git remote -vv show

Make sure to use your own Heroku app name of course, and also make sure you install the Heroku CLI and log in before running these commands.

The last command should now print out your Github URL and Heroku URL as remotes of your repository.

Finally we need to tell Heroku what to do with our app, and we do this by creating a Procfile at the top of our Ionic app folder (only the name Procfile, no extension!). The content is simple:

web: npm run serve:ssr

Heroku will call one of your scripts to run the app, but since it should also build the app we can add another postinstall script inside our package.json which will be triggered by Heroku automatically after you deploy your code:

"postinstall": "npm run build:ssr"

After applying these changes and adding a new file it’s time for another commit and a final push to our Heroku remote:

git add .
git commit -am 'Add profile for Heroku.'
git push heroku master

This will take 5-10 minutes so don’t worry about it. In the end, it should print out the URL of your app or you can visit the Settings -> Domain section directly inside Heroku to find the URL as well.

As a result, you can visit your hosted Ionic app, now running through SSR!

ionic-heroku-ssr

Inspect the source code and check the meta tags – everything will look like a regular website.

Conclusion

Adding server side rendering to your Ionic app has become a lot easier lately, but of course this was just a simple example and your app might need a lot more work to really embrace SSR as a useful tool.

There’s also the possibility to define which part of your App shell should be returned by your server to really make the first print of your app as fast as possible, and if your app uses many more libraries, you will very likely run into some trouble since the node server doesn’t know about window.

Since the Ionic team is working on improvements for SSR at the time writing this, expect many more optimisations in the future!

If you want to see more on SSR or pre rendering (or static sites with Ionic?) just leave a comment below.

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

The post The Ultimate Ionic Server Side Rendering Guide (SSR) appeared first on Devdactic.

Practical Ionic – Build Real World Applications

$
0
0

It’s difficult to implement best practices and connect functionality based on simple tutorials. That’s why I created a new book for you – a practical guide to building real Ionic applications.

This book is a hands on guide (that’s why it’s called practical) on building complex applications with Ionic, Firebase and Capacitor.

And it’s now available for purchase!

Let’s quickly go through a few items so you can evaluate if this book is for you.

What will I learn?

Inside Practical Ionic we build one big application – not a bunch of dummy apps. But this one application consists of all important elements you need to understand in order to build highly complex real world applications.

The book is practical since we tackle each screen or functionality on it’s on and integrate it into our app. All with detailed code snippets and explanations plus important notes about additional topics around the learning material.

This is not your Ionic getting started guide, this is the book to read once you’ve taken the first steps and look for the real knowledge you need in order to build and ship your apps.

Why Practical Ionic?

The whole idea for the book is based on a reader survey from 2019 – so you basically gave me the idea.

Based on feedback I got over the years I understand that some topics are simply too easily covered with tutorials. What happens once you need an inside menu at a later point, or you need authentication guards based on API data?

What about RxJS and the magical pipe block to resolve and transform your Observables?

Practical Ionic helps to understand how you can solve every problem inside your own app in the future based on a concrete business example.

In fact this concept was planned as a series with even more real world apps, so if you enjoy the concept of this book, let me know and we might potentially see a continuation of the Practical Ionic series in the future!

What’s inside the packages?

The essential package comes with only the digital book (PDF, epub, mobi), the advanced and ultimate edition have additional written guides and Ionic template apps for your future projects. Plus they got some nice perks on my other products – check out all of the packages here!

The Writing Process

The whole book process took me over half a year, since I created the app first, started writing on it and finally reworked part of the app again.

If you want to see a bit more of the book and everything I used to create it, check out my new vlog episode covering the creation and launch of Practical Ionic!

Why didn’t you use xyz technology?

It’s hard to build a product that fits everyone these days – in fact it’s not even possible with Ionic alone anymore, since you might have to cover Angular, React and Vue!

Therefore the decision on Ionic, Capacitor & Firebase comes from the popularity of these tools. You could achieve the same results with a Nest backend or if you use React instead of Angular most likely.

For a potential second Practical Ionic book I would consider a full backend chapter on creating your own API, and perhaps even a different framework instead of Angular.

What’s next?

For now, this is it. Over the next time I’ll be focusing on the Ionic Academy and the Kickoff Ionic bootstrap tool more, which both didn’t get enough attention while creating this ebook.

Of course I’m on Twitter for any questions you might have with Practical Ionic, and like with all my products, there’s a 14 day money back guarantee if you’re not happy with your purchase.

Enjoy Ionic and get some practical skills!

Get Practical Ionic today.

The post Practical Ionic – Build Real World Applications appeared first on Devdactic.

How to Build Your Own Capacitor Plugin for Ionic

$
0
0

When you work with a framework like Capacitor, you should know how it works internally, and how you can overcome challenges even if there’s not a plugin out there for your needs.

That’s why we are going to create our very own Capacitor plugin today that works on both iOS, Android and with a little fallback for the web as well.
create-capacitor-plugin

We are going to retrieve the native contacts of a phone (which of course doesn’t work very well on the browser) and write some Swift and Java code for the native platforms to handle everything efficient.

Creating the Capacitor Plugin

To get started with a new Capacitor plugin, you can simply call the generate command of the Capacitor CLI (install if you haven’t) which will ask for a bunch of details:

npm i -g @capacitor/cli
npx @capacitor/cli plugin:generate
cd contacts-plugin
npm run build

You can answer the questions as you want, but if you want to follow the names in this tutorial, here’s what I used.
capacitor-plugin-questions

Afterwards you can dive into your new plugin and create a first build using the predefined NPM script. Let’s also check out what the plugin is actually made of:

  • Android: The native implementation of the plugin for Android
  • dist: The build output folder which is used on the web
  • ios: The native implementation of the plugin for iOS
  • src: The web implementation of your plugin and its interface

These are the most important folders of your plugin, so let’s start to tackle our plugin one by one.

Web Capacitor Plugin Code

First of all we need to define a new function on the interface of the plugin. There’s already one dummy function defined, and we will simply add another one inside the src/definition.ts:

declare module "@capacitor/core" {
  interface PluginRegistry {
    ContactsPlugin: ContactsPluginPlugin;
  }
}

export interface ContactsPluginPlugin {
  echo(options: { value: string }): Promise<{value: string}>;
  getContacts(filter: string): Promise<{results: any[]}>;
}

We wanna have a function to retrieve contacts, and we can pass any value to this function and access it within the plugin implementation later.

For the web, the implementation is actually just a dummy fallback since there are no native contacts. But if your plugin has the ability to work on the web, this would be the place to implement the Typescript logic of it.

Continue by adding your new function and a super simple implementation to the src/web.ts:

import { WebPlugin } from '@capacitor/core';
import { ContactsPluginPlugin } from './definitions';

export class ContactsPluginWeb extends WebPlugin implements ContactsPluginPlugin {
  constructor() {
    super({
      name: 'ContactsPlugin',
      platforms: ['web']
    });
  }

  async echo(options: { value: string }): Promise<{ value: string }> {
    console.log('ECHO', options);
    return options;
  }

  async getContacts(filter: string): Promise<{ results: any[] }> {
    console.log('filter: ', filter);
    return {
      results: [{
        firstName: 'Dummy',
        lastName: 'Entry',
        telephone: '123456'
      }]
    };
  }
}

const ContactsPlugin = new ContactsPluginWeb();

export { ContactsPlugin };

import { registerWebPlugin } from '@capacitor/core';
registerWebPlugin(ContactsPlugin);

In here we can see the already existing function, and within the constructor we see the supported platforms. This actually means that the web code is only used on the web – it doesn’t mean your plugin won’t work on other platforms!

Only if you want to use the web implementation of your plugin on the other native platforms, you would add them to the array in here.

At this point we could already integrate the plugin in our app, but let’s stick to this project before we dive into the usage in the end.

iOS Capacitor Plugin Code

To edit your iOS implementation, I highly recommend to use the native tooling, which means you should open the ios/Plugin.xcworkspace with Xcode.

This will give you the right syntax highlighting and the best available code completion to write your plugin.

Now I don’t really know Swift anymore after years, but with some general coding knowledge it’s quite easy to find tutorials on the native implementation of features, that you can easily adapt and integrate into the Capacitor shell and format that we need to follow!

First of all, we need to register our function within the ios/Plugin/Plugin.m, following the same structure like the existing dummy function:

#import <Foundation/Foundation.h>
#import <Capacitor/Capacitor.h>

// Define the plugin using the CAP_PLUGIN Macro, and
// each method the plugin supports using the CAP_PLUGIN_METHOD macro.
CAP_PLUGIN(ContactsPlugin, "ContactsPlugin",
           CAP_PLUGIN_METHOD(echo, CAPPluginReturnPromise);
           CAP_PLUGIN_METHOD(getContacts, CAPPluginReturnPromise);
)

Next step is to create the function we just registered and add the Swift code.

As said before, I made this solution work based on some Swift tutorials and looking up Stack overflow answers, so most likely it’s not the best way, but it works!

Within the Swift file of our plugin we already see the dummy function, which get’s one argument that contains all the information about the plugin call. From this object you could extract all the information you initially passed to the plugin.

In our case, we could extract the filter value and use it in the native code, but I’ll leave the filtering logic up to you as a little task (read: I was too lazy to add it in the end).

Afterwards follows the native code to retrieve the device contacts, add them to an array and finally use our call variable to report a success back to our calling code and pass the results along.

If we fail, we can also use call.reject which would then give us an error of our Promise inside Javascript.

Go ahead and change the ios/Plugin/Plugin.swift to this now:

import Foundation
import Capacitor
import Contacts

@objc(ContactsPlugin)
public class ContactsPlugin: CAPPlugin {
    
    @objc func echo(_ call: CAPPluginCall) {
        let value = call.getString("value") ?? ""
        call.success([
            "value": value
        ])
    }

    @objc func getContacts(_ call: CAPPluginCall) {
        let value = call.getString("filter") ?? ""
        // You could filter based on the value passed to the function!
        
        let contactStore = CNContactStore()
        var contacts = [Any]()
        let keys = [
                CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
                        CNContactPhoneNumbersKey,
                        CNContactEmailAddressesKey
                ] as [Any]
        let request = CNContactFetchRequest(keysToFetch: keys as! [CNKeyDescriptor])
        
        contactStore.requestAccess(for: .contacts) { (granted, error) in
            if let error = error {
                print("failed to request access", error)
                call.reject("access denied")
                return
            }
            if granted {
               do {
                   try contactStore.enumerateContacts(with: request){
                           (contact, stop) in
                    contacts.append([
                        "firstName": contact.givenName,
                        "lastName": contact.familyName,
                        "telephone": contact.phoneNumbers.first?.value.stringValue ?? ""
                    ])
                   }
                   print(contacts)
                   call.success([
                       "results": contacts
                   ])
               } catch {
                   print("unable to fetch contacts")
                   call.reject("Unable to fetch contacts")
               }
            } else {
                print("access denied")
                call.reject("access denied")
            }
        }
    }
}

We also added a new import and again, I didn’t know a thing about this Swift implementation but still made it work, so you can do the same for any native function that you want to create a plugin for!

Android Capacitor Plugin Code

The Android code took me a bit longer since I didn’t (and still don’t) understand the retrieving logic correctly but it mostly works as well now.

Once again, I highly recommend opening your Android folder with Android Studio to build your plugin. Native tooling, code completion, much better than your usual familiar IDE.

For Android things got a bit more challenging since the plugin needs to ask for permission first, which blows the code up a bit.

In the snippet below you can see that we have the same initial getContacts() function that will be called, which us annotated with @PluginMethod() to mark it for Capacitor.

But we also define a unique code for our permission dialog and add it to the @NativePlugin annotation of our class, because the flow for permissions looks like this:

  1. Use saveCall() to save the information of our plugin call
  2. Trigger the permission dialog by using pluginRequestPermission or other functions to start the dialog, and pass the unique ID to it
  3. Handle the result of the dialog within the handleRequestPermissionsResult and check if you can load the plugin data using getSavedCall()
  4. Compare the requestCode with the static value we defined to know for which permission dialog the result came in
  5. Finally if everything is fine, call the actual logic of your plugin, which in this case is the loadContacts()

It’s a bit more tricky, but a good example of how to build more complex Capacitor plugins – and if you think about it, the flow is actually quite nice, a bit like a callback in Javascript.

Now go ahead and change your android/src/main/java/com/devdactic/contacts/ContactsPlugin.java (you might have a different package name though) to:

package com.devdactic.contacts;

import android.Manifest;
import android.content.ContentResolver;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.provider.ContactsContract;
import android.util.Log;

import com.getcapacitor.JSObject;
import com.getcapacitor.NativePlugin;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;

import org.json.JSONArray;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;

@NativePlugin(
        requestCodes={ContactsPlugin.REQUEST_CONTACTS}
)
public class ContactsPlugin extends Plugin {
    protected static final int REQUEST_CONTACTS = 12345; // Unique request code

    @PluginMethod()
    public void getContacts(PluginCall call) {
        String value = call.getString("filter");
        // Filter based on the value if want

        saveCall(call);
        pluginRequestPermission(Manifest.permission.READ_CONTACTS, REQUEST_CONTACTS);
    }

    @Override
    protected void handleRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
        super.handleRequestPermissionsResult(requestCode, permissions, grantResults);


        PluginCall savedCall = getSavedCall();
        if (savedCall == null) {
            Log.d("Test", "No stored plugin call for permissions request result");
            return;
        }

        for(int result : grantResults) {
            if (result == PackageManager.PERMISSION_DENIED) {
                Log.d("Test", "User denied permission");
                return;
            }
        }

        if (requestCode == REQUEST_CONTACTS) {
            // We got the permission!
            loadContacts(savedCall);
        }
    }

    void loadContacts(PluginCall call) {
        ArrayList<Map> contactList = new ArrayList<>();
        ContentResolver cr = this.getContext().getContentResolver();

        Cursor cur = cr.query(ContactsContract.Contacts.CONTENT_URI,
                null, null, null, null);
        if ((cur != null ? cur.getCount() : 0) > 0) {
            while (cur != null && cur.moveToNext()) {
                Map<String,String> map =  new HashMap<String, String>();

                String id = cur.getString(
                        cur.getColumnIndex(ContactsContract.Contacts._ID));
                String name = cur.getString(cur.getColumnIndex(
                        ContactsContract.Contacts.DISPLAY_NAME));

                map.put("firstName", name);
                map.put("lastName", "");

                String contactNumber = "";

                if (cur.getInt(cur.getColumnIndex( ContactsContract.Contacts.HAS_PHONE_NUMBER)) > 0) {
                    Cursor pCur = cr.query(
                            ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
                            null,
                            ContactsContract.CommonDataKinds.Phone.CONTACT_ID + " = ?",
                            new String[]{id}, null);
                    pCur.moveToFirst();
                    contactNumber = pCur.getString(pCur.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER));
                    Log.i("phoneNUmber", "The phone number is "+ contactNumber);
                }
                map.put("telephone", contactNumber);
                contactList.add(map);
            }
        }
        if (cur != null) {
            cur.close();
        }

        JSONArray jsonArray = new JSONArray(contactList);
        JSObject ret = new JSObject();
        ret.put("results", jsonArray);
        call.success(ret);
    }
}

In the end we return again an array of the same type like we did before inside the web and iOS implementation. Make sure your plugin doesn’t return a different format on different platforms. You want to use a unified API in your Ionic app, and therefore the result on all platforms should have the same interface!

After you are done with everything, you can run a final npm run build and we are done with the Capacitor plugin!

Using Your Capacitor Plugin with Ionic

For the integration, let’s quickly start a blank Ionic app with Capacitor support. The official guide recommends to link your local plugin now, which actually didn’t work well for me. Instead, I simply passed the local path to the plugin to the install command, so make sure you put in the right path to your Capacitor plugin in this step!

ionic start devdacticPlugin blank --type=angular --capacitor
cd ./devdacticPlugin
npm i ../contacts-plugin

# Build app and add native platforms
ionic build
npx cap add ios
npx cap add android

Once the plugin is installed, you should see an entry like this in your package.json:

"contacts-plugin": "file:../contacts-plugin",

Of course this won’t work well with your colleagues (unless they also have the plugin and Ionic app next to each other), but for testing and building the plugin this should be perfectly fine, and we’ll see another benefit of this in the end as well.

Android Plugin Integration

The integration of Capacitor plugins always comes with some steps, you can see this for basically every community plugin.

In our case we first need to add the permission that our plugin needs inside the android/app/src/main/AndroidManifest.xml at the bottom where we can already see a bunch of other permissions:

<uses-permission android:name="android.permission.READ_CONTACTS" />

To use the Capacitor plugin on Android we also need to register it inside the main activity, which we can do by loading the package (watch out for your package id in that path for both plugin and your app!) and calling the add() function inside the initialise within our android/app/src/main/java/io/ionic/starter/MainActivity.java:

package io.ionic.starter;
import com.devdactic.contacts.ContactsPlugin;

import android.os.Bundle;

import com.getcapacitor.BridgeActivity;
import com.getcapacitor.Plugin;

import java.util.ArrayList;

public class MainActivity extends BridgeActivity {
  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    // Initializes the Bridge
    this.init(savedInstanceState, new ArrayList<Class<? extends Plugin>>() {{
      // Additional plugins you've installed go here
      add(ContactsPlugin.class);
    }});
  }

}

This step is (afaik) always necessary for Android plugins.

iOS Plugin Integration

For iOS we also need to add a permission to read the contacts, which we can do inside the ios/App/App/Info.plist:

<key>NSContactsUsageDescription</key>
<string>We need your data</string>

You can do this directly from code or within Xcode by adding a row and searching for “Privacy”, which will prompt you with a bunch of permissions that you could add.

Capacitor Plugin Usage

Now we can finally integrate our own plugin. Since the plugin is registered within Capacitor we can destructure the Plugins object and extract the name of our plugin from there. This name is what you can find in the interface of your plugin!

It’s important to also add the import line using the name of the plugin (as specified in the package.json) because this will actually trigger the web registration of your plugin – otherwise the web part of it won’t work!

Once everything is added, we can simply use the plugin like any other plugin and use our created function to fetch the contacts, so open the home/home.page.ts and add this:

import { Component } from '@angular/core';
import { Plugins } from '@capacitor/core';
import 'contacts-plugin';
 
const { ContactsPlugin } = Plugins;
 
@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})
export class HomePage {
  contacts = [];
 
  constructor() {
  }
 
  async loadContacts() {    
    this.contacts = (await ContactsPlugin.getContacts('somefilter')).results;
    console.log('my contacts: ', this.contacts);    
  }
}

To wrap things up, simply add a little iteration over our contacts so we can display our result within the home/home.page.html:

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

<ion-content>
  <ion-button expand="full" (click)="loadContacts()">
    Load Contacts
  </ion-button>

  <ion-list>
    <ion-item *ngFor="let c of contacts">
      <ion-label>
        {{ c.firstName }} {{ c.lastName }}
        <p>
          {{ c.telephone }}
        </p>
      </ion-label>
    </ion-item>
  </ion-list>
</ion-content>

Now you can test your new plugin on the browser, and also within the native platforms of your Ionic app!

Capacitor Plugin Development Workflow

For this tutorial we have tackled the Capacitor plugin first, and then integrated it within our Ionic project. And if you know a bit about native development and can make it work immediately, that’s fine.

But usually, there’s another way to test and debug your plugin, which I actually also used to develop the code for this tutorial:

You create the plugin, integrate it with the local installation and then you continue to work on your plugin right from your Ionic app!

That means, you would run npx cap open ios to open Xcode, and you can work on your plugin directly from there.
capacitor-xcode-editor

Hit save, run the app again on your Simulator or device and you can use native debugging with breakpoints to work on your plugin from there.

The same is true for Android as well, so after running npx cap open android from your Ionic app, edit the plugin right in there:

capacitor-android-editor
This development speed beats everything I’ve experienced with Cordova, and given the native tools, this is really the best way to develop and debug your plugin at the same time.

Because the plugin is basically a link to the files right inside your plugin folder, all the changes you make here are also directly saved inside the plugin folder – you are basically working on the same files!

Conclusion

If you can’t find the right Capacitor community plugin for your needs, simply go ahead and create your own!

We’ve seen that it’s easier than ever today, given the boilerplate of the generate command and the native debugging and development environment. And when you are done with the plugin, you can run npm publish to make it available to everyone so we can all install it simply using npm!

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

The post How to Build Your Own Capacitor Plugin for Ionic appeared first on Devdactic.

How to Use Firebase Analytics with Ionic

$
0
0

Since you might already use Firebase in your project, why not use the analytics functionality it provides in combination with Google?

Setting up Firebase analytics within your Ionic application is super easy with Capacitor, so we will go throught he whole process today and end up logging some events to Firebase!

ionic-firebase-analytics

We will use the Firebase analytics community plugin, look at the implementation and finally how to debug all of your tracking code. But first, let’s start with Firebase.

Firebase Project Setup

For testing, let’s start a new project within the Firebase console and make sure you have Google Analytics enabled for the new project. The Wizard will connect to your Google Analytics account and you can select one of your accounts and automatically generated a new property in that account.

firebase-ga

If you already got a project, you can also manually enable GA by going to Project settings -> Integration – > Google Analytics within your existing Firebase project!

The next step is to add a web, iOS and Android application inside Firebase. You can do this right from the Dashboard or by going to your Project settings -> General tab under “Your apps”.

firebase-add-app

You only need to have the package id of your application (or the one you want to use), but all other information for ios/Android is optional. Same for web, here basically everything is optional and you only need the final Firebase configuration object you see at the end of the steps.

After the initial step for iOS and Android you can download a file, which you can for now simply put anywhere. The files are:

  • google-services.json file for Android
  • GoogleService-info.plist file for iOS

We’ll make use of these two files in a second so keep them at hand!

Ionic App Setup

Let’s start our app with the correct package id, which makes your life a lot easier. You can also skip all of this if you already have an existing app of course.

Within our new app we generated another service and page for some analytics logic, and install the Capacitor plugin:

ionic start devdacticAnalytics blank --type=angular --capacitor --package-id=com.devdactic.analytics
cd ./devdacticAnalytics

ionic g service services/analytics
ionic g page pages/second

npm install @capacitor-community/firebase-analytics

ionic build
npx cap add ios
npx cap add android

Once the basic setup is ready you can run the first build, which helps to quickly finish the setup for the native platforms as well.

But before, copy your Firebase web configuration into the environment/environment.ts like this:

export const environment = {
  production: false,
  firebaseConfig: {
    apiKey: "",
    authDomain: "",
    databaseURL: "",
    projectId: "",
    storageBucket: "",
    messagingSenderId: "",
    appId: "",
    measurementId: ""
  }
};

That’s the best place to store your web config.

iOS Integration

For iOS, simply open the native project like this:

npx cap open ios

Now all we need to do is copy our GoogleService-Info.plist that we downloaded in the beginning from Firebase into our iOS project, and you should drag the file into the Xcode project right inside the app/app folder like in the image below.

ios-capacitor-push

Don’t just copy it inside VSC, really use Xcode for this step!

Android Integration

There are only two things to do for Android, and that’s first of all moving the google-services.json that you downloaded to the android/app/ folder like in the image below.

android-push-file

Once that file is in place, we need to register our Capacitor plugin inside the android/app/src/main/java/com/devdactic/analytics/MainActivity.java (attention: in your case it will be your package id in the path!):

package com.devdactic.analytics;
import com.getcapacitor.community.firebaseanalytics.FirebaseAnalytics;

import android.os.Bundle;

import com.getcapacitor.BridgeActivity;
import com.getcapacitor.Plugin;

import java.util.ArrayList;

public class MainActivity extends BridgeActivity {
  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    // Initializes the Bridge
    this.init(savedInstanceState, new ArrayList<Class<? extends Plugin>>() {{
      // Additional plugins you've installed go here
      add(FirebaseAnalytics.class);
    }});
  }
}

This step is basically necessary for all Capacitor community plugins with Android support. Simply import the plugin and call add() inside the init().

Implementing Firebase Analytics

Now we can get into the actual usage of our plugin, and I recommend you put all the analytics logic into a dedicated service that you can use from everywhere inside your app.

There are a few key functionalities that we can use:

  • initializeFirebase: Only necessary for the web, so leave it out if you only target native platforms
  • setUserId : Identify logs by a specific user. I recommend to use the FB auth unique ID or any other unique user ID for this!
  • setUserProperty: Add a property to the user logs. You can have up to 25 unique properties, so don’t store every trash in them.
  • logEvent: The most basic of all events – just log that something happened. Check out the recommendations for events!
  • setScreenName: Only works on ios/Android! Track the view of a specific page/screen of your app. I added a little logic to connect this with the router events so you don’t need to manually call this in each of your pages, but the information from the router is limited to the URL itself.
  • setCollectionEnabled: Enabled or disabled the collection of user events and data. Helpful if you want to not track anything unless the user has opted in for tracking or other legal documents.

Much of this contains dummy data, so you could extend the service functions with some parameters in order to use them from everywhere in your app. For now simply open the services/analytics.service.ts and change it to:

import { Injectable } from '@angular/core';
import { environment } from './../../environments/environment';
import { Router, RouterEvent, NavigationEnd } from '@angular/router';
import { filter } from 'rxjs/operators';

// Init for the web
import "@capacitor-community/firebase-analytics";

import { Plugins } from "@capacitor/core";
const { FirebaseAnalytics, Device } = Plugins;

@Injectable({
  providedIn: 'root'
})
export class AnalyticsService {
  analyticsEnabled = true;

  constructor( private router: Router) {
    this.initFb();
    this.router.events.pipe(
      filter((e: RouterEvent) => e instanceof NavigationEnd),
    ).subscribe((e: RouterEvent) => {
      console.log('route changed: ', e.url);
      this.setScreenName(e.url)
    });
  }

  async initFb() {
    if ((await Device.getInfo()).platform == 'web') {
      FirebaseAnalytics.initializeFirebase(environment.firebaseConfig);
    }
  }

  setUser() {
    // Use Firebase Auth uid
    FirebaseAnalytics.setUserId({
      userId: "test_123",
    });
  }

  setProperty() {
    FirebaseAnalytics.setUserProperty({
      name: "framework",
      value: "angular",
    });
  }

  logEvent() {
    FirebaseAnalytics.logEvent({
      name: "login",
      params: {
        method: "email"
      }
    });
  }

  setScreenName(screenName) {
    FirebaseAnalytics.setScreenName({
      screenName
    });
  }

  toggleAnalytics() {
    this.analyticsEnabled = !this.analyticsEnabled;
    FirebaseAnalytics.setCollectionEnabled({
      enabled: this.analyticsEnabled,
    });    
  }
  
}

The rest of the implementation is pretty boring since we just need a way to call our functions, so let’s quickly change the home/home.page.ts to:

import { Component } from '@angular/core';
import { AnalyticsService } from '../services/analytics.service';

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

  constructor(private analyticsService: AnalyticsService) { }

  setUser() {
   this.analyticsService.setUser();
  }

  setProperty() {
    this.analyticsService.setProperty();
  }

  logEvent() {
    this.analyticsService.logEvent();
  }


  toggleDataCollection() {
    this.analyticsService.toggleAnalytics();
    this.enabled = this.analyticsService.analyticsEnabled;
  }

}

And finally the view with the buttons to call our actions inside the home/home.page.html:

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

<ion-content class="ion-padding">
  
  <ion-button expand="full" (click)="setUser()">Set User ID</ion-button>
  <ion-button expand="full" (click)="setProperty()">Set User Property</ion-button>
  <ion-button expand="full" (click)="logEvent()">Log Event</ion-button>
  <ion-button expand="full" routerLink="/second">Go to second page</ion-button>
  <ion-button expand="full" (click)="toggleDataCollection()">Toggle Data collection</ion-button>

  Analytics enabled: {{ enabled }}

</ion-content>

Additionally I have added a back button on the second page we created – I’m quite sure you know how to do that yourself, don’t you?

Testing Your App

The testing is what took me the most time, since it’s a bit tricky. If you use the web/native app in the standard way, it will take quite some time until you see any logs inside Firebase.

The reason is that usually the logs are sent as a batch every few hours to save battery life (actually kudos for this).

But there is actually a DebugView inside the Firebase menu which you can select to see debug logs. The logs you see in here don’t count towards the other “real” statistics you see inside the Firebase console!

firebase-analytics-debug

To see your logs appear in here, you need to follow a different approach on each platform.

You can also find all the debug information in the official docs.

Web

For the web, you don’t get any response of the plugin per default so you need to apply a trick: Go to the Chrome store and install the Google Analytics Debugger extension.

After doing so, you get a little icon next to your address bar and after enabling it, you should see a lot of logs for each action you take with the plugin!

iOS

To start your iOS app in debug mode, all we need to do is add a flag to the launch arguments of your app. You can do this within Xcode by going to Product -> Scheme -> Edit Scheme in the top bar, and then add this flag to the launch arguments within the Run section:

-FIRDebugEnabled

After setting the flag it should look like in the image below.

firebase-ios-testing-flag

Once you don’t want a debug app anymore, you explicitly need to set the opposite of the flag, which is this:

-FIRDebugDisabled

Android

All we need to to for Android to enable the debug mode is calling this from your command line while your device is connected:

adb shell setprop debug.firebase.analytics.app <package_name>

# To later disable it
adb shell setprop debug.firebase.analytics.app .none.

Make sure you are using the package name of your app, so for me it was “com.devdactic.analytics”.

A quick addition: When creating a native Android build I encountered an error and the answer in this issue was my fix. I simply set the value inside android/variables.gradle to this:

firebaseMessagingVersion =  '20.1.6'

This might not be relevant in the future, but wanted to keep it in here in case you encounter that problem!

Conclusion

Firebase analytics is easy to set up ad one of the best ways to track engagement and events within your Ionic app if you already use Firebase anyway.

If you would like to see a future post on other remote logging services let me know below in the comments!

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

The post How to Use Firebase Analytics with Ionic appeared first on Devdactic - Ionic Tutorials.

How to Create an Ionic PWA with Web Push Notifications

$
0
0

If you plan to build a website or PWA with Ionic, you can’t rely on standard push notifications. But instead, you can easily integrate web push instead!

The integration of web push notifications is super easy with Firebase, but we need to configure a few things upfront if you want to make this work in a PWA.

ionic-pwa-web-push

We will take all of the steps together and finally send push notifications through Firebase to our Ionic app/website in the end.

Disclaimer

If you are super excited now I must tell you that this only works on specific browsers right now. And with that I mean it doesn’t work inside Safari.

That means, the whole benefit of push on the web and PWA won’t work on iOS at all. The push API is simply not included in Safari as of now, so the only chance for iOS push notifications is a native app. You can also see that it’s not even working inside a regular Safari browser on a Mac.

Setting up Firebase Messaging

But that doesn’t mean the functionality is obsolete, we can make this work on Android and other web browsers anyway, so let’s start a new Ionic app, add the schematics for PWA and Angular Fire and finally generate a service for our messaging logic:

ionic start devdacticPush blank --type=angular --capacitor
cd ./devdacticPush
ng add @angular/pwa
ng add @angular/fire

ionic g service services/messaging

Now the configuration begins.. First of all you need a Firebase project, and from the the configuration object.

Check out my other tutorials on how to get that information.

With that information, go ahead and update your environments/environment.ts to include the configuration:

export const environment = {
  production: false,
  firebase: {
    apiKey: "",
    authDomain: "",
    databaseURL: "",
    projectId: "",
    storageBucket: "",
    messagingSenderId: "",
    appId: ""
  }
};

To make Firebase messanging work, we need to create a new file and include our information in here again.
Go ahead and create a file at src/firebase-messaging-sw.js(exactly the name please!):

importScripts('https://www.gstatic.com/firebasejs/7.16.1/firebase-app.js');
importScripts('https://www.gstatic.com/firebasejs/7.16.1/firebase-messaging.js');

firebase.initializeApp({
    apiKey: '<your-key>',
    authDomain: '<your-project-authdomain>',
    databaseURL: '<your-database-URL>',
    projectId: '<your-project-id>',
    storageBucket: '<your-storage-bucket>',
    messagingSenderId: '<your-messaging-sender-id>'
});

const messaging = firebase.messaging();

It’s important to use the same Firebase version in this file like in your package.json! In this example, the version would be 7.16.1.

Now things get tricky: Because we already added the PWA schematic, Angular injected a default service worker. But we need also need the service worker for Firebase messaging, so the solution is to have a combined file.

So create a new file at src/combined-sw.js and then simply import both service workers like this:

importScripts('ngsw-worker.js');
importScripts('firebase-messaging-sw.js');

To include these 2 new files, we also need to apply a little change to the angular.json so quite at the top inside the assets array, find the webmanifest entry and add your two new service workers below:

"assets":[
  ..
 "src/manifest.webmanifest",
 "src/combined-sw.js",
 "src/firebase-messaging-sw.js"
]

Almost done… We now also need to add a generic ID to our src/manifest.webmanifest, which is in fact the same for every app as it’s the GCM ID of Google. Found this strange, but it’s correct!

Therefore, also open the src/manifest.webmanifest and add this line:

"gcm_sender_id": "103953800507",

Last step is to change the main module file of our app so we can load our new combined service worker instead of the default Angular service worker.

Besides that we initialise AngularFire and the messaging module within our app, so go ahead and change the app/app.module.ts to this:

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router';

import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
import { SplashScreen } from '@ionic-native/splash-screen/ngx';
import { StatusBar } from '@ionic-native/status-bar/ngx';

import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
import { ServiceWorkerModule } from '@angular/service-worker';
import { environment } from '../environments/environment';

import { AngularFireModule } from '@angular/fire';
import { AngularFireMessagingModule } from '@angular/fire/messaging';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [
    BrowserModule,
    IonicModule.forRoot(),
    AppRoutingModule,
    ServiceWorkerModule.register('combined-sw.js', {
      enabled: environment.production,
    }),
    AngularFireModule.initializeApp(environment.firebase),
    AngularFireMessagingModule,
  ],
  providers: [
    StatusBar,
    SplashScreen,
    { provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}

Now everything is configured and we can finally implement web push notifications!

Listening for Push Notifications

There are two scenarios: Your app/website/PWA is in the foreground, or it is closed/ in the background. For the first case, we can subscribe to a messages object of AngularFire and always catch incoming push messages.

The other case is actually not that easy, since it’s treated by the Firebase service worker we added to our project.

Also, we need a token of the user so we can target those notifications, which we do by calling requestToken which will also at the same time ask for user permission to push notifications. You could also do this in two separate calls actually!

You should also include functionality to delete the token once the user doesn’t want any notifications more, so go ahead and open the services/messaging.service.ts now and insert:

import { Injectable } from '@angular/core';
import { AngularFireMessaging } from '@angular/fire/messaging';
import { tap } from 'rxjs/operators'

@Injectable({
  providedIn: 'root'
})
export class MessagingService {
  token = null;
  
  constructor(private afMessaging: AngularFireMessaging) {}

  requestPermission() {
    return this.afMessaging.requestToken.pipe(
      tap(token => {
        console.log('Store token to server: ', token);
      })
    );
  }

  getMessages() {
    return this.afMessaging.messages;
  }

  deleteToken() {
    if (this.token) {
      this.afMessaging.deleteToken(this.token);
      this.token = null;
    }
  }
}

In this tutorial we don’t handle storking the token, we will just use it in our tests from the logs.

In a real world application, you would store this token on the server and later retrieve the token for a user if you want to send a push.

Now we can put this service to use within our page, and we simply subscribe to the different functions and display some toasts and alerts about the data. We will see at the end of this tutorial why we can use certain information from the received push inside our listenForMessages and how they are structured, for now simply go ahead and change the home/home.page.ts to:

import { Component } from '@angular/core';
import { MessagingService } from '../services/messaging.service';
import { AlertController, ToastController } from '@ionic/angular';

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

  constructor(
    private messagingService: MessagingService,
    private alertCtrl: AlertController,
    private toastCtrl: ToastController
  ) {
    this.listenForMessages();
  }

  listenForMessages() {
    this.messagingService.getMessages().subscribe(async (msg: any) => {
      const alert = await this.alertCtrl.create({
        header: msg.notification.title,
        subHeader: msg.notification.body,
        message: msg.data.info,
        buttons: ['OK'],
      });

      await alert.present();
    });
  }

  requestPermission() {
    this.messagingService.requestPermission().subscribe(
      async token => {
        const toast = await this.toastCtrl.create({
          message: 'Got your token',
          duration: 2000
        });
        toast.present();
      },
      async (err) => {
        const alert = await this.alertCtrl.create({
          header: 'Error',
          message: err,
          buttons: ['OK'],
        });

        await alert.present();
      }
    );
  }

  async deleteToken() {
    this.messagingService.deleteToken();
    const toast = await this.toastCtrl.create({
      message: 'Token removed',
      duration: 2000
    });
    toast.present();
  }
}

The last part is a UI to trigger our dummy functions, so change the home/home.page.html to this:

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>
      Ionic Web Push
    </ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-button expand="full" (click)="requestPermission()">Request Permission</ion-button>
  <ion-button expand="full" (click)="deleteToken()">Delete Token</ion-button>
</ion-content>

You can now run the app on your browser and start the testing when you receive a token in the log.

Testing Push Notifications

With the token of a user we can generate a targeted push notification, and you can try it with the browser open or closed to see the different results.

There are also different ways to call the API of Firebase to create a push, one would be to use a tool like Postman or Insomnia.

Simply set the authorization header of the call to the server key that you can grab from the the Cloud Messaging tab of your Firebase project:

Authorization: key=<YOUR-FIREBASE-SERVER-KEY>

Now you just need to fill the body of your POST call with some information, and you can use this for testing:

{
    "notification": {
          "title": "Practical Ionic", 
          "body": "Check out my book!",
          "icon": "https://i2.wp.com/devdactic.com/wp-content/uploads/2020/07/practical-ionic-book.png",
          "click_action":"https://devdactic.com/practical-ionic/"
    },
    "data": {
        "info": "This is my special information for the app!"
    },
    "to": "<USER-PUSH-TOKEN>"
}

Send the notification, and you should see the alert (if the app is open) or a system notification for a new message!

If you are more of a CLI type, then simply go with the below curl statement:

curl -X POST \
  https://fcm.googleapis.com/fcm/send \
  -H 'Authorization: key=<YOUR-FIREBASE-SERVER-KEY>' \
  -H 'Content-Type: application/json' \
  -d '{ 
 "notification": {
"title": "Practical Ionic", 
  "body": "Check out my book!",
  "icon": "https://i2.wp.com/devdactic.com/wp-content/uploads/2020/07/practical-ionic-book.png",
  "click_action":"https://devdactic.com/practical-ionic/"
 },
 "to" : "<USER-PUSH-TOKEN>"
}'

The result should be basically the same, and even on a device (Android only) you see the notification like a real push!

ionic-web-push-android

To get this result, you should deploy your PWA somewhere.

Firebase PWA Deployment

The easiest way to test your PWA is by uploading it to Firebase hosting, which can be done in minutes given your already existing project.

Simply init the connection in your app now by running:

firebase init

For the questions, you need to answer like this:

  • “Which Firebase CLI features do you want to set up for this folder?” -> “Hosting: Configure and deploy Firebase Hosting sites.”
  • “Select a default Firebase project for this directory:” -> Select your Firebase project.
  • “What do you want to use as your public directory?” -> “www”.

Now we just need to set the right caching headers for our PWA by changing the firebase.json to:

{
  "hosting": {
    "public": "www",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ],
    "headers": [
      {
        "source": "/build/app/**",
        "headers": [
          {
            "key": "Cache-Control",
            "value": "public, max-age=31536000"
          }
        ]
      },
      {
        "source": "ngsw-worker.js",
        "headers": [
          {
            "key": "Cache-Control",
            "value": "no-cache"
          }
        ]
      }
    ]
  }
}

Your app is now configured, and whenever you want to build and deploy a new version to Firebase hosting, simply run:

ionic build --prod
firebase deploy

You should see the URL to your app on the command line, or you can also check the hosting tab of your Firebase project in the browser for more information!

Conclusion

Implementing web push notifications isn’t hard, but there are some limitations to this. You won’t have the functionality on iOS (as of now) and in general, these kind of notifications and popups for permission can be really annyoing.

So use this feature wisely and only when it’s really adding value for the users!

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

The post How to Create an Ionic PWA with Web Push Notifications appeared first on Devdactic - Ionic Tutorials.

Ionic 5 App Navigation with Login, Guards & Tabs Area

$
0
0

Most apps require a certain kind of boilerplate code, with login, introduction page, routing and security – and that’s what we will create in this tutorial.

We will start with a tabs template from Ionic and use Capacitor to store some data. Additionally we will secure the routing in our app and use a dummy JWT from the reqres.in API.

ionic-5-navigation-with-login
At the end you’ll have a complete understanding about routing, securing your app, using guards and reactive forms and all the basics to start your next Ionic app!

If you need help with boilerplate more often, also check out my tool KickoffIonic.

Starting our Ionic 5 App Navigation

As promised, we start with a tabs template today so we can save a bit of time. We generate a service that will keep our authentication logic, additional pages for the introduction and login screen, and guards to add some cool functionality to our app:

ionic start devdacticApp tabs --type=angular --capacitor
cd ./devdacticApp

# Manage your authentication state (and API calls)
ionic g service services/authentication

# Additional Pages
ionic g page pages/intro
ionic g page pages/login

# Secure inside area
ionic g guard guards/auth --implements CanLoad

# Show intro automatically once
ionic g guard guards/intro --implements CanLoad

# Automatically log in users
ionic g guard guards/autoLogin --implements CanLoad

We can actually tell the CLI already which interface inside the guard should be implemented, which will come in very handy later.

Because we will do a little dummy HTTP request, also add the according module to 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 { SplashScreen } from '@ionic-native/splash-screen/ngx';
import { StatusBar } from '@ionic-native/status-bar/ngx';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';

import { HttpClientModule } from '@angular/common/http';

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

We will use the Capacitor Storage plugin so we don’t need any additional Cordova plugin or other library.

Routing Setup

Our routing file is a bit messed up from the generation of additional pages, and we also want to add a few guards to our pages. We have the following guards for specific reasons:

  • IntroGuard: Check if the user has already seen the intro and show the page if not
  • AutoLoginGuard: Automatically log in a user on app startup if already authenticated before
  • AuthGuard: Secure the internal pages of your app

We can use multiple guards for a page, and we use canLoad instead of canActivate since this will protect the whole lazy loaded module and not even load the file if the user is not allowed to access a path!

By securing the /tabs path at the top level, we also automatically secure every other path and page that you might later add to your tabs routing, since the guard will always be applied in that case.

Go ahead now and change the src/app/app-routing.module.ts to:

import { AuthGuard } from './guards/auth.guard';
import { NgModule } from '@angular/core';
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';
import { IntroGuard } from './guards/intro.guard';
import { AutoLoginGuard } from './guards/auto-login.guard';

const routes: Routes = [
  {
    path: 'login',
    loadChildren: () => import('./pages/login/login.module').then( m => m.LoginPageModule),
    canLoad: [IntroGuard, AutoLoginGuard] // Check if we should show the introduction or forward to inside
  },
  {
    path: 'intro',
    loadChildren: () => import('./pages/intro/intro.module').then( m => m.IntroPageModule)
  },
  {
    path: 'tabs',
    loadChildren: () => import('./tabs/tabs.module').then(m => m.TabsPageModule),
    canLoad: [AuthGuard] // Secure all child pages
  },
  {
    path: '',
    redirectTo: '/login',
    pathMatch: 'full'
  }
];
@NgModule({
  imports: [
    RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })
  ],
  exports: [RouterModule]
})
export class AppRoutingModule {}

Since we renamed the paths a bit we now come to the tabs routing file with just an empty path, beacuse the “tabs” part of the URL will already be resolved. Check out my Angular routing tutorial for more information about routing as well.

Therefore, we remove the parent path and slightly change the redirect logic inside the src/app/tabs/tabs-routing.module.ts now as well:

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { TabsPage } from './tabs.page';

const routes: Routes = [
  {
    path: '',
    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: '',
        redirectTo: '/tabs/tab1',
        pathMatch: 'full'
      }
    ]
  }
];

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

We’ll see how each of the different guards work along the tutorial, for now we start with the introduction logic.

Creating the Introduction Page

Of course we could also make the tutorial page simply the first page, but usually you don’t want to bore your users more often than necessary with this page.

Therefore the idea is to use a guard that checks if we’ve already seen the tutorial and shows it if not. Otherwise, the guard will return true and the page the user wanted to access will be shown as usual.

Within our routing we have only added this guard to the login page, but of course it could be added to other pages as well. But since the login is the very first page that would be shown when the app is opened on a device, this is the place where it really makes sense.

The guard will make use of the Capacitor Storage plugin to check for a certain key, which is actually a string and not a boolean. This API only allows to store arbitrary strings, but that’s fine for us.

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

import { Injectable } from '@angular/core';
import { CanLoad, Router } from '@angular/router';
import { Plugins } from '@capacitor/core';
const { Storage } = Plugins;

export const INTRO_KEY = 'intro-seen';

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

  constructor(private router: Router) { }

  async canLoad(): Promise {
      const hasSeenIntro = await Storage.get({key: INTRO_KEY});      
      if (hasSeenIntro && (hasSeenIntro.value === 'true')) {
        return true;
      } else {
        this.router.navigateByUrl('/intro', { replaceUrl:true });
        return false;
      }
  }
}

So if needed, we directly transition to the introduction without even showing the login page. That means, no flash of a page you don’t want to present!

Within the introduction we will add some slides, which are very common to present your apps features. You can even access the slides element from code as a ViewChild to interact directly with them to e.g. scroll to the next/previous slide.

Once the users finishes the introduction, we will write the according key to our storage and move on to our login page. From that point on, the user won’t see that page again!

Open the src/app/pages/intro/intro.page.ts and change it to:

import { Component, OnInit, ViewChild } from '@angular/core';
import { IonSlides } from '@ionic/angular';
import { INTRO_KEY } from 'src/app/guards/intro.guard';
import { Router } from '@angular/router';
import { Plugins } from '@capacitor/core';
const { Storage } = Plugins;

@Component({
  selector: 'app-intro',
  templateUrl: './intro.page.html',
  styleUrls: ['./intro.page.scss'],
})
export class IntroPage implements OnInit {
  @ViewChild(IonSlides)slides: IonSlides;

  constructor(private router: Router) { }

  ngOnInit() {
  }

  next() {
    this.slides.slideNext();
  }

  async start() {
    await Storage.set({key: INTRO_KEY, value: 'true'});
    this.router.navigateByUrl('/login', { replaceUrl:true });
  }
}

Now we just need a cool UI for the introduction, and we will make the slides cover the whole screen and also use a background image. But from HTML, the only thing we need are the basics and the buttons to trigger our actions, so change the src/app/pages/intro/intro.page.html to:

<ion-content>
  <ion-slides pager="true">

    <!-- Slide 1 -->
    <ion-slide>
      <ion-text color="light">
        <img src="https://i1.wp.com/ionicacademy.com/wp-content/uploads/2019/10/async-code-course.png">
        <h1>Hey there!</h1>
        <p>This is an epic app because...</p>
        <ion-button (click)="next()" fill="outline" color="light">Next</ion-button>
      </ion-text>
    </ion-slide>

    <!-- Slide 2 -->
    <ion-slide>
      <ion-text color="light">
        <img src="https://i1.wp.com/ionicacademy.com/wp-content/uploads/2020/06/ionic-everywhere-course.png">
        <h1>It's Ionic!</h1>
        <p>... it shows all the basics you need!</p>
        <ion-button (click)="start()" fill="outline" color="light">Start</ion-button>
      </ion-text>
    </ion-slide>

  </ion-slides>
</ion-content>

Now this won’t look that good out of the box, but with a few lines of CSS inside the src/app/pages/intro/intro.page.scss everything becomes a lot better:

ion-content {
    --background: #0081ca;
}

ion-slides {
    height: 100%;
    --bullet-background-active: #fff;
    --bullet-background: #000;
}

Now you can server your app and the first thing you should see is the introduction, which at the end guides you to the login.

ionic-full-navigation-intro

If you reload the app at that point, the introduction won’t be shown again since the guard evaluates that we’ve already seen the intro.

Creating the Authentication Logic

Now on to the login, which will use a dummy implementation for retrieving a token. Usually you would plug in your own API, but by doing it like this we can actually replicate a real world scenario pretty accurate.

If you are building a real app check out Practical Ionic, my book to help you build more complex apps!

Our service will also hold a BehaviorSubject, which we initialise with null in the beginning for a reason: Our guard will later catch this very first value, which doesn’t indicate a lot about the authentication state of a user. Therefore, we can later easily filter out this value in the guard so users can directly access pages of our app if they were previously authenticated.

When we request a token after login, we will store it locally (with a bit of RxJS fun since Storage returns a Promise and we need an Observable) and also set the new value to our behaviour subject.

After that, everyone calling the subject would get the latest value, which is true after a login and set back to false in the case of a logout.

Continue with the src/app/services/authentication.service.ts and change it to:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { map, tap, switchMap } from 'rxjs/operators';
import { BehaviorSubject, from, Observable, Subject } from 'rxjs';

import { Plugins } from '@capacitor/core';
const { Storage } = Plugins;

const TOKEN_KEY = 'my-token';

@Injectable({
  providedIn: 'root'
})
export class AuthenticationService {
  // Init with null to filter out the first value in a guard!
  isAuthenticated: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(null);
  token = '';

  constructor(private http: HttpClient) {
    this.loadToken();
  }

  async loadToken() {
    const token = await Storage.get({ key: TOKEN_KEY });    
    if (token && token.value) {
      console.log('set token: ', token.value);
      this.token = token.value;
      this.isAuthenticated.next(true);
    } else {
      this.isAuthenticated.next(false);
    }
  }

  login(credentials: {email, password}): Observable<any> {
    return this.http.post(`https://reqres.in/api/login`, credentials).pipe(
      map((data: any) => data.token),
      switchMap(token => {
        return from(Storage.set({key: TOKEN_KEY, value: token}));
      }),
      tap(_ => {
        this.isAuthenticated.next(true);
      })
    )
  }

  logout(): Promise<void> {
    this.isAuthenticated.next(false);
    return Storage.remove({key: TOKEN_KEY});
  }
}

Now we got all the logic in place in the background so we can build the login and later secure the pages of our app.

Building a User Login Page

Since we want to build a cool login process with decent error messages, we are going to use a reactive form. And the first step to use it is to add it to the module of the page, in our case that’s the src/app/pages/login/login.module.ts:

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

Now we will use a simply form with a few validators, that we will access from the template as well. In order to get the form elements more easily later we also define two getter functions for them at the bottom of the class.

Within the actual login we will use our service from before and handle the outcome: Either navigate forward or display an alert.

Notice how we set the replaceUrl key as an extra for the navigation! This prevents the case where users can simply navigate back in the history of your application on Android, and we’ll use it in a few places where it fits.

It’s basically the same as calling a setRoot() on the Ionic navigation controller, which also simply proxies the Angular router.

Therefore continue with the src/app/pages/login/login.page.ts now:

import { AuthenticationService } from './../../services/authentication.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 authService: AuthenticationService,
    private alertController: AlertController,
    private router: Router,
    private loadingController: LoadingController
  ) {}

  ngOnInit() {
    this.credentials = this.fb.group({
      email: ['eve.holt@reqres.in', [Validators.required, Validators.email]],
      password: ['cityslicka', [Validators.required, Validators.minLength(6)]],
    });
  }

  async login() {
    const loading = await this.loadingController.create();
    await loading.present();
    
    this.authService.login(this.credentials.value).subscribe(
      async (res) => {
        await loading.dismiss();        
        this.router.navigateByUrl('/tabs', { replaceUrl: true });
      },
      async (res) => {
        await loading.dismiss();
        const alert = await this.alertController.create({
          header: 'Login failed',
          message: res.error.error,
          buttons: ['OK'],
        });

        await alert.present();
      }
    );
  }

  // Easy access for form fields
  get email() {
    return this.credentials.get('email');
  }
  
  get password() {
    return this.credentials.get('password');
  }
}

I’ve added some default values, which are the combination for a successful login request – change them and the request will fail (you hear that Galadriel voice?)!

We have prepared the form code, now we just need an according view around it. The form elements itself are not very special, but for each field we can check if it was touched or dirty, which basically means the user has interacted with the field.

You don’t want to show all possible errors to your users before any interaction happened!

It’s possible to check for specific errors of a field, and that’s what we check inside those blocks to show specific error messages for different errors, which occur because we added the validators to these form elements in the step before.

Additionally I added several buttons without functionality, but always make sure to use the type=button on buttons inside a form that shouldn’t trigger the submit – that type is only used on the button that should really trigger the submit action.

With that information, go ahead and change the src/app/pages/login/login.page.html:

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

<ion-content>

  <form (ngSubmit)="login()" [formGroup]="credentials">
    <div class="input-group">
      <ion-item>
        <ion-input type="email" placeholder="Email" formControlName="email"></ion-input>
      </ion-item>
      <div *ngIf="(email.dirty || email.touched) && email.errors" class="errors">
        <span *ngIf="email.errors?.required">Email is required</span>
        <span *ngIf="email.errors?.email">Email is invalid</span>
      </div>
      <ion-item>
        <ion-input type="password" placeholder="Password" formControlName="password"></ion-input>
      </ion-item>
      <div *ngIf="(password.dirty || password.touched) && password.errors" class="errors">
        <span *ngIf="password.errors?.required">Password is required</span>
        <span *ngIf="password.errors?.minlength">Password needs to be 6 characters</span>
      </div>
    </div>

    <ion-button type="submit" expand="block" [disabled]="!credentials.valid">Log in</ion-button>
    <ion-button type="button" expand="block" color="light" fill="clear">Not yet a member? Sign up!
    </ion-button>

    <ion-button type="button" expand="block" color="tertiary">
      <ion-icon name="logo-google" slot="start"></ion-icon>
      Sign in with Google
    </ion-button>
    <ion-button type="button" expand="block" color="tertiary">
      <ion-icon name="logo-apple" slot="start"></ion-icon>
      Sign in with Apple
    </ion-button>
  </form>
</ion-content>

This doesn’t look very special yet, but you can spice up basically every view in about minutes with some additional CSS. For example, we could define a background image, use some rounded borders and colors for distinction like this inside the src/app/pages/login/login.page.scss:

ion-content {
    --padding-top: 40%;
    --padding-start: 10%;
    --padding-end: 10%;
    --background: url('https://images.unsplash.com/photo-1536431311719-398b6704d4cc?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1234&q=80') 0 0/100% 100% no-repeat;
}

.input-group {
    background: #fff;
    border-radius: 10px;
    overflow: hidden;
    margin-bottom: 15px;
}

.errors {
    font-size: small;
    color: #fff;
    background: var(--ion-color-danger);
    padding-left: 15px;
    padding-top: 5px;
    padding-bottom: 5px;
}

Now we’ve got a lovely login screen that should bring us to the inside area upon successful login!

ionic-full-navigation-login
I’ve left out the signup screen, but I’m quite sure you are capable of adding the page and building it almost like the login with this information.

Adding the Logout

This part is actually quite boring but necessary to test out our further security features. We’ve already created a logout inside the services previously, so let’s now just add a function ton the first page of the inside area, which is the src/app/tab1/tab1.page.ts:

import { AuthenticationService } from './../services/authentication.service';
import { Component } from '@angular/core';
import { Router } from '@angular/router';

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

  constructor(private authService: AuthenticationService, private router: Router) {}

  async logout() {
    await this.authService.logout();
    this.router.navigateByUrl('/', { replaceUrl: true });
  }
}

To call that, let’s just add a button to the src/app/tab1/tab1.page.html and we are good to go:

<ion-header>
  <ion-toolbar>
    <ion-title>
      Tab 1
    </ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-button expand="full" (click)="logout()">Logout</ion-button>
</ion-content>

The logout will bring you back to the login, but what you notice is that you can e.g. directly navigate to the URL http://localhost:8100/tabs/tab1/ although you are not authenticated.

That’s actually no surprise because we haven’t added the code for our guard that should protect our inside are.

Protecting Pages with the Auth Guard

In the beginning we used a guard to perform a quick check if we’ve seen the tutorial already, now we want to check if a user is allowed to enter a certain page.

That means, within the guard we need to check the authentication state from our service, and because that would be way too easy, there’s a bit more complexity in here:

The initial value of the behaviour subject inside the service is null. If we would simply retrieve that value inside the guard, any direct access of a page (like inside a web application) would be forbidden since the guard assumes an unauthenticated user.

It’s not a huge deal for a mobile application, but if you are using it on the web, or later direct links to open pages of your mobile app, this is a very annoying behaviour.

Therefore, we filter out the null value inside the guard and only then check the authentication state.

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

import { AuthenticationService } from './../services/authentication.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 authService: AuthenticationService, private router: Router) { }

  canLoad(): Observable<boolean> {    
    return this.authService.isAuthenticated.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('/login')
          return false;
        }
      })
    );
  }
}

There are also cases when your guard evaluates to true, but the app simply doesn’t change: In these cases, the problem is usually that the Observable is not complete.

Basically, the guard is still listening and doesn’t end, but we have fixed this by using take(1), which will take only one result and then complete the Observable!

Now you can log in, directly access any of the tabs pages with a URL like http://localhost:8100/tabs/tab1, and after a logout, those pages aren’t accessible anymore and you would be redirected to the login.

Very simple mechanism, but powerful to protect the inside area of your application.

Automatic Login

If the user was previously authenticated and then closes the app, the user would still start on the login page again right now. But we can actually automatically forward authenticated users to the inside area with a little trick!

We will use our third guard as a check if the current user is authenticated using basically the same logic like the previous authentication guard.

But this time, we will handle the result a bit differently and forward authenticated users to the inside area.

Go ahead and change our last pipe inside the src/app/guards/auto-login.guard.ts to:

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

@Injectable({
  providedIn: 'root'
})
export class AutoLoginGuard implements CanLoad {
  constructor(private authService: AuthenticationService, private router: Router) { }

  canLoad(): Observable<boolean> {    
    return this.authService.isAuthenticated.pipe(
      filter(val => val !== null), // Filter out initial Behaviour subject value
      take(1), // Otherwise the Observable doesn't complete!
      map(isAuthenticated => {
        console.log('Found previous token, automatic login');
        if (isAuthenticated) {
          // Directly open inside area       
          this.router.navigateByUrl('/tabs', { replaceUrl: true });
        } else {          
          // Simply allow access to the login
          return true;
        }
      })
    );
  }
}

This guard was only added to our login page in the beginning, but could (like the other guards) be used in different places as well.

But now, every authenticated users will be logged in directly!

Conclusion

This tutorial was a basic example for a very common use case, an introduction with login screen before tabs navigation.

With a simple routing setup and helpful guards, you have by now already a powerful template for the rest of your application!

You can also check out a video version of this tutorial below.

The post Ionic 5 App Navigation with Login, Guards & Tabs Area appeared first on Devdactic - Ionic Tutorials.

The Essential Ionic Image Zoom Guide (Modal & Cards)

$
0
0

Adding Ionic image zoom is often a requirement in apps, but the implementation isn’t very well documented. In this tutorial we will implement a simple Ionic image zoom based on the Ionic slides!

For years the ion-slides element has been the best place to implement zoom, but of course that’s not really clear upfront when you are looking for a solution.

Therefore we will build a simple image gallery and add a modal overlay to present our images with zoom, both from touch events and from code.

ionic-image-zoom-guide

On top of that we will implement an Instagram like zoom behaviour with images inside a feed, which is more challenging but the result is going to be epic!

All of this is based on standard Ionic components, so we don’t need any external package.

Starting the Ionic Image Zoom App

Let’s start a fresh app and enable Capacitor because by now you should really start new apps like this! It doesn’t matter for this tutorial, but I still recommend it.

The only additional thing we need is a page, which we can generate inside our project right after bootstrapping it:

ionic start imageGallery blank --type=angular --capacitor
cd ./imageGallery
ionic g page imageModal

Since we want to show images, take some of your and add them to the assets/img folder inside your Ionic project (you need to create the img folder). I have named the images like 1.png, 2.png for this tutorial, which you should do for now.

Preparing an Image Gallery

First of all we create a gallery to display images with horizontal scroll. To do so, we use the slides component and iterate over an array of numbers, which are used to get the path to each of our images.

Each image can be clicked to open the modal preview, so for now change the home/home.page.html to:

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>
      Ionic Image Zoom
    </ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-slides [options]="sliderOpts" class="preview-slides">
    <ion-slide *ngFor="let img of [1, 2, 3, 4]">
      <img src="assets/img/{{ img }}.png" tappable (click)="openPreview(img)">
    </ion-slide>
  </ion-slides>
</ion-content>

This doesn’t look like a gallery out of the box, but we have already used one of the slider properties, in which we can pass a configuration for the underlying swiper!

By passing in a configuration you can customise the whole slides component, top show multiple elements on one page, center them or define padding..Basically everything!

So within our home/home.page.ts we define the options and add the function to open our modal:

import { Component, ChangeDetectorRef } from '@angular/core';
import { ImageModalPage } from '../image-modal/image-modal.page';
import { ModalController, IonSlides } from '@ionic/angular';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss']
})
export class HomePage {
  sliderOpts = {
    zoom: false,
    slidesPerView: 1.5,
    spaceBetween: 20,
    centeredSlides: true
  };

  constructor(private modalController: ModalController, private changeDetectorRef: ChangeDetectorRef) { }
 
  async openPreview(img) {
    const modal = await this.modalCtrl.create({
      component: ImageModalPage,
      cssClass: 'transparent-modal',
      componentProps: {
        img
      }
    });
    modal.present();
  }
}

When we open the modal page, we pass the image we clicked on through and also attach a custom class so we can make it transparent later on.

Additionally we can add a bit of styling for the slides of our page like this inside the home/home.page.scss:

.preview-slides {
  margin-top: 20%;
  background: #e6e6e6;

  img {
      padding-top: 20px;
      padding-bottom: 20px;
  }
}

The custom CSS class we added to the modal needs to be defined at the top of our application, so open the global.scss and add:

.transparent-modal {
    .modal-wrapper {
        --background: rgba(44, 39, 45, 0.2);
    }
}

The modal component exists above your Ionic application, therefore you can’t add this styling to the page where you create the modal!

Now you should be able to scroll through your images inside the gallery and open the modal. Time to implement the Ionic image zoom!

Creating an Ionic Image Zoom Modal

There is a section inside the Swiper API about image zoom, which is all we need!

To zoom images within the Ionic slides, we need to add another element with the class swiper-zoom-container – that’s basically everything.

We also need to pass in options for the slides again from which we will enable the zoom functionality in the next step. I’ve also added a few buttons inside the footer of the page so we can later try and zoom into our images from code and leave the modal.

For now go ahead and change the image-modal/image-modal.page.html to:

<ion-content>
  <ion-slides [options]="sliderOpts">
    <ion-slide>
      <div class="swiper-zoom-container">
        <img src="assets/img/{{ img }}.png">
      </div>
    </ion-slide>
  </ion-slides>
</ion-content>

<ion-footer>
  <ion-row>
    <ion-col size="4" class="ion-text-center">
      <ion-button (click)="zoom(false)" fill="clear" color="light">
        <ion-icon name="remove" slot="start"></ion-icon>
        out
      </ion-button>
    </ion-col>
    <ion-col size="4" class="ion-text-center">
      <ion-button (click)="close()" fill="clear" color="light">
        <ion-icon name="close" slot="start"></ion-icon>
        close
      </ion-button>
    </ion-col>
    <ion-col size="4" class="ion-text-center">
      <ion-button (click)="zoom(true)" fill="clear" color="light">
        <ion-icon name="add" slot="start"></ion-icon>
        in
      </ion-button>
    </ion-col>
  </ion-row>
</ion-footer>

Again, almost same slider setup but this time with the additional container. Now we just need to enable zoom from the config, and implement the zoom from code.

To achieve this, we can directly access the Swiper element from our IonSlides with the getSwiper() function!

Because this call returns a Promise we need to handle it accordingly with async/await, but then we are able to access the zoom object inside the Swiper and call in() or out() depending on how we called our function.

Open the image-modal/image-modal.page.ts and change it to:

import { Component, OnInit, ViewChild, Input } from '@angular/core';
import { ModalController, IonSlides } from '@ionic/angular';
 
@Component({
  selector: 'app-image-modal',
  templateUrl: './image-modal.page.html',
  styleUrls: ['./image-modal.page.scss'],
})
export class ImageModalPage implements OnInit {
  @ViewChild(IonSlides) slides: IonSlides;
  @Input('img')img: any;

  sliderOpts = {
    zoom: true
  };
 
  constructor(private modalController: ModalController) { }
 
  ngOnInit() { }

  ionViewDidEnter(){
    this.slides.update();
  }
 
  async zoom(zoomIn: boolean) {
    const slider = await this.slides.getSwiper();
    const zoom = slider.zoom;
    zoomIn ? zoom.in() : zoom.out();
  }
 
  close() {
    this.modalController.dismiss();
  }
 
}

Because nothing looks awesome out of the box let’s quickly apply some styling inside the image-modal/image-modal.page.scss:

ion-content {
    --background: transparent;
}

ion-footer {
    margin-bottom: 10px;
}
 
ion-slides {
    height: 100%;
}

Now the modal looks perfectly transparent and we can zoom our image! Testing this works best on a device, I couldn’t really make zoom working inside a browser at this point.

Ionic Image Zoom with Advanced Styling

Now that we are Ionic image zoom novices, let’s take on another challenge: Simply zoom into any image inside a list/feed of cards!

This is a lot more tricky, because we need to perform different actions:

  • Allow zoom for images inside a card
  • Make the image pop out from the card by changing the overflow value
  • Display a background overlay when zoom starts, dismiss when it ends
  • Make the selected image appear above the backdrop

I recommend to check out the video below this tutorial if you want to see all the steps one by one to achieve this result!

First of all, we can implement a conditional backdrop that we will style later, and a card iteration with a slides element in front of the actual card content.

Since we need access to the card and the slides element, we also give them a template reference, which we use directly inside the functions touchstart and touchend on our zoom container!

These functions are actually the key to our whole image zoom functionality, because inside them we can change the styling of the element that we want to zoom in.

For now open the home/home.page.html and change the content area to:

<ion-content>
    <!-- Background drop while zoom -->
    <div *ngIf="zoomActive" class="backdrop" [style.opacity]="zoomScale"></div>

    <ion-card *ngFor="let img of [1, 2, 3, 4]" class="image-card" #card>
        <ion-slides class="image-slide" [options]="sliderZoomOpts" #zoomslides>
            <ion-slide>
                <div class="swiper-zoom-container" (touchstart)="touchStart(card)"
                    (touchend)="touchEnd(zoomslides, card)">
                    <img src="assets/img/{{ img }}.png">
                </div>
            </ion-slide>
        </ion-slides>

        <ion-card-content>
            Almost like Instagram
        </ion-card-content>
    </ion-card>
</ion-content>

The opacity of our backdrop will also change while we zoom into an image, so the zoomScale will be a value between 0 and 1 in the end.

Now we need a bigger configuration object for our slides, because we want to disallow any classic slide prev/next behaviour. On top of that we can define a max ratio for the zoom, so we can later divide the scale of the image inside the zoomChange by this value for our backdrop opacity (by now you get why this was tricky, huh).

This functions is called whenever our slides are zoomed, and returns both the current scale and and elements that we don’t need for this example. But we can change our zoomActive at this point so the zoom basically starts.

Additionally we will set the z index to a slightly higher value when we start to touch the element, and return the index back when the touch ends. As a quick z index overview in this example:

  • 9: All cards in normal mode
  • 10: Backdrop, so it’s above cards
  • 11: The card that we zoom, so it’s above the backdrop

Once the zoom ends (the touches end, there is no other zoom end callback apparently), we access the swiper like before and zoom out completely.

Now add this to your home/home.page.ts:

zoomActive = false;
zoomScale = 1;

sliderZoomOpts = {
  allowSlidePrev: false,
  allowSlideNext: false,
  zoom: {
    maxRatio: 5
  },
  on: {
    zoomChange: (scale, imageEl, slideEl) => {        
      this.zoomActive = true;
      this.zoomScale = scale/5;
      this.changeDetectorRef.detectChanges();         
    }
  }
}

async touchEnd(zoomslides: IonSlides, card) {
  // Zoom back to normal
  const slider = await zoomslides.getSwiper();
  const zoom = slider.zoom;
  zoom.out();

  // Card back to normal
  card.el.style['z-index'] = 9;

  this.zoomActive = false;
  this.changeDetectorRef.detectChanges();
}

touchStart(card) {
  // Make card appear above backdrop
  card.el.style['z-index'] = 11;
}

Finally, we need to add the according z-index to our elements and make sure the image can overflow the card. The backdrop has a full black background, but since we dynamically change the opacity this changes during zoom.

Add the following to your home/home.page.scss:

.image-slide,
.image-card {
    overflow: visible;
}

.image-card {
    z-index: 9;
}

.backdrop {
    height: 200%;
    width: 100%;
    background: black;
    position: absolute;
    z-index: 10;
}

There are only 2 minor issues:

First, the backdrop doesn’t really fill the whole content area. I’ve added a height of 200% in this example, but once your list becomes longer, you will notice it suddenly ends.

You might have to calculate the real content height and set this value as the height of the backdrop element instead.

Second, when we release the finger, the zoom goes back to deactivated and the backdrop is dismissed immediately. It’s a small detail, but you could try to add an Angular animation leave event and then hide the backdrop instead to make the whole process a bit more smooth.

Conclusion

It’s actually not too hard to implement Ionic image zoom by using the slides and the underlying Swiper implementation. Especially the second feed zoom shows how you can achieve functionality that you usually wouldn’t expect from Ionic, but with a bit of CSS and some small logic you can make almost everything work!

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

The post The Essential Ionic Image Zoom Guide (Modal & Cards) appeared first on Devdactic - Ionic Tutorials.


Building an Ionic Firebase Chat with Authentication

$
0
0

If you want to get started with Ionic and Firebase, building a simple Ionic Firebase chat is the first thing you can do that’s easy to achieve and yet powerful at the same time!

In this tutorial we will create an Ionic Firebase chat with authentication, security for the pages of our app and of course the realtime chat functionality using the Firestore database.

Are you using Firebase freuently? Check out my Kickoff Ionic bootstrap tool to setup your Ionic apps with Firebase even faster!

ionic-firebase-chat

If you want to create an even more powerful chat with Firebase including push notifications, file upload and cloud functions, check out one of the courses inside the Ionic Academy.

Firebase Project Setup

The first step is Firebase, and you need to create a new project (or use any existing). Inside Firebase, make sure you have enabled the Firestore database by clicking “Get Started” in the right tab.

Once you have created the project you need to copy the web configuration which looks like this:

ionic-4-firebase-add-to-app

Leave this open until our app is ready so you can copy it over!

Additionally you need to enable email/password authentication inside the Authentication menu tab of your Firebase project so we can later register new users.

If you want to use Firebase storage to upload files you can also check out the How to Upload Files from Ionic to Firebase Storage tutorial.

Start the Ionic Firebase Chat App

Now we can start a new Ionic app and add the AngularFire package using a schematic. This will ask for the project to connect with the app, so select the project from the previous step when asked.

ionic start devdacticFire blank --type=angular --capacitor
cd ./devdacticFire
ng add @angular/fire

ionic g page pages/login
ionic g page pages/chat
ionic g service services/chat

Additionally we created two pages and a service for our main chat functionality.

Now we need the configuration from Firebase, and we can add it right inside our environments/environment.ts like this:

export const environment = {
  production: false,
  firebaseConfig: {
    apiKey: "",
    authDomain: "",
    databaseURL: "",
    projectId: "",
    storageBucket: "",
    messagingSenderId: "",
    appId: ""
  }
};

With the configuration in place we can initialise Firebase by adding the according modules to our app/app.module.ts and passing in the environment from before:

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router';

import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
import { SplashScreen } from '@ionic-native/splash-screen/ngx';
import { StatusBar } from '@ionic-native/status-bar/ngx';

import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';

import { AngularFireModule } from '@angular/fire';
import { AngularFireAuthModule } from '@angular/fire/auth';
import { AngularFirestoreModule } from '@angular/fire/firestore';
import { environment } from '../environments/environment';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule,
    AngularFireModule.initializeApp(environment.firebaseConfig),
    AngularFireAuthModule,
    AngularFirestoreModule,],
  providers: [
    StatusBar,
    SplashScreen,
    { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }
  ],
  bootstrap: [AppComponent]
})
export class AppModule {}

The last step of our setup process is the routing. We will have a login page and a chat page, which means the first of them should be available to everyone, but the “inside” chat page is only available to authenticated users.

To do so we can use guards directly from AngularFire to perform one of these tasks:

  • Redirect logged in users directly to the inside area instead of presenting the login again
  • Redirect unauthorised users to the login if they try to access the chat page directly

By now we don’t need to create our own custom guards and can simply use the according functions from the AngularFire package like this inside the app/app-routing.module.ts:

import { NgModule } from '@angular/core';
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';
import { canActivate, redirectUnauthorizedTo, redirectLoggedInTo } from '@angular/fire/auth-guard';

// Send unauthorized users to login
const redirectUnauthorizedToLogin = () =>
  redirectUnauthorizedTo(['/']);

// Automatically log in users
const redirectLoggedInToChat = () => redirectLoggedInTo(['/chat']);

const routes: Routes = [
  {
    path: '',
    loadChildren: () => import('./pages/login/login.module').then( m => m.LoginPageModule),
    ...canActivate(redirectLoggedInToChat),
  },
  {
    path: 'chat',
    ...canActivate(redirectUnauthorizedToLogin),
    loadChildren: () => import('./pages/chat/chat.module').then( m => m.ChatPageModule)
  }
];

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

Now our pages are secure, we start on the login and we can begin with the logic for the authentication.

Creating a Service for the Ionic Firebase Chat

As usual we begin with the logic so we can later easily build the page based on this functionality. In our case we first need the sign up functionality, which is pretty easy given the AngularFire package.

While the createUserWithEmailAndPassword already creates a user account inside Firebase, you usually want to store some additional user information inside the database as this basic user object can’t have specific data like a full name or properties you might capture at sign up.

The easiest way is to grab the unique user id (uid) after the register function and store some more data inside a collection of the database. If you wanted to make this even more secure you could also use a cloud function to automatically perform the step, but the process show here works likewise fine.

The signIn and signOut are equally easy and just proxy the according call to our AF package.

Now get started and change the app/services/chat.service.ts to:

import { Injectable } from '@angular/core';
import { AngularFireAuth } from '@angular/fire/auth';
import { AngularFirestore } from '@angular/fire/firestore';
import * as firebase from 'firebase/app';
import { switchMap, map } from 'rxjs/operators';
import { Observable } from 'rxjs';

export interface User {
  uid: string;
  email: string;
}

export interface Message {
  createdAt: firebase.firestore.FieldValue;
  id: string;
  from: string;
  msg: string;
  fromName: string;
  myMsg: boolean;
}

@Injectable({
  providedIn: 'root'
})
export class ChatService {
  currentUser: User = null;

  constructor(private afAuth: AngularFireAuth, private afs: AngularFirestore) {
    this.afAuth.onAuthStateChanged((user) => {
      this.currentUser = user;      
    });
  }

  async signup({ email, password }): Promise<any> {
    const credential = await this.afAuth.createUserWithEmailAndPassword(
      email,
      password
    );

    const uid = credential.user.uid;

    return this.afs.doc(
      `users/${uid}`
    ).set({
      uid,
      email: credential.user.email,
    })
  }

  signIn({ email, password }) {
    return this.afAuth.signInWithEmailAndPassword(email, password);
  }

  signOut(): Promise<void> {
    return this.afAuth.signOut();
  }

  // TODO Chat functionality
}

We also catch any change of the authenticated user inside onAuthStateChanged, so whenever someone sign in or out we can set our current user and have access to that information from then on. Our interfaces add just some more typings for our own comfort but are otherwise optional.

After the sign up we can now also prepare the Ionic Firebase chat functionality. For this we need a few additional functions:

  • addChatMessage: Simply adds a new message into a messages collection with additonal timestamp and the UID of the user who sent the message
  • getChatMessages: Get an observable of messages that is updated whenever a new message is added. We need some additional logic in here that we’ll explore later
  • getUsers: Helper function to get all users so we can resolve names of users
  • getUserForMsg: Helper function to find the real name (email) of a user based on a UID and the array of users

From the outside we will only call the first two functions, the rest is just some internal help.

We need to use the switchMap operator inside the pipe block when we retrieve messages so we can switch to a new Observable, because we first grab all users and later retrieve all messages, so we can iterate the messages and resolve their from names and add a property whether we have sent the message.

Now continue the app/services/chat.service.ts and also add:

// Chat functionality

addChatMessage(msg) {
  return this.afs.collection('messages').add({
    msg: msg,
    from: this.currentUser.uid,
    createdAt: firebase.firestore.FieldValue.serverTimestamp()
  });
}

getChatMessages() {
  let users = [];
  return this.getUsers().pipe(
    switchMap(res => {
      users = res;
      return this.afs.collection('messages', ref => ref.orderBy('createdAt')).valueChanges({ idField: 'id' }) as Observable<Message[]>;
    }),
    map(messages => {
      // Get the real name for each user
      for (let m of messages) {          
        m.fromName = this.getUserForMsg(m.from, users);
        m.myMsg = this.currentUser.uid === m.from;
      }        
      return messages
    })
  )
}

private getUsers() {
  return this.afs.collection('users').valueChanges({ idField: 'uid' }) as Observable<User[]>;
}

private getUserForMsg(msgFromId, users: User[]): string {    
  for (let usr of users) {
    if (usr.uid == msgFromId) {
      return usr.email;
    }
  }
  return 'Deleted';
}

Another idea would be to directly store the relevant user name or information with each message so we don’t need to resolve the name. This means you would later have to change all these occurrences in messages if a user changes names, but that would be possible with cloud functions and the preferred way inside a real chat.

Also, in a real world chat you might have groups with a defined set of users, so you would only retrieve the uid/name combination for all the people inside a chat and therefore limit the calls to Firebase and store the resolved information locally instead.

Anyway, we got all functionality for our Ionic Firebase chat in place so let’s continue with the UI!

Create a Login and Signup Page for Firebase Authentication

Back to the beginning, we need to create users and sign them in. We should use a reactive form, and therefore we need to add the module to ourapp/pages/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 {}

The form will be quite easy and is used for both sign up and sign in on our page to make life a bit easier for now.

Both functions are basically just a call to our service, but when you make everything work together with a little loading indicator and error handling, the functions get a bit longer. But at the core, it’s really just handling the different success/error cases.

When the function finishes successful, we can use the Angular router to move to the inside area – and we replace the current URL so the user can’t simply go back to login, which should be prevented.

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

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

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

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

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

  async signUp() {
    const loading = await this.loadingController.create();
    await loading.present();
    this.chatService
      .signup(this.credentialForm.value)
      .then(
        (user) => {
          loading.dismiss();
          this.router.navigateByUrl('/chat', { replaceUrl: true });
        },
        async (err) => {
          loading.dismiss();
          const alert = await this.alertController.create({
            header: 'Sign up failed',
            message: err.message,
            buttons: ['OK'],
          });

          await alert.present();
        }
      );
  }

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

    this.chatService
      .signIn(this.credentialForm.value)
      .then(
        (res) => {
          loading.dismiss();
          this.router.navigateByUrl('/chat', { replaceUrl: true });
        },
        async (err) => {
          loading.dismiss();
          const alert = await this.alertController.create({
            header: ':(',
            message: err.message,
            buttons: ['OK'],
          });

          await alert.present();
        }
      );
  }

  // Easy access for form fields
  get email() {
    return this.credentialForm.get('email');
  }
  
  get password() {
    return this.credentialForm.get('password');
  }
}

At the end we also got two simply getter functions, which help us to easily access the form controls from the template like we did as well inside our Ionic 5 App navigation with login tutorial.

That means, we can not only connect our input fields to the reactive form, but also check if the control has any errors and display a specific error message for each of the fields!

Additionally we should check if the field is dirty or touched, because that means the user has somehow interacted with the input and we don’t want to show the error before by default!

Now go ahead and open the src/app/pages/login/login.page.html and change it to:

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

<ion-content class="ion-padding">

  <form [formGroup]="credentialForm">
    <ion-item>
      <ion-input
        placeholder="Email address"
        formControlName="email"
        autofocus="true"
        clearInput="true"
      ></ion-input>
    </ion-item>
    <div *ngIf="(email.dirty || email.touched) && email.errors" class="errors">
      <span *ngIf="email.errors?.required">Email is required</span>
      <span *ngIf="email.errors?.email">Email is invalid</span>
    </div>

    <ion-item>
      <ion-input
      placeholder="Password"
      type="password"
      formControlName="password"
      clearInput="true"
    ></ion-input>
    </ion-item>
    <div *ngIf="(password.dirty || password.touched) && password.errors" class="errors">
      <span *ngIf="password.errors?.required">Password is required</span>
      <span *ngIf="password.errors?.minlength">Password needs to be 6 characters</span>
    </div>
  </form>

    <ion-button (click)="signUp()" expand="full">Sing up</ion-button>
    <ion-button (click)="signIn()" expand="full" color="secondary">Sing in</ion-button>

</ion-content>

So we have connected the input elements to the formGroup and added our buttons below the form. We can even add some additional styling to make the errors stand out like this inside the src/app/pages/login/login.page.scss:

.errors {
    font-size: small;
    color: #fff;
    background: var(--ion-color-danger);
    padding-left: 15px;
    padding-top: 5px;
    padding-bottom: 5px;
}

Now the login of our Ionic Firebase chat works and we can sign up and see new entries inside the Firebase project console on the web. We should also see new entries inside Firestore for a new users, and we are routed forward to the chat page, which is now the next task for us.

Creating the Ionic Chat View

In fact the chat is quite easy, since we only need to retrieve the Observable from our service and then add an according view based on the information inside the message.

As you can see, we do all the transformation and data enrichment inside the service so we can keep the page as easy as possible and free from logic!

The only specialty we got is that after sending a message, we want to scroll the ion-content to the bottom to see the latest message (we could also do this whenever we get a new message by using a pipe block here!). And we can do this by accessing the component as a ViewChild!

Therefore open the src/app/pages/chat/chat.page.ts and simply change it to:

import { Component, OnInit, ViewChild } from '@angular/core';
import { IonContent } from '@ionic/angular';
import { Observable } from 'rxjs';
import { ChatService } from '../../services/chat.service';
import { Router } from '@angular/router';

@Component({
  selector: 'app-chat',
  templateUrl: './chat.page.html',
  styleUrls: ['./chat.page.scss'],
})
export class ChatPage implements OnInit {
  @ViewChild(IonContent) content: IonContent;

  messages: Observable<any[]>;
  newMsg = '';

  constructor(private chatService: ChatService, private router: Router) { }

  ngOnInit() {
    this.messages = this.chatService.getChatMessages();
  }

  sendMessage() {
    this.chatService.addChatMessage(this.newMsg).then(() => {
      this.newMsg = '';
      this.content.scrollToBottom();
    });
  }

  signOut() {
    this.chatService.signOut().then(() => {
      this.router.navigateByUrl('/', { replaceUrl: true });
    });
  }

}

The most important part here is now styling the chat based on whether the user itself did send the message. Luckily we already added this information as the myMsg boolean to each message inside the chat service!

That means we can create a grid layout and simply set a little offset for messages based on this boolean. Also, we can use conditional styling using ngClass to add different colors to the messages.

Finally, we need to create the Firebase timestamp into milliseconds so we can then apply the Angular datepipe for a better display of the time sent.

Now wrap up the view by changing the src/app/pages/chat/chat.page.html to:

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

<ion-content class="ion-padding">

  <ion-grid>
    <ion-row *ngFor="let message of messages | async">
      <ion-col size="9" class="message"
        [offset]="message.myMsg ? 3 : 0"
        [ngClass]="{ 'my-message': message.myMsg, 'other-message': !message.myMsg }">
        <b>{{ message.fromName }}</b><br>
        <span>{{ message.msg }}
        </span>
        <div class="time ion-text-right"><br>{{ message.createdAt?.toMillis() | date:'short' }}</div>
      </ion-col>
    </ion-row>
  </ion-grid>

</ion-content>

<ion-footer>
  <ion-toolbar color="light">
    <ion-row class="ion-align-items-center">
      <ion-col size="10">
        <ion-textarea autoGrow="true" class="message-input" rows="1" maxLength="500" [(ngModel)]="newMsg" >
        </ion-textarea>
      </ion-col>
      <ion-col size="2">
        <ion-button expand="block" fill="clear" color="primary" [disabled]="newMsg === ''"
          class="msg-btn" (click)="sendMessage()">
          <ion-icon name="send" slot="icon-only"></ion-icon>
        </ion-button>
      </ion-col>
    </ion-row>
  </ion-toolbar>
</ion-footer>

The input for new messages is inside the footer so it automatically scrolls up when we enter some text. By using the autoGrow property the field will automatically get bigger when the user types in the message.

If you run the app now it still doesn’t look to good, but that can be changed with a bit more CSS to style our chat bubbles inside the src/app/pages/chat/chat.page.scss:

.message-input {
    width: 100%;
    border: 1px solid var(--ion-color-medium);
    border-radius: 6px;
    background: #fff;
    resize: none;
    margin-top: 0px;
    --padding-start: 8px;
}

.message {
    padding: 10px !important;
    border-radius: 10px !important;
    margin-bottom: 4px !important;
    white-space: pre-wrap;
}

.my-message {
    background: var(--ion-color-tertiary);
    color: #fff;
}

.other-message {
    background: var(--ion-color-secondary);
    color: #fff;
}

.time {
    color: #dfdfdf;
    float: right;
    font-size: small;
}

Now run the app again and enjoy your Ionic Firebase chat!

Conclusion

If you understand the basics of Firebase like how authentication works, how you can secure your app with guards from AngularFire and how to use the Firestore database you can build powerful apps like this Ionic Firebase chat quite fast!

This is just a basic example, so perhaps now go ahead and try to add some group functionality to replicate the usual WhatsApp beahviour.

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

The post Building an Ionic Firebase Chat with Authentication appeared first on Devdactic - Ionic Tutorials.

How to add Capacitor Google Sign In to your Ionic App

$
0
0

If you need a social sign in inside your Ionic app, adding Capacitor Google sign in is actually a breeze to implement after some initial configuration.

Google sign in is one of the most common social authentication providers besides the new
Sign in with Apple
that iOS apps need to support, and we can set it up directly with Capacitor with just a bit of set up.

capacitor-google-sign-in

The only thing you will need for this tutorial is a Firebase project, so create one now or use any of your existing projects!

Setting up the Capacitor Google Sign app

For the Capacitor Google Sign we need one additional plugin from the Capacitor community, so go ahead for now and set up a blank project and add the plugin like below:

ionic start devdacticLogin blank --type=angular --capacitor
cd ./devdacticLogin
npm i @codetrix-studio/capacitor-google-auth

Since we need to configure the native apps as well, you should set the right bundle ID for your app upfront. You can add it to the CLI command when generating a project or simply set it inside your capacitor.config.json right now:

{
  "appId": "com.devdactic.capalogin",
  "appName": "devdacticLogin",
  "bundledWebRuntime": false,
  "npmClient": "npm",
  "webDir": "www",
  "plugins": {
    "SplashScreen": {
      "launchShowDuration": 0
    }
  },
  "cordova": {}
}

Of course you should pick a bundle ID different from my example, it’s the one you later also use when publishing your iOS and Android app.

Now you can add the native platforms after running one initial build:

ionic build
npx cap add ios
npx cap add android

Now the configuration begins…

Firebase Google Sign In Preparation

As said before, we now need our Firebase project. The first step is to open the Firebase project settings and click Add app and select Android.

Make sure you put in the correct bundle ID of your app – the one that’s already in your capacitor.config.json.

We also need SHA-1 signing certificate, something you usually use when you sign your APK for the Play Store in the end. But in order to hook our app up to Google services, we already need it now.

You can first of all get the fingerprint of your debug key, which is 99% of the time automatically created at ~/.android/debug.keystore on your computer.

Get the output for this key by running:

keytool -list -v -alias androiddebugkey -keystore ~/.android/debug.keystore

The default password for this key should be android, for me actually hitting only enter worked as well.

If you encounter any problems on a Mac because Java is not installed, head over to the Oracle downloads page and install the current JDK!

From the output of your keytool command copy the value after SHA1 and paste it into the Firebase wizard to register our app.

firebase-android-setup

This is your debug SHA1, once you sign your final APK for go live you also need to add this fingerprint for the release key to your Android app inside Firebase, but there’s a simple “Add Fingerprint” button available.

You can now download the google-services.json file and move it to the android/app folder inside your Ionic project. To make the value available to our Android app we also need to make a change to the android/app/src/main/res/values/strings.xml and add an entry for the server_client_id:

<?xml version='1.0' encoding='utf-8'?>
<resources>
    <string name="app_name">devdacticLogin</string>
    <string name="title_activity_main">devdacticLogin</string>
    <string name="package_name">com.devdactic.capalogin</string>
    <string name="custom_url_scheme">com.devdactic.capalogin</string>
    <string name="server_client_id">REPLACEME.apps.googleusercontent.com</string>
</resources>

Now you just need to replace the dummy with your key – but where is your key?

The key is already part of your google-services.json, but there are some other keys as well and taking the wrong key will result in an error when you finally use the sign in!

The best way to find the correct key is to actually visit the Google APIs console, in which a project for your Firebase project was automatically created.

google-app-credentials

You now want to copy from the OAuth 2.0 Client IDs section, the row Web client and more specific the Client ID in that row (there’s a copy symbol after the ID, just use that).

This is your client ID from now on, so paste it into the Android strings file we opened a minute agoand set it as your server_client_id. We will also need it again soon.

Inside the Google APIs console you also see a warning for the OAuth consent screen, which you should fill out now as you will get into trouble later otherwise. It’s basically a configuration for the consent screen that your users will see when they use the Capacitor Google sign in.

I actually always encountered issues with the OAuth consent screen not being set up, and my changes were not saved, and going through the wizard didn’t really help. What actually fixed the problem in the end:

Inside your Firebase project, go to the Authentication menu, select the Sign-in method tab and activate Google sign in – after hitting safe, this fixed all the OAuth consent screen issues!

firebase-enable-google

Since the Google APIs view is already open, we can make another tiny change to our Web Client, so simply click on the row where you copied the key before and now open the details for it.

In the following screen we can add Authorized JavaScript origins, and if you take a look at the list you’ll see that the usual port 8100 where Ionic apps are served is missing. Go ahead and click the button to add a new URI with our port:

google-credentials-origin

If you still get an error in your developer console later, make sure you empty the cache of your browser and refresh the page.

Capacitor Google Sign In Preparation – Android

Apparently we are not yet done, but we need to tell Android about the Capacitor plugin we installed. Therefore, we need to open the android/app/src/main/java/com/devdactic/capalogin/MainActivity.java which is the starting point of the native Android application and add two lines to import and use our plugin:

package com.devdactic.capalogin;

import android.os.Bundle;

import com.getcapacitor.BridgeActivity;
import com.getcapacitor.Plugin;

import java.util.ArrayList;
import com.codetrixstudio.capacitor.GoogleAuth.GoogleAuth;

public class MainActivity extends BridgeActivity {
  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    // Initializes the Bridge
    this.init(savedInstanceState, new ArrayList<Class<? extends Plugin>>() {{
      // Additional plugins you've installed go here
      add(GoogleAuth.class);
    }});
  }
}

Since we might have the client ID still in our clipboard, we can quickly add it to another file in order to also test our Google Sign In on the web.

Open the src/index.html and put the following line with your client ID into the head tag of your page:

<meta name="google-signin-client_id" content="REPLACEME.apps.googleusercontent.com">

Besides that we also need the value in our capacitor.config.json to configure the plugin correctly, so open it and add the whole plugins block to your configuration file:

{
  "appId": "com.devdactic.capalogin",
  "appName": "devdacticLogin",
  "bundledWebRuntime": false,
  "npmClient": "npm",
  "webDir": "www",
  "plugins": {
    "SplashScreen": {
      "launchShowDuration": 0
    },
    "GoogleAuth": {
      "scopes": [
        "profile",
        "email"
      ],
      "serverClientId": "REPLACEME.apps.googleusercontent.com",
      "forceCodeForRefreshToken": true
    }
  },
  "cordova": {}
}

Of course use your real ID in there again, not my dummy.

Now we’ve taken the hard steps and just need to wrap it up for iOS as well.

Capacitor Google Sign In Preparation – iOS

Head over to the Firebase project settings and click Add app again, but this time we will add a native iOS app.

Again, make sure you put in the correct bundle ID of your app, the one from your capacitor.config.json.

In the next step you can download a file named GoogleService-Info.plist, and we need to move that file into the right location in our project at ios/App/App (yes, twice App in the path) so it sits next to the info.plist in your iOS project.

It’s not enough to do this in your text editor, you really need to drag it into Xcode at the right path!

Before copy, also select Copy items if needed and then it should be right in your Xcode project.

firebase-ios-plist

Additionally we need to add an URL scheme to our iOS app, and the easiest way is actually inside Xcode again. Keep in mind that all these changes to the native platforms are persistent since we are using Capacitor!

Within Xcode, select your app in the navigation area to the left, go into the Info tab and scroll to the bottom. In here, we need to expand the URL Types area at the bottom and click the plus to create a new scheme.

This scheme will now be filled with the value of your REVERSED_CLIENT_ID, an ID you can find in the previously downloaded GoogleService-Info.plist (not the client ID from before!). Paste that value into the field like in the image below.

ios-url-scheme

This steps makes sure the callback after the Google sign in opens our app correctly.

Capacitor Google Sign In Implementation

Now things become very easy, since the usage of the plugin is actually just one line of code. Go ahead and open the home/home.page.ts and change it to:

import { Component } from '@angular/core';
import '@codetrix-studio/capacitor-google-auth';
import { Plugins } from '@capacitor/core';

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

  constructor() { }

  async googleSignup() {
    const googleUser = await Plugins.GoogleAuth.signIn(null) as any;
    console.log('my user: ', googleUser);
    this.userInfo = googleUser;
  }
}

We are not really doing anything with the information after the sign in, but you can easily hook this up to the general Firebase authentication to create a new user. You can find more about this in my book Practical Ionic as well!

The last step for testing is to add a button, simply follow up with the home/home.page.html and change it to:

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

<ion-content>
  <ion-button (click)="googleSignup()">Sign in</ion-button>
  <ion-card>
    <ion-card-content>
      {{ userInfo | json}}
    </ion-card-content>
  </ion-card>
</ion-content>

Now you are ready to use the sign in inside your browser (clear cache if you experience problems) and on your native iOS and Android apps as well!

Conclusion

Implementing Capacitor Google Sign In is mostly a problem of the configuration, which easily results in failures during testing if not made correctly. But if you follow the exact steps outlined above, you now got a powerful social authentication available inside your app!

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

The post How to add Capacitor Google Sign In to your Ionic App appeared first on Devdactic - Ionic Tutorials.

Building an SQLite Ionic App with Capacitor

$
0
0

If you need to store more data than a few keys, you should pick the SQLite Ionic integration that you can easily use with Capacitor to add powerful SQL functionalities to your app!

In this tutorial we will integrate the Capacitor community SQLite plugin and build a powerful app that first of all loads some seed data from a JSON dump, and then allows to work with that data right inside your app.

sqlite-ionic-app-capacitor

We will not build a 100% synchronisation functionality but this could be the start of your next Ionic SQLite app with remote database sync for sure!

Setting up the SQLite Ionic App

As always we start with a blank app and then install the Capacitor plugin to access the device SQLite database. We also need an additional page and service for the tutorial and finally you should add the native platform that you plan to use, since you need to test the functionality on a real device:

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

# Install the Capacitor plugin
npm install @capacitor-community/sqlite

# Add some app logic
ionic g service services/database
ionic g page pages/details

# Add the native platforms
ionic build
npx cap add ios
npx cap add android

At the time writing the plugin did not have a web implementation, but hopefully this might change in the future!

To use the plugin, you also need to add it to the main activity of your Android project inside the android/app/src/main/java/io/ionic/starter/MainActivity.java:

package io.ionic.starter;
import com.getcapacitor.community.database.sqlite.CapacitorSQLite;

import android.os.Bundle;

import com.getcapacitor.BridgeActivity;
import com.getcapacitor.Plugin;

import java.util.ArrayList;

public class MainActivity extends BridgeActivity {
  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    // Initializes the Bridge
    this.init(savedInstanceState, new ArrayList<Class<? extends Plugin>>() {{
      // Additional plugins you've installed go here
      add(CapacitorSQLite.class);
    }});
  }
}

Since we want to download some SQL data for our app on start, we also need to make an HTTP request and therefore import the module as usually inside our 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 { SplashScreen } from '@ionic-native/splash-screen/ngx';
import { StatusBar } from '@ionic-native/status-bar/ngx';

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: [
    StatusBar,
    SplashScreen,
    { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

The last setup part is changing the routing, so we can have a list of data from our local SQLite database displayed and a details page to show some more information. Go ahead and change the routing inside the app/app-routing.module.ts to this:

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

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

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

Now we are able to navigate to a details page with an ID, so we will be able to easily retrieve the data with a query later.

Preparing a Database Export as JSON

The cool feature of the plugin is that it allows you to import a JSON dump of your data. There are also Cordova plugins that allow a SQL import, but since your database is usually behind an API anyway you could create this on the server and host the file for seeding your apps database somewhere.

The file specifies some general information like the name, and then the tables which can also directly hold some values. In our case we define two tables called vendors and products, where each product has the vendorid as foreign key.

When you want to sync your tables later you also need to include a last_modified column, but more on the whole import and sync inside the plugin docs.

I’ve hosted the following file for testing here: https://devdactic.fra1.digitaloceanspaces.com/tutorial/db.json

Feel free to otherwise simply create the file in your app and load it directly from the assets folder instead:

{
  "database": "product-db",
  "version": 1,
  "encrypted": false,
  "mode": "full",
  "tables": [
    {
      "name": "vendors",
      "schema": [
        { "column": "id", "value": "INTEGER PRIMARY KEY NOT NULL" },
        { "column": "company_name", "value": "TEXT NOT NULL" },
        { "column": "company_info", "value": "TEXT NOT NULL" },
        { "column": "last_modified", "value": "INTEGER DEFAULT (strftime('%s', 'now'))" }
      ],
      "values": [
        [1, "Devdactic", "The main blog of Simon Grimm", 1587310030],
        [2, "Ionic Academy", "The online school to learn Ionic", 1590388125],
        [3, "Ionic Company", "Your favourite cross platform framework", 1590383895]
      ]
    },
    {
      "name": "products",
      "schema": [
        { "column": "id", "value": "INTEGER PRIMARY KEY NOT NULL" },
        { "column": "name", "value": "TEXT NOT NULL" },
        { "column": "currency", "value": "TEXT" },
        { "column": "value", "value": "INTEGER" },
        { "column": "vendorid", "value": "INTEGER" },
        { "column": "last_modified", "value": "INTEGER DEFAULT (strftime('%s', 'now'))" },
        {
          "foreignkey": "vendorid",
          "value": "REFERENCES vendors(id)"
        }
      ],
      "values": [
        [1, "Devdactic Fan Hat", "EUR", 9, 1, 1604396241],
        [2, "Ionic Academy Membership", "USD", 25, 2, 1604296241],
        [3, "Ionic Sticker Swag", "USD", 4, 3, 1594196241],
        [4, "Practical Ionic Book", "USD", 79, 1, 1603396241]
      ]
    }
  ]
}

Now our app can pull in that data and seed the initial SQLite database!

Building a Database Service

Before we dive into the pages, let’s create the logic for the app upfront.

Before you can actually use the database, you should check whether you are running on Android, since you need to request permissions in that case!

I’ve also added a bit of logic to store the name of the database (retrieved from the previous JSON file) inside Capacitor Storage, and also keep track whether we have already synced the initial data.

This is just a very rough idea in here, it really depends on your case: Do you want to get the latest data on every app start? Do you just need the remote data once to seed the app? Think about what you need inside your app and then build out the logic for your needs.

So within the setupDatabase we either start the download and import, or if we already did it before we can directly open the database (based on the name we stored).

Additionally I added a BehaviorSubject so we can subscribe to the database stead and not perform any queries before it’s ready.

Now go ahead and change the app/services/database.service.ts to:

import { Injectable } from '@angular/core';
import { Plugins } from '@capacitor/core';
import '@capacitor-community/sqlite';
import { AlertController } from '@ionic/angular';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, from, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';

import { JsonSQLite } from '@capacitor-community/sqlite';
const { CapacitorSQLite, Device, Storage } = Plugins;

const DB_SETUP_KEY = 'first_db_setup';
const DB_NAME_KEY = 'db_name';

@Injectable({
  providedIn: 'root'
})
export class DatabaseService {
  dbReady = new BehaviorSubject(false);
  dbName = '';

  constructor(private http: HttpClient, private alertCtrl: AlertController) { }

  async init(): Promise<void> {
    const info = await Device.getInfo();

    if (info.platform === 'android') {
      try {
        const sqlite = CapacitorSQLite as any;
        await sqlite.requestPermissions();
        this.setupDatabase();
      } catch (e) {
        const alert = await this.alertCtrl.create({
          header: 'No DB access',
          message: 'This app can\'t work without Database access.',
          buttons: ['OK']
        });
        await alert.present();
      }
    } else {
      this.setupDatabase();
    }
  }

  private async setupDatabase() {
    const dbSetupDone = await Storage.get({ key: DB_SETUP_KEY });

    if (!dbSetupDone.value) {
      this.downloadDatabase();
    } else {
      this.dbName = (await Storage.get({ key: DB_NAME_KEY })).value;
      await CapacitorSQLite.open({ database: this.dbName });
      this.dbReady.next(true);
    }
  }

  // Potentially build this out to an update logic:
  // Sync your data on every app start and update the device DB
  private downloadDatabase(update = false) {
    this.http.get('https://devdactic.fra1.digitaloceanspaces.com/tutorial/db.json').subscribe(async (jsonExport: JsonSQLite) => {
      const jsonstring = JSON.stringify(jsonExport);
      const isValid = await CapacitorSQLite.isJsonValid({ jsonstring });

      if (isValid.result) {
        this.dbName = jsonExport.database;
        await Storage.set({ key: DB_NAME_KEY, value: this.dbName });
        await CapacitorSQLite.importFromJson({ jsonstring });
        await Storage.set({ key: DB_SETUP_KEY, value: '1' });

        // Your potential logic to detect offline changes later
        if (!update) {
          await CapacitorSQLite.createSyncTable();
        } else {
          await CapacitorSQLite.setSyncDate({ syncdate: '' + new Date().getTime() })
        }
        this.dbReady.next(true);
      }
    });
  }
}

Inside the downloadDatabase we download the JSON file from the beginning, make sure the JSON is valid for the import and then import the whole data into our SQLite database (or better, the database will actually be created with that name).

We also store the database name and make sure we don’t run through this again, and if you plan to sync new data from your app back to a remote server, you can now create a sync table with the plugin:

The table will have a timestamp of the current sync, and you can later create a partial export with all the changes that happened since the last sync. Once you then sync the data again, simply call setSyncDate and you can work offline again.

Now we also need some real functionality inside our app to query the data. This is just a simple example of a few queries to read out the data, get a specific product and populate the vendor fields (using left join) or add a dummy product.

The call to getProductList is wrapped inside the our ready state, since otherwise the first page of the app would call this function before the database is ready, which is exactly what we want to prevent. Only when the ready state changes the function would return the value from the query statemen!

Now continue with the app/services/database.service.ts and add:

getProductList() {
  return this.dbReady.pipe(
    switchMap(isReady => {
      if (!isReady) {
        return of({ values: [] });
      } else {
        const statement = 'SELECT * FROM products;';
        return from(CapacitorSQLite.query({ statement, values: [] }));
      }
    })
  )
}

async getProductById(id) {
  const statement = `SELECT * FROM products LEFT JOIN vendors ON vendors.id=products.vendorid WHERE products.id=${id} ;`;
  return (await CapacitorSQLite.query({ statement, values: [] })).values[0];
}

getDatabaseExport(mode) {
  return CapacitorSQLite.exportToJson({ jsonexportmode: mode });
}

addDummyProduct(name) {
  const randomValue = Math.floor(Math.random() * 100) + 1;
  const randomVendor = Math.floor(Math.random() * 3) + 1
  const statement = `INSERT INTO products (name, currency, value, vendorid) VALUES ('${name}','EUR', ${randomValue}, ${randomVendor});`;
  return CapacitorSQLite.execute({ statements: statement });
}

deleteProduct(productId) {
  const statement = `DELETE FROM products WHERE id = ${productId};`;
  return CapacitorSQLite.execute({ statements: statement });
}

// For testing only..
async deleteDatabase() {
  const dbName = await Storage.get({ key: DB_NAME_KEY });
  await Storage.set({ key: DB_SETUP_KEY, value: null });
  return CapacitorSQLite.deleteDatabase({ database: dbName.value });
}

For testing we also add a delete function, and the getDatabaseExport helps to create a JSON export of the database. The mode in this case is either partial or full, which means only the items since the last sync or a full export of all data insite the SQLite database.

Next step is to make sure the init is called right in the beginning of our app, so we can change the app/app.component.ts to show a loading while we import the data and the database is not yet ready:

import { Component } from '@angular/core';
import { LoadingController, Platform } from '@ionic/angular';
import { SplashScreen } from '@ionic-native/splash-screen/ngx';
import { StatusBar } from '@ionic-native/status-bar/ngx';
import { DatabaseService } from './services/database.service';

@Component({
  selector: 'app-root',
  templateUrl: 'app.component.html',
  styleUrls: ['app.component.scss']
})
export class AppComponent {
  constructor(
    private platform: Platform,
    private splashScreen: SplashScreen,
    private statusBar: StatusBar,
    private databaseService: DatabaseService,
    private loadingCtrl: LoadingController
  ) {
    this.initializeApp();
  }

  async initializeApp() {
    this.platform.ready().then(async () => {
      const loading = await this.loadingCtrl.create();
      await loading.present();
      this.databaseService.init();
      this.databaseService.dbReady.subscribe(isReady => {
        if (isReady) {
          loading.dismiss();
          this.statusBar.styleDefault();
          this.splashScreen.hide();
        }
      });
    });
  }
}

I’ve noticed that the first page loads anyway already, so you could even add a guard to your page to prevent access until the database is ready, which would be the best solution I guess.

Otherwise you always need to be careful that your app is not starting any query while the database is still locked or not opened yet.

Building the SQLite Ionic APP Logic

We’ve prepared everything for our app so we can now start the logic to query the database with our previously defined functions.

Our first page should load all the products, and offer some functionality so we can create an export, add a dummy product or delete the database.

Most of this is just calling our service, so simply go ahead and change the src/home/home.page.ts to:

import { Component } from '@angular/core';
import { DatabaseService } from '../services/database.service';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})
export class HomePage {
  products = [];
  export = null;
  newProduct = 'My cool product';

  constructor(private databaseService: DatabaseService) {
    this.loadProducts();
  }

  loadProducts() {
    this.databaseService.getProductList().subscribe(res => {
      this.products = res.values;
    });
  }

  // Mode is either "partial" or "full"
  async createExport(mode) {
    const dataExport = await this.databaseService.getDatabaseExport(mode);
    this.export = dataExport.export;
  }

  async addProduct() {
    await this.databaseService.addDummyProduct(this.newProduct);
    this.newProduct = '';
    this.loadProducts();
  }

  async deleteProduct(product) {    
    await this.databaseService.deleteProduct(product.id);
    this.products = this.products.filter(p => p != product);    
  }

  // For testing..
  deleteDatabase() {
    this.databaseService.deleteDatabase();
  }
}

Now we can iterate the data and use sliding buttons to easily add the delete functionality right away. Additionally we add the correct routerLink for each item, so we open the details page with the ID of that product.

Continue by changing the src/home/home.page.html to:

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>
      My SQL App
    </ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>

  <ion-item>
    <ion-input [(ngModel)]="newProduct"></ion-input>
  </ion-item>
  <ion-button expand="full" (click)="addProduct()">Add dummy product</ion-button>

  <ion-list>
    <ion-list>
      <ion-item-sliding *ngFor="let p of products">
        <ion-item [routerLink]="['product', p.id]">
          <ion-label>
            {{ p.name }}
            <p>{{ p.value | currency:p.currency }}</p>
          </ion-label>
        </ion-item>
        <ion-item-options side="end">
          <ion-item-option (click)="deleteProduct(p)" color="danger">Delete</ion-item-option>
        </ion-item-options>
      </ion-item-sliding>
    </ion-list>

  </ion-list>

  <ion-button expand="full" (click)="createExport('full')">Create Full JSON Export</ion-button>
  <ion-button expand="full" (click)="createExport('partial')">Create Partial JSON Export</ion-button>
  <ion-button expand="full" (click)="deleteDatabase()" color="danger">Delete Database</ion-button>

  <ion-card *ngIf="export">
    <ion-card-content>
      {{ export | json}}
    </ion-card-content>
  </ion-card>

</ion-content>

Now you should already see your app in action, and for debugging purpose I recommend to run the Capacitor live reload on a device like:

ionic cap run ios --livereload --external

Creating a Simple Details Page

Since we can already enter the details page, it’s now time to load the ID from the navigation and use it to query our database again. This is just basic Angular, so simply change the pages/details/details.page.ts to:

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { DatabaseService } from '../../services/database.service';

@Component({
  selector: 'app-details',
  templateUrl: './details.page.html',
  styleUrls: ['./details.page.scss'],
})
export class DetailsPage implements OnInit {
  product = null;

  constructor(private route: ActivatedRoute, private databaseService: DatabaseService) { }

  async ngOnInit() {
    const id = this.route.snapshot.paramMap.get('id');
    this.product = await this.databaseService.getProductById(id);
  }

}

You can log the product and see that after the left join all the vendor information is present as well (SQL magic, kinda) so we just need a simple view to display all of that information to confirm our SQLite Ionic app works as expected.

Therefore apply the last change to the pages/details/details.page.html now:

<ion-header>
  <ion-toolbar color="primary">
    <ion-buttons slot="start">
      <ion-back-button defaultHref="/"></ion-back-button>
    </ion-buttons>
    <ion-title>Product</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-card *ngIf="product">
    <ion-card-header>
      <ion-card-title>{{ product.name }}</ion-card-title>
    </ion-card-header>
    <ion-card-content>
      <h2>{{ product.company_name}}</h2>
      {{ product.company_info }}
    </ion-card-content>
  </ion-card>
</ion-content>

Now you can add some dummy products and see how the database changes – but wouldn’t you like to see the real data that is stored on the device as well?

Debugging

Debugging the SQLite database of your app is a bit more complicated, but it’s a great way to confirm all the data is in place or spot any problems with your implementation.

Simply follow the SQLite debugging section of my tutorial here and you should be able to access your database for both iOS and Android quite quickly.

Note: The Capacitor SQLite plugin will add a specific name (SQLite.db) to your database file, so if your database name is “product-db” (like in my JSON), the file on your device would be called “product-dbSQLite.db”.

Keep that in mind especially for the Android DB which you directly extract from the device using the ADB shell.

Conclusion

Working with SQLite inside your Ionic app is a great way to use existing data you might already have to populate the apps database and with the sync capabilities, it’s even possible to completely work offline and then transfer the changes back to the server at a later point of time!

You can also find a video version of this tutorial with even more information about the whole process below.

The post Building an SQLite Ionic App with Capacitor appeared first on Devdactic - Ionic Tutorials.

How to Add Ionic Facebook Login with Capacitor

$
0
0

Adding Ionic Facebook login to your app can help to quickly sign in users, and the setup required to make it work with Capacitor is actually not too hard!

After we have seen how to use Google sign in or the new Sign in with Apple, today we will focus on the third major social authentication provider.

To do so we will use the Capacitor Facebook login plugin, which also needs some previous setup inside the Facebook developer tools.

ionic-facebook-login-capacitor

Once we are done, our users will be able to sign in with Facebook using the native dialog, and we will also make a dummy API call to the Facebook Graph API to retrieve some user data as the prove that we are logged in!

Getting started with our Ionic Facebook App

Before we dive into the Facebook part, let’s make sure you have an Ionic app ready. If you want to test things out, go ahead with the following commands:

ionic start devdacticFbLogin blank --type=angular --capacitor --package-id=com.devdactic.fblogin
cd ./devdacticFbLogin
npm i @capacitor-community/facebook-login

ionic build
npx cap add ios
npx cap add android

The important part is to have a real package id and not just the dummy one that Ionic apps usually use, so instead of com.devdactic.fblogin use your own id in there.

If you already have an existing app, simply make sure you have set your own appId inside the capacitor.config.json like:

{
  "appId": "com.devdactic.fblogin",
  "appName": "devdacticFbLogin",
  "bundledWebRuntime": false,
  "npmClient": "npm",
  "webDir": "www",
  "plugins": {
    "SplashScreen": {
      "launchShowDuration": 0
    }
  },
  "cordova": {}
}

This ID will be used within the next step, so make sure everything is right up until here.

Because we will make an Http request we should also quickly change the src/app/app.module.ts and include the HttpClientModule 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 { SplashScreen } from '@ionic-native/splash-screen/ngx';
import { StatusBar } from '@ionic-native/status-bar/ngx';

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: [
    StatusBar,
    SplashScreen,
    { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }
  ],
  bootstrap: [AppComponent]
})
export class AppModule {}

That’s it for the first app setup, now on to the Facebook app itself.

Facebook App Preparation

To use Ionic Facebook login, you first of all now need to create a Facebook app over at the Facebook developers page.

Simply create an account if you don’t have one yet, and then from within your app dashboard click “Create App” to start a new dialog.

ionic-facebook-create-app

I’m actually not 100% about the other options available but I picked the third option “Build Connected Experiences” and everything worked just fine.

Give your app a name and create the app – you will soon be inside the overview page of your app.

From the left menu, select Settings and Basic and scroll down that page until you see the Add Platform button.

Just like we usually do within Firebase, we now create apps in here for iOS and Android.

ionic-facebook-add-platform

The only thing you need to add for iOS is the Bundle ID, which is exactly the ID we have seen in the beginning when setting up our app (or inside the capacitor.config.json).

ionic-fb-ios

Once you got the iOS part, you can go ahead with the Android app for which you now also need the key hash of the keystore that you use to sign your app.

For development, your key is usually located at ~/.android/debug.keystore with the default password android. To get the hash you can simply run:

keytool -exportcert -alias androiddebugkey -keystore ~/.android/debug.keystore | openssl sha1 -binary | openssl base64

Copy that hash output into the Android section Key Hashes, and add the bundle ID inside the Google Play Package Name field.

ionic-fb-android

Now we have added both platforms to our Facebook app and we can continue within our Ionic app.

iOS Facebook Changes

At this point you should have added the native iOS platform to your Ionic Capacitor app, and we need to apply a few changes to it.

First, we need to overwrite the CFBundleURLTypes and add some more keys inside the ios/App/App/Info.plist, which you can either open with Xcode or your standard IDE:

<key>CFBundleURLTypes</key>
	<array>
	<dict>
		<key>CFBundleURLSchemes</key>
		<array>
		<string>fb687134308903752</string>
		</array>
	</dict>
	</array>
	<key>FacebookAppID</key>
	<string>YOUR_FACEBOOK_APP_ID</string>
	<key>FacebookDisplayName</key>
	<string>YOUR_FACEBOOK_APP_NAME</string>
	<key>LSApplicationQueriesSchemes</key>
	<array>
	<string>fbapi</string>
	<string>fbauth2</string>
	</array>

Simply overwrite the existing CFBundleURLTypes with the whole snippet above so everything you need is inside the plist.

Note: Of course use your own FacebookAppID and FacebookDisplayName inside that snippet!

For the next step you need to open the ios/App/App/AppDelegate.swift and add the two imports for the Facebook SDK at the top, and then change the two functions that are inside the snippet below.

The rest of that file can stay the same, make sure you only replace the relevant functions:

import UIKit
import Capacitor
import FacebookCore
import FBSDKCoreKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    FBSDKCoreKit.ApplicationDelegate.shared.application(application, didFinishLaunchingWithOptions: launchOptions)
    // Override point for customization after application launch.
    return true
  }

  func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
    // Called when the app was launched with a url. Feel free to add additional processing here,
    // but if you want the App API to support tracking app url opens, make sure to keep this call
    if CAPBridge.handleOpenUrl(url, options) {
        return FBSDKCoreKit.ApplicationDelegate.shared.application(app, open: url, options: options)
      }
      else{
       return false
      }
  }
}

Now the iOS part is ready and the app knows how to handle the return value after the native Facebook login is finished.

Android Facebook Changes

For Android, we first need to add the Capacitor plugin like always inside theandroid/app/src/main/java/com/devdactic/fblogin/MainActivity.java:

package com.devdactic.fblogin;

import android.os.Bundle;

import com.getcapacitor.BridgeActivity;
import com.getcapacitor.Plugin;

import java.util.ArrayList;

public class MainActivity extends BridgeActivity {
  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    // Initializes the Bridge
    this.init(savedInstanceState, new ArrayList<Class<? extends Plugin>>() {{
      // Additional plugins you've installed go here
      add(com.getcapacitor.community.facebooklogin.FacebookLogin.class);
    }});
  }
}

Simply add the one line inside the init block and you are fine.

For the next step you need to paste the block below under the manifest -> application tag (where you can also see another activity tag).

Open the android/app/src/main/AndroidManifest.xml and insert at that location:

<meta-data android:name="com.facebook.sdk.ApplicationId"
  android:value="@string/facebook_app_id"/>

<activity
    android:name="com.facebook.FacebookActivity"
    android:configChanges="keyboard|keyboardHidden|screenLayout|screenSize|orientation"
    android:label="@string/app_name" />

<activity
    android:name="com.facebook.CustomTabActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="@string/fb_login_protocol_scheme" />
    </intent-filter>
</activity>

Finally we need to add our Facebook app id for two values, so open the android/app/src/main/res/values/strings.xml and change it to this (or simply add the two relevant rows since the rest contains my app bundle id!):

<?xml version='1.0' encoding='utf-8'?>
<resources>
    <string name="app_name">devdacticFbLogin</string>
    <string name="title_activity_main">devdacticFbLogin</string>
    <string name="package_name">com.devdactic.fblogin</string>
    <string name="custom_url_scheme">com.devdactic.fblogin</string>
    <string name="facebook_app_id">YOUR_FACEBOOK_APP_ID</string>
    <string name="fb_login_protocol_scheme">fbYOUR_FACEBOOK_APP_ID</string>
</resources>

Make sure you add fb before the FB App ID for the fb_login_protocol_scheme, that’s no typo! So it should be something like “fb1234567”.

That’s it, both of our native platforms are now configured!

Web Facebook Changes

If you also want to test the process on the browser because you want to build an Ionic website or simply for testing, you need to load the Facebook JS SDK inside the
src/index.html:

<script>
    window.fbAsyncInit = function () {
      FB.init({
        appId: 'YOUR_FACEBOOK_APP_ID',
        cookie: true, // enable cookies to allow the server to access the session
        xfbml: true, // parse social plugins on this page
        version: 'v5.0' // use graph api current version
      });
    };
  
    // Load the SDK asynchronously
    (function (d, s, id) {
      var js, fjs = d.getElementsByTagName(s)[0];
      if (d.getElementById(id)) return;
      js = d.createElement(s); js.id = id;
      js.src = "https://connect.facebook.net/en_US/sdk.js";
      fjs.parentNode.insertBefore(js, fjs);
    }(document, 'script', 'facebook-jssdk'));
  </script>

Put that snippet simply below the body of your page and insert your Facebook App id in the relevant place again.

Ionic Facebook Login

Now we can finally trigger the dialog from our Ionic app, which is actually the easiest part.

The only challenge is that the plugin is a bit different for web and native app, so we use a little switch inside the setupFbLogin to check on which platform we are running and either use the web version (which is already registered at the top) or use the according object from the Capacitor plugins object.

Once this is done, you can call all functions on the plugin to login a user with specific permissions, which will give you an accessToken plus more information inside a native app. When you run the app inside the browser preview, the plugin will only return the token and no other user information, so in that case we can directly call the getCurrentToken which will then give us all relevant information.

Finally with the token in place and the user id available, we can make a dummy call to the Graph API inside the loadUserData to retrieve some general information about that user!

Now go ahead and change the home/home.page.ts to:

import { Component } from '@angular/core';
import { FacebookLoginPlugin } from '@capacitor-community/facebook-login';
import { Plugins, registerWebPlugin } from '@capacitor/core';
import { isPlatform } from '@ionic/angular';
import { HttpClient } from '@angular/common/http';

import { FacebookLogin } from '@capacitor-community/facebook-login';
registerWebPlugin(FacebookLogin);

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

  constructor(private http: HttpClient) {
    this.setupFbLogin();
  }

  async setupFbLogin() {
    if (isPlatform('desktop')) {
      this.fbLogin = FacebookLogin;
    } else {
      // Use the native implementation inside a real app!
      const { FacebookLogin } = Plugins;
      this.fbLogin = FacebookLogin;
    } 
  }

  async login() {
    const FACEBOOK_PERMISSIONS = ['email', 'user_birthday'];
    const result = await this.fbLogin.login({ permissions: FACEBOOK_PERMISSIONS });

    if (result.accessToken && result.accessToken.userId) {
      this.token = result.accessToken;
      this.loadUserData();
    } else if (result.accessToken && !result.accessToken.userId) {
      // Web only gets the token but not the user ID
      // Directly call get token to retrieve it now
      this.getCurrentToken();
    } else {
      // Login failed
    }
  }

  async getCurrentToken() {    
    const result = await this.fbLogin.getCurrentAccessToken();

    if (result.accessToken) {
      this.token = result.accessToken;
      this.loadUserData();
    } else {
      // Not logged in.
    }
  }

  async loadUserData() {
    const url = `https://graph.facebook.com/${this.token.userId}?fields=id,name,picture.width(720),birthday,email&access_token=${this.token.token}`;
    this.http.get(url).subscribe(res => {
      this.user = res;
    });
  }

  async logout() {
    await this.fbLogin.logout();
    this.user = null;
    this.token = null;
  }
}

If you want to test other API calls, simply give the cool Facebook Graph API Explorer a try!
The last step is to simply show a few buttons to test our app, so go ahead and replace everything inside the home/home.page.html with:

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

<ion-content>

  <ion-card *ngIf="user">
    <img [src]="user.picture.data.url">
    <ion-card-header>
      <ion-card-title>
        {{ user.name }}
      </ion-card-title>
      <ion-card-subtitle>
        {{ user.birthday }}
      </ion-card-subtitle>
    </ion-card-header>
    <ion-card-content>
      {{ user.email }}
      <ion-button (click)="logout()" expand="full">Logout</ion-button>
    </ion-card-content>
  </ion-card>
  <ion-button (click)="login()" expand="full">Login</ion-button>

</ion-content>

That’s it! Now run your app in the preview mode or on a real device and enjoy your Ionic Facebook login.

Conclusion

Adding Facebook login to your Ionic app isn’t very complicated if you follow the exact steps necessary to configure your app correctly upfront.

If you encounter problems, make sure you got the bundle ID correct everywhere and also that you are using the right Android hash.

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

The post How to Add Ionic Facebook Login with Capacitor appeared first on Devdactic - Ionic Tutorials.

How to Build Your Own Ionic Library for NPM

$
0
0

You find yourself creating custom components, pages and CRUD operations for your apps over and over? The solution might be to create your own Ionic library that you can install from NPM!

In this tutorial we will create an Angular library, use Ionic components and export our own functionality that allows us to even route to a whole page coming from the Ionic library!

ionic-library

At the same time we will include our Ionic library inside an Ionic testing app to see our changes in realtime, and finally publish the whole package to NPM so everyone can install it.

You can actually see my published devdactic-lib package right here!

Creating the Angular Library Project

The first step to your own custom Ionic library is to generate an Angular library. But this library needs to live inside a workspace, so we generate a new workspace without any application first and then generate the library and add two more components to it.

Go ahead and run:

# npm install -g @angular/cli
ng new devdacticWorkspace --create-application=false
cd ./devdacticWorkspace

ng generate library devdactic-lib --prefix=dev
ng g component customCard
ng g component customPage


ng build
cd dist/devdactic-lib
npm link

After adding the components we run a first build which is necessary at least once to build the /dist folder!

Once the build is done, we can run the link command inside the folder. This command creates a symlink between your local node modules folder and this dist folder, which means you can easily add this folder to an Ionic project afterwards for testing and development.

When you now take a first look at this workspace, you will find the actual code for your library inside projects/devdactic.lib:

ionic-library-overview

This folder is where you build the functionalities of your Ionic library, add components and services, and declare everything correctly so we can import the module easily in other applications.

For development, I now recommend you run the build command with the watch flag, which is basically live reload for your component whenever something changes!

ng build --watch

During development I sometimes had to restart this command or the Ionic serve, since some changes were not picked up correctly.

Testing the Ionic Library

Before we dive any further into the library, let’s simultanously create our Ionic app that uses our library.

Create a blank new project and install the package like it was already on NPM – it will directly pick up your local symlink instead:

ionic start devdacticLibraryApp blank --type=angular --capacitor
cd ./devdacticLibraryApp
npm link devdactic-lib

You won’t see it inside your package.json file, but you should see a statement on the console which shows the path of your symlink.

There’s also a tiny issue with the Angular setup and symlinks, and in order to fix this you should now open your angular.json inside the Ionic project and add the preserveSymlinks entry at the shown path:

"projects": {
    "app": {
      "architect": {
        "build": {
          "options": {
            "preserveSymlinks": true,

Now the compiler will be happy, and you can add the module as a little test to the src/app/home/home.module.ts like you are used to:

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 { DevdacticLibModule } from 'devdactic-lib';

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

Since the library only exports the default generated component by now, we can not yet use our own components. But for testing, we can use the automatically created one inside the src/app/home/home.page.html like this:

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

<ion-content>
 <dev-devdactic-lib></dev-devdactic-lib>
</ion-content>

It’s time to bring up ionic serve or Capacitor livereload on a device and check out the result:

If you see something like this, it means your Ionic library integration into the app with symlink works! If not, try to check the logs from both the library and your app, restart the commands and see if you missed something from above.

When you can see that your app and library are connected, it’s time to move on.

Adding Ionic to your Library

By now the library is merely an Angular library and doesn’t know about Ionic components at all. In order to use stuff like ion-card or ion-list inside the lib, we need to install Ionic as a development dependency:

cd projects/devdactic-lib
npm i @ionic/angular --save-dev

You see that we navigate into the actual library folder first, because there’s also a package.json at the top of our workspace, but that’s the wrong place to install the depedency:

ionic-library-package-dependencies

We want to have it right inside the library, not at the top!

Also, the command for installing the dependency is not enough since we also need to add it as a peerDependencies inside the projects/devdactic-lib/package.json:

{
  "name": "devdactic-lib",
  "version": "0.0.1",
  "peerDependencies": {
    "@angular/common": "^9.1.12",
    "@angular/core": "^9.1.12",
    "@ionic/angular": "^5.5.0"
  },
  "dependencies": {
    "tslib": "^1.10.0"
  },
  "devDependencies": {
    "@ionic/angular": "^5.5.0"
  }
}

Whenever someone uses our package, Angular will check if the peer dependencies are already installed in the parent project or otherwise install it, since the library now depends on it.

Now we are ready to add all the Ionic stuff into the library.

Preparing the Exports of your Library

You see that a lot of this isn’t working 100% with CLI commands yet, so you have to take some manual extra steps to make everything work.

Since apps from the outside using our package don’t know about the content automatically, we need to make sure that we are exporting everything correctly. Therefore, open the projects/devdactic-lib/src/public-api.ts and change it to:

/*
 * Public API Surface of devdactic-lib
 */

export * from './lib/devdactic-lib.service';
export * from './lib/devdactic-lib.component';
export * from './lib/devdactic-lib.module';
export * from './lib/custom-card/custom-card.component';

We have only added our custom card, since we will handle the other page component a bit differently in the end.

Now it’s time to add everything to the main module of the library, but we also add something else:

You might have seen this with other packages that you include with a forRoot() call in your module, and that’s the behaviour we want to implement as well. To do so, we need to add a function to the main module of our library that exports some information and becomes a LibConfig object which is a simple interface that we define in there as well.

You could also pass more or other information to your library of course, we will simply pass a URL to it for now.

We as create an InjectionToken with our interface as this is not defined at runtime, we just need it do be injected into our module. We will also inject this LibConfigService in the next step inside a service to retrieve the actual value that was passed to our library!

Now change the projects/devdactic-lib/src/lib/devdactic-lib.module.ts to:

import { DevdacticLibService } from './devdactic-lib.service';
import { NgModule, ModuleWithProviders, InjectionToken } from '@angular/core';
import { DevdacticLibComponent } from './devdactic-lib.component';
import { CustomCardComponent } from './custom-card/custom-card.component';
import { CommonModule } from '@angular/common';
import { IonicModule } from '@ionic/angular';
import { HttpClientModule } from '@angular/common/http';

export interface LibConfig {
  apiUrl: string;
}
 
export const LibConfigService = new InjectionToken<LibConfig>('LibConfig');

@NgModule({
  declarations: [DevdacticLibComponent, CustomCardComponent],
  imports: [
    CommonModule,
    HttpClientModule,
    IonicModule
  ],
  exports: [DevdacticLibComponent, CustomCardComponent]
})
export class DevdacticLibModule {
  static forRoot(config: LibConfig): ModuleWithProviders {
    return {
      ngModule: DevdacticLibModule,
      providers: [
        DevdacticLibService,
        {
          provide: LibConfigService,
          useValue: config
        }
      ]
    };
  }
}

This setup might look a bit strange or difficult first, but it’s mandatory in order to feed some settings/values to our library later.

Using Ionic Components inside the Library

Now that we got the setup done we can start and add Ionic components inside our library.

To get started, simply change the projects/devdactic-lib/src/lib/custom-card/custom-card.component.html to this:

<ion-card>
    <ion-card-header>
        <ion-card-title>{{ title }}</ion-card-title>
    </ion-card-header>
    <ion-card-content>
        {{ content }}
    </ion-card-content>
</ion-card>

A simple card, but we will use dynamic values and so we define inputs for them just like we would when creating a shared component inside an Ionic app.

Therefore, open the according projects/devdactic-lib/src/lib/custom-card/custom-card.component.ts and change it to:

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

@Component({
  selector: 'dev-custom-card',
  templateUrl: './custom-card.component.html',
  styleUrls: ['./custom-card.component.css']
})
export class CustomCardComponent implements OnInit {
  @Input() title: string;
  @Input() content: string;

  constructor() { }

  ngOnInit(): void {
  }

}

That means, we can now use those inputs to bring values to the component, and since we already imported the module for our library inside our Ionic app before, we can now directly make use of our new component by adding the component inside the src/app/home/home.page.html of our testing app:

<dev-custom-card title="My Coold Library Card" content="There will be dragons"></dev-custom-card>

Serve the application, and you should see the card (coming from the library) filled with our own values!

ionic-library-initial-test

Using Services from our Ionic Library

This was just the start, now let’s continue with a service that we can directly import to our app. You could add all kind of useful stuff in here, in this example we will simply use the apiUrl of our LibConfig interface as the base URL to make an HTTP request.

In our previous example on building a WordPress library we made calls to the WP API based on the base url, which was even more helpful.

This time we will inject the base URL “https://randomuser.me/” in the next step, and since a call to that dummy api looks like “https://randomuser.me/api” we add the “api” part in our service.

Go ahead and change the projects/devdactic-lib/src/lib/devdactic-lib.service.ts to:

import { HttpClient } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { LibConfigService, LibConfig } from './devdactic-lib.module';
import { map } from 'rxjs/operators';

@Injectable({
  providedIn: 'root'
})
export class DevdacticLibService {
  baseUrl = this.config.apiUrl;

  constructor(@Inject(LibConfigService) private config: LibConfig, private http: HttpClient) {
    console.log('My config: ', config);
  }

  getData() {
    return this.http.get<any>(`${this.baseUrl}/api`).pipe(
      map((res: any) => res.results[0])
    )
  }
}

Now the service inside our library can make API calls based on a URL we pass to it, and we can pass it inside the forRoot() function right inside the src/app/app.module.ts of our Ionic testing app 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 { SplashScreen } from '@ionic-native/splash-screen/ngx';
import { StatusBar } from '@ionic-native/status-bar/ngx';

import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
import { DevdacticLibModule } from 'devdactic-lib';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule,
    DevdacticLibModule.forRoot({
      apiUrl: 'https://randomuser.me'
    })],
  providers: [
    StatusBar,
    SplashScreen,
    { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }
  ],
  bootstrap: [AppComponent]
})
export class AppModule {}

Your library starts to look like a real powerful package now!

To see the result of this, we can now inject the service into our src/app/home/home.page.ts just like we do with any other service:

import { Component } from '@angular/core';
import { DevdacticLibService } from 'devdactic-lib';

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

  constructor(private devdacticLibService: DevdacticLibService) { }

  getData() {
    this.devdacticLibService.getData().subscribe(res => {
      this.user = res;
    });
  }

}

The only difference is that we are importing this service from our own Ionic library package instead now!

To finally show the value and call the function, quickly also change the src/app/home/home.page.html to:

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

<ion-content>
  <dev-custom-card title="My Coold Library Card" content="There will be dragons"></dev-custom-card>
  <ion-button expand="full" (click)="getData()">Load Data</ion-button>

  <ion-card *ngIf="user">
    <img [src]="user.picture.large">
    <ion-card-content>
      {{ user.email }}
    </ion-card-content>
  </ion-card>
</ion-content>

Alright, component and services from the Ionic library are working, now to the last missing piece.

Using Pages and CSS Variables from our Ionic Library

This was a question under the last version of this tutorial: How to include a page from the library?

An Ionic page is just like an Angular component, but usually Ionic pages come with their own routing and module to allow lazy loading, and we can do the same with our library now!

Therefore we first of all need to create a new file named custom-page-routing.module.ts inside our library within the custom page folder. Then, we can change the newly created projects/devdactic-lib/src/lib/custom-page/custom-page-routing.module.ts to look like inside a real Ionic app:

import { CustomPageComponent } from './custom-page.component';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';

const routes: Routes = [
  {
    path: '',
    component: CustomPageComponent,
  }
];

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

Additionally the page needs a module, so we create a new module at projects/devdactic-lib/src/lib/custom-page/custom-page.module.ts and insert:

import { CustomPageComponent } from './custom-page.component';
import { CustomPageRoutingModule } from './custom-page-routing.module';
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule } from '@ionic/angular';
import { FormsModule } from '@angular/forms';

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    IonicModule,
    CustomPageRoutingModule
  ],
  declarations: [CustomPageComponent]
})
export class CustomPageModule {}

The component is now basically like any other Ionic page that you generate, but we now need to export it a bit different inside the library.

To do so, open the projects/devdactic-lib/src/public-api.ts again and export the module instead of just the component this time:

export * from './lib/devdactic-lib.service';
export * from './lib/devdactic-lib.component';
export * from './lib/devdactic-lib.module';
export * from './lib/custom-card/custom-card.component';
export * from './lib/custom-page/custom-page.module';

Next step is to make the page look like an actual Ionic page, and to do so simply change the projects/devdactic-lib/src/lib/custom-page/custom-page.component.html to some Ionic markup of a page:

<ion-header>
    <ion-toolbar color="primary">
        <ion-buttons slot="start">
            <ion-back-button  defaultHref="/"></ion-back-button>
        </ion-buttons>
        <ion-title>
            Devdactic Lib Page
        </ion-title>
    </ion-toolbar>
</ion-header>

<ion-content>
    <div class="custom-box"></div>
    This is a full page from the library!
</ion-content>

Since I also wanted to show how to inject some styling into your library, add the following snippet to the projects/devdactic-lib/src/lib/custom-page/custom-page.component.css:

.custom-box {
    background: var(--custom-background, #ff00ff);
    width: 100%;
    height: 100px;
}

Just like Ionic components, we can also define our own CSS variables that we could set from the outside to style the component! if the --custom-background is not set, the fallback value will be used instead.

Now we are ready to use the page inside our testing app, and we need a way to navigate to it.

As said before, we can now use lazy loading and don’t need to import the component directly (which would also work).

That means, we can use the standard Angular import() syntax with the only difference that we are importing the module from our Ionic library. Go ahead and open the src/app/app-routing.module.ts and insert a new route 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: 'custom',
    loadChildren: () => import('devdactic-lib').then( m => m.CustomPageModule)
  },
  {
    path: '',
    redirectTo: 'home',
    pathMatch: 'full'
  },
];

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

Don’t worry if you see compiler warnings at this point, in the end all will be fine. VSC wasn’t picking up the changes for me as well, but it still worked as expected afterwards..

Now we just need to navigate to the new route, so simply add a button with the new route to the src/app/home/home.page.html:

<ion-button expand="full" routerLink="/custom">Open Library Page</ion-button>

And don’t forget about the CSS!

We can style the block on that page right from our Ionic app by setting the CSS variable that we used in the library.

Since we don’t really have any connected styling file for the page, simply add it to the src/global.scss like this:

dev-custom-page {
    .custom-box {
        --custom-background: #1900ff;
    }
}

Now we can navigate to a whole page that’s defined inside our Ionic library and even pass custom styling to it if we want to!

ionic-library-custom-page

Publishing your Ionic Library to NPM

If at some point you want to make the library public or just don’t want to use the local symlink anymore, you can easily distribute the library.

Simply run a build, navigate into the output folder and publish it to npm like this:

ng build --prod
cd dist/devdactic-lib
npm publish

I did so as well, and you can see the devdactic-lib inside the npm package registry. If you used the same name you can’t publish it of course, pick your own library name instead then!

Now installing your new Ionic library is as easy as running:

npm i devdactic-lib

The usage inside your testing app doesn’t change since we used the symlink correctly and it now just switches over to the real files downloaded from npm.

Conclusion

Creating your own Ionic library is a powerful way to build a set of functionality for your company or client that you can reuse across your apps.

Define your own custom element or tweak Ionic components to your needs, or even create wrapper libraries for your APIs to reuse them in different projects.

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

The post How to Build Your Own Ionic Library for NPM appeared first on Devdactic - Ionic Tutorials.

Hosting an Ionic PWA with API Caching on Netlify

$
0
0

If you want to improve the offline experience of your Ionic PWA, it’s actually quite easy to not only cache the static assets but also cache the API calls inside an Ionic PWA!

In this tutorial we will build a simple PWA and work with different caching strategies for different API endpoints.
ionic-pwa-caching

We will also prepare our final PWA and upload it to Netlify, which is a service for hosting web projects that you can get started with for free. You can give my PWA from this tutorial a try here!

Setup your Ionic PWA

To get started, simply create a blank new Ionic project and add the Angular schematic for PWAs which makes our app basically ready as a PWA:

ionic start devdacticPwa blank --type=angular --capacitor
cd ./devdacticPwa
ng add @angular/pwa

The schematic will inject a bunch of files, but we will not fine tune the apperance of our PWA in this tutorial.

If you want more in-depth knowledge about Ionic PWAs, check out the courses inside the Ionic Academy!

Since we want to perform API calls, we also need to add the HttpClientModule like we always do inside our 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 { SplashScreen } from '@ionic-native/splash-screen/ngx';
import { StatusBar } from '@ionic-native/status-bar/ngx';

import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
import { ServiceWorkerModule } from '@angular/service-worker';
import { environment } from '../environments/environment';

import { HttpClientModule } from '@angular/common/http';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [
    BrowserModule,
    IonicModule.forRoot(),
    AppRoutingModule,
    ServiceWorkerModule.register('ngsw-worker.js', {
      enabled: environment.production,
    }),
    HttpClientModule,
  ],
  providers: [
    StatusBar,
    SplashScreen,
    { provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}

Now let’s add the basic logic for our API calls to the app.

Adding a simple API call to your Ionic PWA

We will make two different API calls in this app so we can later see the different caching strategies for our service worker.

Both of them are free to use and we only want to test a few basic things, so they are enough in our case.

To show the current state of our network we can also listen to the networkStatusChange using the Capacitor network plugin. This allows us to even better see and understand how our Ionic PWa works in different scenarios.
Now go ahead and change the home/home.page.ts to:

import { HttpClient } from '@angular/common/http';
import { Component, OnInit } from '@angular/core';
import { Plugins } from '@capacitor/core';
const { Network } = Plugins;

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

  constructor(private http: HttpClient) { }

  async ngOnInit() {
    const status = await Network.getStatus();
    this.appIsOnline = status.connected;

    Network.addListener('networkStatusChange', (status) => {
      this.appIsOnline = status.connected;
    });
  }

  getData() {
    this.http.get('https://randomuser.me/api/?results=5').subscribe(result => {
      console.log('results: ', result);
      this.users = result['results'];
    });
  }

  getOnlineData() {
    this.http.get('https://api.chucknorris.io/jokes/random').subscribe(result => {
      console.log('joke result: ', result);
      this.joke = result;
    });
  }

}

Nothing really special, and in fact nothing related to PWAs or caching?

Yes, that’s right! The service worker configuration happens in a different place, and our app works just fine during testing without the service worker.

For the view, we can simply print out different values from the API calls and add an ion-footer so we can directly show when the network state in our app changes.

Open the home/home.page.html and replace it with:

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

<ion-content>
  <ion-button expand="block" (click)="getData()">Get Data</ion-button>
  <ion-button expand="block" (click)="getOnlineData()">Get Online Data</ion-button>

  <ion-card *ngIf="joke">
    <ion-card-content>
      {{ joke.value }}
    </ion-card-content>
  </ion-card>

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

</ion-content>

<ion-footer>
  <ion-toolbar color="primary" *ngIf="appIsOnline">
    <ion-title>You are online :)</ion-title>
  </ion-toolbar>
  <ion-toolbar color="danger" *ngIf="!appIsOnline">
    <ion-title>You are offline :(</ion-title>
  </ion-toolbar>
</ion-footer>

Right now the app will already work as expected inside a browser, but we are not yet using the full power of the service worker.

Caching API Calls inside your Ionic PWA

If we would build our app right now as a PWA, it would work like the web version: You get data when you are online, you get an error and no data when you are offline.

But we can improve that behaviour with caching strategies that we pass to the service worker that works in the background of our PWA.

This worker already downloads all the static files of our app when we install the app as a PWA, and we can do even more by configuring different API endpoints that we also want to cache.

In our case we got two endpoints, and you can use multiple URLs or wildcards and entries to define different strategies for different endpoints. Sounds complicated, but it’s just flexible.

In our case, we will use both strategies:

  • freshness: This strategy means we want to get the most up to date data from the API, and only present a cached version after the timeout specified.
  • performance: Show data as fast as possible, so if anything is cached, it will be returned immediately from there instead of calling the API.

To use this information, open your ngsw-config.json and add the following entry on the top level next to the already existing assetGroups which are used for caching static files:

"dataGroups": [
    {
      "name": "joke",
      "version": 1,
      "urls": ["https://api.chucknorris.io/**"],
      "cacheConfig": {
        "strategy": "freshness",
        "maxSize": 5,
        "maxAge": "5h",
        "timeout": "3s"
      }
    },
    {
      "name": "randomuser-api",
      "version": 1,
      "urls": ["https://randomuser.me/api/?results=5"],
      "cacheConfig": {
        "strategy": "performance",
        "maxSize": 10,
        "maxAge": "20s",
        "timeout": "3s"
      }
    }
  ],

Now we can finally test our PWA, and the easiest way is to run a production build and a local server to serve the files using the http-server:

ionic build --prod
http-server www

After running this you can inspect your app on http://127.0.0.1:8080 and play around with the PWA.

Note: If you get any problems during testing, simply click “Clear site data” inside the Application -> Clear storage menu of your browser debugging tools!

You can first make a standard request with your PWA being online, then you can go into offline mode inside the Network tab of your developer tools.

When you now run the requests again, you will see that the requests you make are filled by the service worker:
ionic-pwa-service-worker

You can see two succeeded calls handled by the SW, and one failed because our one API resource has the caching strategy freshness and tries to get new data!

You can even inspect which data is currently cached by going to Application -> Cache Sorage:

ionic-pwa-cached-data

If you data is not present in here after your standard API calls, something with the URLs of your caching strategy is messed up, or the SW is out of sync and you should clear the site data and refresh the browser.

Keep in mind that we set a maxAge of 10 seconds for our one API resource, and therefore the data won’t be used after that time passed!

Netlify Ionic PWA hosting

So we are confident about our Ionic PWA and tested it locally, but now we want to see it on a real device! There are a lot of different ways like using Firebase hosting for your PWA, or using a service like Vercel.

To show you another way (which is very similar to Vercel) we will use Netlify in this tutorial to build and host the PWA.

Since Netlify needs to build our app in the end, we need to add another script to the scripts object of our package.json upfront:

"build:prod": "ng build --prod"

This makes it easier to run a production build automatically.

Now we need a Git repository, and you can either use Github or Bitbucket for this.

Simply create a new repository in your account without any files, because we already got a Git repository locally through our Ionic project!

ionic-pwa-github-repo

Both Github and Bitbucket will show you commands to connect your existing repository with this new repo, and usually you just need to add your local files, commit them and then add a new origin and push the code:

git add .
git commit -am 'Initial commit.'

# Copy this from your repository!
git remote add origin https://github.com/saimon24/devdactic-pwa.git

git push -u origin master

Next time I’ll also use main instead of master, which is now the recommended terminus.

After your push your code should show up inside the repository, and you can start with Netlify by creating a new account.

Inside your account, click on New site from Git to start the wizard that will add your project. You will need to authorise Netlify to access your Github (Bitbucket) account and you can select the previously created project.
ionic-pwa-netlify-setup

The important part is now to configure the build correctly in the next step:

  • Build command: npw run build:prod
  • Publish directory: www

ionic-netlify-build-settings

Based on this information, Netlify can now pull in your code, run a production build (using the additional script we added to the package.json!) and host the output www folder to serve the PWA!

Once you deploy the site, you can see the log and finally get the URL to your deployment. I’ve hosted my PWA from this tutorial here!

Whenever you now push code, Netlify will run a new build of your app and update your PWA – what a great service.

Conclusion

Embracing the service worker inside your Ionic PWA and caching different resources and API endpoints can help to make your PWA work completely offline based on cached information.

Keep in mind that the service worker is only inside a PWA, so if you create a real native iOS or Android app from your code, you need a different caching approach as the service worker wont work in there like it does in our PWA.

On top services like Netlify and Vercel make it super easy these days to quickly build and host your Ionic PWA starting for free!

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

The post Hosting an Ionic PWA with API Caching on Netlify appeared first on Devdactic - Ionic Tutorials.

2020 as a Solopreneur: A Review

$
0
0

How to start a review for 2020? I deleted these lines 10 times in the hope for a better introduction but the year has been exactly like that: Confusing, chaotic, challenging and different.

We are all affected by the pandemic and likely a huge potion of 2021 will be like that as well. But I’m happy that I as a Solopreneur and my family still made it through the year, and I’m lucky that my business wasn’t realy negatively affected by everything going on right now.

Instead of focusing on all the things I can’t control (which are quite a lot), I tried to focus on what I can do:

Give my best on every task!

So here’s what worked and didn’t, and the challenges along the way.

Ionic Academy – Recurring revenue!

The Ionic Academy is my main source of recurring revenue and had a peak of 470 active members during the year, which is a new best!

I am happy that this project was actually more positively affected by the pandemic since many people worked from home or wanted to acquire new skills.

Overall I’m happy again with the growth, but the churn continues to be a problem. Developers usually join for a specific project or problem, so it’s natural that they leave after 1-2 months again.

Besides ongoing fresh content and the support inside the Academy, I have started a new template of the month section which gets a fresh new template every month next year!

On top of that I’m thinking about monthly Q&A live sessions or small challenges for more user engagement, but so far I haven’t found a way to dramatically reduce churn and motive members to stay longer (although there are quite some long time members and supporters <3). This issue could also be tackled by getting more companies to sign up for an account, but so far 99% of my content is targeted towards developers and not really bigger enterprises that it's no surprise that manager don't show up. The whole B2B area is still almost unknown to me, so I'm not sure if I can find an entry or right approach to get more companies on board in 2021 (reach out if you have an idea about that!).

Kickoff Ionic – A Micro Saas?

This is an on-off love: I released the Kickoff Ionic tool to bootstrap and configure Ionic apps faster this year, and the project has about 40 Pro accounts by now!

It’s not a huge financial success compared to other projects, but it proves to me that I can build small SaaS applications with my tech stack and get people on board to pay for my work (work that’s different than my usual content).

After the initial motivation I worked on other projects and had a hard time finding motivation again, but I’ve worked on some new features over the last time again. Plus it’s technically challenging and not the type of project you easily work on while watching Netflix in the evening, which is currently the only spot I got for side projects..

I am still not sure how to handle this project but I’m currently creating a traction plan with a customer goal and steps that would have a bigger impact on the project to finally make this a real success not only from a technical point but also from a financial point of view.

It’s hard to justify work on a project that’s not adding something to your monthly baseline, so let’s see if I can turn around the current situation again in 2021!

Practical Ionic – I’m an author!

After one book years ago I finally felt it’s time to create a new eBook, and I’m kinda happy with the result which is Practical Ionic.

It took some time to set up everything for the build process since the book is written in Markdown and converted with some Latex scripts, but now that I got the base I’m confident to reuse this basic template for the next book(s) in 2021!

In terms of money, the book generated ~$20k by now (released end of July 2020) and although I think it’s not matching all the time invested, it was a good project. Plus it was a nice combination during the Black Friday Special I did with IonicThemes.

The book still makes sales every week, so the revenue will continue to grow this year. Plus I’ve already outlined a second Practical Ionic which focuses on different use cases around API connection with Ionic apps.

Since I got the basic stuff for the book already in place, hopefully the next book will be finished a bit faster!

No More Time for Money exchange.

When I became self employed almost 4 years ago, I had a handful of clients I worked with every now and then. During the year I have not accepted any new clients since I wanted to focus 100% on my content and projects, and by now there’s only one project I sometimes help with.

The decision wasn’t easy, especially during a global pandemic with a lot of uncertainty everywhere.

On top of that, it’s nice and “easy” to accept that type of work which usually brings in a good amount of money. Plus you can directly calculate how much your current task is worth, which isn’t really possible when I write a tutorial, record a video or schedule social media updates.

But my mid-term goal is to live 100% from the income of my own products, and so I had to make a decision to reduce the amount of hours I directly exchange for money, and instead use that time to build new products.

If I’d to the calculation for the projects I created (like Kickoff Ionic or the book) I’m quite sure consulting would have been more lucrative.

In the end it’s not about the money for me, it’s about doing what I love and what I think I’m best at.

Therefore, I’m not actively looking for new clients in 2021 as I have enough ideas to fill all my available time – the challenge is to work on those things that have the potential of generating income like new books, courses or finally a bigger SaaS application.

YouTube & Twitch – Am I a YouTuber yet?

Despite growing my YouTube channel again by 10k followers, I still don’t really call myself a YouTuber. I had the goal of reaching 100k one day, but with the current growth it will take longer than I guess YouTube hands out these awards..

Anyway, I released at least 1 video per week, often an additional Vlog video on top as well. That took quite some time, and after being frustrated with my own topic selection I decided to double down on more Ionic related topics for these videos as well.

For 2021 I plan to create some different content on YT and experiment more. Explain videos, differently structured tutorials or playlists explaining how to build bigger apps or UIs.

After all, expecting to suddenly grow more than before is quite unrealistic if you don’t change how you approach a platform. So next year I’m trying to improve the quality of my videos and do more of the things people would like to see!

On top of that I started experimenting with live streams and Twitch at the end of the year and had about 80 people watching my second live stream – WOW!

This is definitely something I plan to do regularly in the future and stream at the same time to both Twitch and YouTube.

Instagram, Facebook, Twitter

I’m not as strict with posting on social media as I am with my main content, but I posted quite frequently on Instagram most of the year.

Right at the start of the year I also opened a Facebook group called Simonics for everyone interested in sharing Ionic stuff, but I must admit I didn’t spend enough time inside the group yet. Still, it has grown to ~450 people so far!

Overall I don’t have any fixed numbers or goals I want to reach with these platforms, I just like to connect with people and give some help whenever possible, or otherwise share some ideas and inspiration.

As a solopreneur and the face of my business, I just want to show how it’s like, what I’m doing and learning and be different in a way that big companies just can’t be. There’s a human beeing behind Devdactic, the Ionic Academy and Kickoff Ionic, and I want to show that (that’s why I also record a personal welcome video for EVERY new Ionic Academy member!).

Towards the end of the year I also became more active on Twitter again, I think it’s a platform I will put more effort into next year.

What I Read

I’m usually reading books that match my current mood or things that are relevant to me. I didn’t hit my reading goal of 1 book per month, but anyway I enjoyed most of these:

  • The Compound effect
  • Why we sleep
  • Building a Storybrand
  • Atomic Habits
  • Factfulness
  • Ikigai
  • The Obstacle is the Way
  • The One thing
  • Ego is the enemy
  • Think like a Monk

I don’t use affiliate links so simply look them up on Amazon if you are interested. All of them capture at least some interesting ideas!

For 2021 I have the rough goal of 1 book per month again, but I don’t have a fixed list yet. Sometimes I pick a book that’s on my list for some time, sometimes I feel like I need inspiration on a specific topic.

If you are interested in books I read or recommend for solo founders, I might do a video on that as well!

2021 – The Year of ?

The last years I had like a theme for my year, but planning for 2021 feels like 2020 was do some degree: Unforeseeable.

On top of that there’s not something big I want to start during this year, since (like right now again) I don’t know how much I’ll actually work. We had our daughter many months at home without daycare last year, and it’s going to be like this throughout January again. Probably even longer, who knows?

My wife and I share the days so each of us can work at least 4 hours, but that’s no time for an entrepreneur to live out ideas, plan big projects or start challenging projects.

This year is more like: Keep doing what you do, improve a little, try a little, give your best.

Honestly?

I’m not really motivated by that. Who is motivated by just continuing the usual things you already do?

But on the other hand it’s a sign that things are good and don’t need huge adjustment.

Whatever this year brings, I think it’s best to not worry about your ultimate life goals and how you are losing time right now. We are living through a global pandemic that affects everyone differently.

My solution is: Think from week to week and sometimes just day to day. Plan as far as possible, but don’t get upset when you have to change plans once agains. It’s going to happen anyway.

Enjoy the tasks on a specific day, enjoy the results of a good week and stay positive.

If we can keep that up, we might just look outside one day and see that things are slowly coming back to normal.

Here’s to a different, uncertain and challenging 2021 – let’s all do our best in every possible way.

The post 2020 as a Solopreneur: A Review appeared first on Devdactic - Ionic Tutorials.


Building an Ionic JWT Refresh Token Flow

$
0
0

When you want to implement a more secure authentication with Ionic, the Ionic JWT refesh token flow is an advanced pattern with two tokens to manage.

In this tutorial we will implement the Ionic app based on a simple API that I created upfront with NestJS. This flow is based on two tokens, one access token with a short time to live, and another refresh token which can be used to get a new access token from the server.

This means, even if the access token was leaked a potential hacker can only use this for a short duration, and if we know about a security breach we could also block the refresh tokens from being used again!

ionic-jwt-refresh-token

The API can be found at https://tutorial-token-api.herokuapp.com and offers the basic routes that we need to implement a full Ionic JWT refresh token flow.

What we will do is:

  1. Signup & login a user
  2. Attach a JWT to all of our calls to the API to authenticate the user
  3. Use a refresh token once our access token expires to get a new token for the next call

All of that needs some additional logic inside an interceptor, but let’s start with the basics.

Starting the Refresh Token App

To get started, we bring up a new Ionic app and add two pages and a service for our JWT refresh token flow. On top of that we can also add a guard to protect our internal routes, so run the following:

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

ionic g page pages/login
ionic g page pages/inside

ionic g service services/api

# Secure your routes
ionic g guard guards/auth --implements CanLoad

Now we can also add the URL to our backend to our src/environments/environment.ts to easily access it from anywhere:

export const environment = {
  production: false,
  api_url: 'https://tutorial-token-api.herokuapp.com'
};

Since we need to make HTTP calls, we also add the HttpClientModule to our app/app.module.ts as usual:

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router';

import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
import { SplashScreen } from '@ionic-native/splash-screen/ngx';
import { StatusBar } from '@ionic-native/status-bar/ngx';

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: [
    StatusBar,
    SplashScreen,
    { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }
  ],
  bootstrap: [AppComponent]
})
export class AppModule {}

Finally we can get started with the routing for the app which should simply display the login page and afterwards an inside area that is protected by our guard (well not yet, but we will build out the guard later).

Go ahead and change the app/app-routing.module.ts to:

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: 'inside',
    loadChildren: () => import('./pages/inside/inside.module').then( m => m.InsidePageModule),
    canLoad: [AuthGuard]
  },
];

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

Now we got the basics in place, and if you want an even more template you could also use the basics of the Ionic 5 navigation with login template!

Building the API Service Functionality

Next step is to implement the basic service functionality required for our token flow. First of all, here’s an overview of the relevant API routes in the dummy server:

  • Register: POST to /users
  • Login: POST to /auth
  • New Access Token: POST to /auth/refresh
  • Logout: POST to /auth/logout
  • Get protected data: GET to /users/secret

For now we will get started with the basic mechanism to sign up a new user, a function to call the protected route (the JWT header will be added later), and the login, which will save upon successful login the access token and refresh token that we get back from the server.

Get started with the service by changing the services/api.service.ts to:

import { environment } from './../../environments/environment';
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { tap, switchMap } from 'rxjs/operators';
import { BehaviorSubject, from, Observable, of } from 'rxjs';
import { Plugins } from '@capacitor/core';
import { Router } from '@angular/router';
const { Storage } = Plugins;

const ACCESS_TOKEN_KEY = 'my-access-token';
const REFRESH_TOKEN_KEY = 'my-refresh-token';

@Injectable({
  providedIn: 'root'
})
export class ApiService {
  // Init with null to filter out the first value in a guard!
  isAuthenticated: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(null);
  currentAccessToken = null;
  url = environment.api_url;

  constructor(private http: HttpClient, private router: Router) {
    this.loadToken();
  }

  // Load accessToken on startup
  async loadToken() {
    const token = await Storage.get({ key: ACCESS_TOKEN_KEY });    
    if (token && token.value) {
      this.currentAccessToken = token.value;
      this.isAuthenticated.next(true);
    } else {
      this.isAuthenticated.next(false);
    }
  }

  // Get our secret protected data
  getSecretData() {
    return this.http.get(`${this.url}/users/secret`);
  }

  // Create new user
  signUp(credentials: {username, password}): Observable<any> {
    return this.http.post(`${this.url}/users`, credentials);
  }

  // Sign in a user and store access and refres token
  login(credentials: {username, password}): Observable<any> {
    return this.http.post(`${this.url}/auth`, credentials).pipe(
      switchMap((tokens: {accessToken, refreshToken }) => {
        this.currentAccessToken = tokens.accessToken;
        const storeAccess = Storage.set({key: ACCESS_TOKEN_KEY, value: tokens.accessToken});
        const storeRefresh = Storage.set({key: REFRESH_TOKEN_KEY, value: tokens.refreshToken});
        return from(Promise.all([storeAccess, storeRefresh]));
      }),
      tap(_ => {
        this.isAuthenticated.next(true);
      })
    )
  }
}

We also got the basic token loading in place when the app starts so we can see if a user was logged in before. In a real app you could also check the expiration dates of the tokens in that place as well!

On top of these functions we need to provide a way to get a new access token once the existing expires. To get a new token, we can load the current refresh token from storage, perform an APU request and return that result.

Caution: In my API I designed the /auth/refresh route to expect the refresh token inside the header of the HTTP call. Since we will later build an interceptor that automatically attaches the standard access token to the header, we now need to set the header manually for this call in here!

For the logout you could also use a POST to correctly sign out users of your API and remove any stored or active tokens for them, that’s why the logout is currently a POST. In our case, we will simply continue to remove all information inside the app and guide the user back to the login.

Now finish our service by adding the following to the services/api.service.ts:

// Potentially perform a logout operation inside your API
// or simply remove all local tokens and navigate to login
logout() {
    return this.http.post(`${this.url}/auth/logout`, {}).pipe(
      switchMap(_ => {
        this.currentAccessToken = null;
        // Remove all stored tokens
        const deleteAccess = Storage.remove({ key: ACCESS_TOKEN_KEY });
        const deleteRefresh = Storage.remove({ key: REFRESH_TOKEN_KEY });
        return from(Promise.all([deleteAccess, deleteRefresh]));
      }),
      tap(_ => {
        this.isAuthenticated.next(false);
        this.router.navigateByUrl('/', { replaceUrl: true });
      })
    ).subscribe();
  }
  
  // Load the refresh token from storage
  // then attach it as the header for one specific API call
  getNewAccessToken() {
    const refreshToken = from(Storage.get({ key: REFRESH_TOKEN_KEY }));
    return refreshToken.pipe(
      switchMap(token => {
        if (token && token.value) {
          const httpOptions = {
            headers: new HttpHeaders({
              'Content-Type': 'application/json',
              Authorization: `Bearer ${token.value}`
            })
          }
          return this.http.get(`${this.url}/auth/refresh`, httpOptions);
        } else {
          // No stored refresh token
          return of(null);
        }
      })
    );
  }
  
  // Store a new access token
  storeAccessToken(accessToken) {
    this.currentAccessToken = accessToken;
    return from(Storage.set({ key: ACCESS_TOKEN_KEY, value: accessToken }));
  }

We got the logic in place to get all relevant information from our API, now we can integrate the service in our pages to move on with out JWT refresh flow.

Building the Login Page

To sign up and log in users we need a little form to capture data, and since we want to use a reactive form we need to add the ReactiveFormsModule to our src/app/pages/login/login.module.ts:

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

Now we can build the form and the functions for sign up and login, which will basically simply call our service functionality and surround everything with a bit of loading and error handling!

If the API result is a success, we can move the user forward to the inside are of our app. Now go ahead and change the src/app/pages/login/login.page.ts to:

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { AlertController, LoadingController } from '@ionic/angular';
import { Router } from '@angular/router';
import { ApiService } from '../../services/api.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 apiService: ApiService,
    private alertController: AlertController,
    private router: Router,
    private loadingController: LoadingController
  ) {}
 
  ngOnInit() {
    this.credentials = this.fb.group({
      username: ['', Validators.required],
      password: ['', Validators.required],
    });
  }
 
  async login() {
    const loading = await this.loadingController.create();
    await loading.present();
    
    this.apiService.login(this.credentials.value).subscribe(
      async _ => {        
        await loading.dismiss();        
        this.router.navigateByUrl('/inside', { replaceUrl: true });
      },
      async (res) => {        
        await loading.dismiss();
        const alert = await this.alertController.create({
          header: 'Login failed',
          message: res.error.msg,
          buttons: ['OK'],
        });
        await alert.present();
      }
    );
  }

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

    this.apiService.signUp(this.credentials.value).subscribe(
      async _ => {
        await loading.dismiss();        
        this.login();
      },
      async (res) => {
        await loading.dismiss();        
        const alert = await this.alertController.create({
          header: 'Signup failed',
          message: res.error.msg,
          buttons: ['OK'],
        });
        await alert.present();
      }
    );
  }
}

To capture the data we bring up a few input fields that are connected to our formGroup and the fields inside. We will handle both login and sign up on this page for simplicity, feel free to create another page for the registration in your app!

Simply open the src/app/pages/login/login.page.html for now and change it to:

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

<ion-content>
  <form (ngSubmit)="login()" [formGroup]="credentials">
    <div class="input-group">
      <ion-item>
        <ion-input placeholder="Username" formControlName="username"></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 we are able to sign up and log in afterwards with the credentials.

Note:: If the API takes a few seconds when using it the first time it’s because I’m using free dynos on Heroku for this project and the API needs to awake from sleep!

Building the Inside Area

This part is now even simpler since we just need a way to test our call to get secret data from the API, which is only allowed for authenticated users.

Therefore go ahead and add the call to our service inside the src/app/pages/inside/inside.page.ts now:

import { Component, OnInit } from '@angular/core';
import { ApiService } from '../../services/api.service';

@Component({
  selector: 'app-inside',
  templateUrl: './inside.page.html',
  styleUrls: ['./inside.page.scss'],
})
export class InsidePage implements OnInit {
  secretData = null;

  constructor(private apiService: ApiService) { }

  ngOnInit() { }

  async getData() {
    this.secretData = null;

    this.apiService.getSecretData().subscribe((res: any) => {
      this.secretData = res.msg;
    });
  }

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

Just like the class, the template is pretty boring and should only display the buttons necessary for triggering our functions and the data from the API call.

Continue with the src/app/pages/inside/inside.page.html and change it to:

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>Inside</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)="getData()">Get data</ion-button>
  <ion-card>
    <ion-card-content>
      {{ secretData }}
    </ion-card-content>
  </ion-card>
</ion-content>

Now we are done with the UI and step into the more complex part of our Ionic JWT refresh token logic!

Protecting your App with Guards

The inside area should only open for authenticated users, and just like inside our Ionic 5 navigation with login we will protect the page based on the isAuthenticated BehaviourSubject of our service.

Since the subject is initialised with null (see the service code in your project), we need to filter out that value inside the canLoad function. Afterwards we can check for the first real value that is emitted, and based on the value we either allow access to the page or send the user back to the login.

Now implement our basic guard inside the src/app/guards/auth.guard.ts like this:

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

@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanLoad {
  
  constructor(private apiService: ApiService, private router: Router) { }
 
  canLoad(): Observable<boolean> {    
    return this.apiService.isAuthenticated.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;
        }
      })
    );
  }
}

That was a nice and easy preparation for what comes next..

Creating the Token Interceptor Logic

We can build the whole logic for the Ionic JWT refresh token flow within an interceptor. This interceptor is used for two things:

  • Attach the JWT (access token) to the header of an HTTP request
  • Obtain a new access token if the API returns an a specific error indicating that the access token has expired

Since the CLI can’t create an Interceptor, we can now create a new file at src/app/interceptors/jwt.interceptor.ts (create the folder as well).

We can now tell the app to use our interceptor class inside the app/app.module.ts by adding it to the array of providers 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 { SplashScreen } from '@ionic-native/splash-screen/ngx';
import { StatusBar } from '@ionic-native/status-bar/ngx';

import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';

import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { JwtInterceptor } from './interceptors/jwt.interceptor';

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

Now we can tackle the first part, which is adding the JWT to the header of the request if necessary.

I always recommend to check an internal blocked list since you might not want to attach the JWT to ALL outgoing requests of your app.

If the isInBlockedList() returns true, we will simply handle the request as it is without changing it. Otherwise, we will also handle it but before continuing it the current JWT will be added inside the addToken() function, which returns the changed request with the new header.

If we encounter a 400 or 401 error from the request, it means the token is invalid. These codes can be different in your own API, so talk to the backend team or make sure you catch the right error codes so you can handle them accordingly afterwards.

For now, go ahead and implement the first part of the interceptor inside the src/app/interceptors/jwt.interceptor.ts:

import { environment } from './../../environments/environment';
import { Injectable } from '@angular/core';
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { Observable, throwError, BehaviorSubject, of } from 'rxjs';
import { ApiService } from '../services/api.service';
import {
  catchError,
  finalize,
  switchMap,
  filter,
  take,
} from 'rxjs/operators';
import { ToastController } from '@ionic/angular';

@Injectable()
export class JwtInterceptor implements HttpInterceptor {
  // Used for queued API calls while refreshing tokens
  tokenSubject: BehaviorSubject<string> = new BehaviorSubject<string>(null);
  isRefreshingToken = false;

  constructor(private apiService: ApiService, private toastCtrl: ToastController) { }

  // Intercept every HTTP call
  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {

    // Check if we need additional token logic or not
    if (this.isInBlockedList(request.url)) {
      return next.handle(request);
    } else {
      return next.handle(this.addToken(request)).pipe(
        catchError(err => {
          if (err instanceof HttpErrorResponse) {
            switch (err.status) {
              case 400:
                return this.handle400Error(err);
              case 401:
                return this.handle401Error(request, next);
              default:
                return throwError(err);
            }
          } else {
            return throwError(err);
          }
        })
      );
    }
  }

  // Filter out URLs where you don't want to add the token!
  private isInBlockedList(url: string): Boolean {
    // Example: Filter out our login and logout API call
    if (url == `${environment.api_url}/auth` ||
      url == `${environment.api_url}/auth/logout`) {
      return true;
    } else {
      return false;
    }
  }

  // Add our current access token from the service if present
  private addToken(req: HttpRequest<any>) {
    if (this.apiService.currentAccessToken) {
      return req.clone({
        headers: new HttpHeaders({
          Authorization: `Bearer ${this.apiService.currentAccessToken}`
        })
      });
    } else {
      return req;
    }
  }
}

Now if we encounter a 400 error, this simply means we are not authorized and the refresh flow didn’t work for whatever reason. In that case, we will display an alert and perform the logout.

The 401 error is the more important part in our case, since we can now perform the dance:

  1. Set the isRefreshingToken variable so we block other calls from performing the dance as well at the same time
  2. Get a new access token from the getNewAccessToken()
  3. Store the new access token if we were able to get it
  4. Emit the new value on the tokenSubject so other calls can use it
  5. Handle the request again by attaching the new token inside the addToken()

All of that happens with RxJS logic by switching to different Observables and results! It might look kinda hard on the first look, so go through the code a few times to understand each part.

There’s also a block at the end we enter if we are already refreshing the token and another API call was made at the same time that encountered a 401 error: In that block we wait until we get a new value on the tokenSubject and then perform the request again – so it’s kinda queued until we have obtained a new access token!

Now add the last missing functions to the src/app/interceptors/jwt.interceptor.ts:

// We are not just authorized, we couldn't refresh token
// or something else along the caching went wrong!
private async handle400Error(err) {
    // Potentially check the exact error reason for the 400
    // then log out the user automatically
    const toast = await this.toastCtrl.create({
      message: 'Logged out due to authentication mismatch',
      duration: 2000
    });
    toast.present();
    this.apiService.logout();
    return of(null);
  }
  
// Indicates our access token is invalid, try to load a new one
private handle401Error(request: HttpRequest < any >, next: HttpHandler): Observable < any > {
    // Check if another call is already using the refresh logic
    if(!this.isRefreshingToken) {
  
      // Set to null so other requests will wait
      // until we got a new token!
      this.tokenSubject.next(null);
      this.isRefreshingToken = true;
      this.apiService.currentAccessToken = null;
  
      // First, get a new access token
      return this.apiService.getNewAccessToken().pipe(
        switchMap((token: any) => {
          if (token) {
            // Store the new token
            const accessToken = token.accessToken;
            return this.apiService.storeAccessToken(accessToken).pipe(
              switchMap(_ => {
                // Use the subject so other calls can continue with the new token
                this.tokenSubject.next(accessToken);
  
                // Perform the initial request again with the new token
                return next.handle(this.addToken(request));
              })
            );
          } else {
            // No new token or other problem occurred
            return of(null);
          }
        }),
        finalize(() => {
          // Unblock the token reload logic when everything is done
          this.isRefreshingToken = false;
        })
      );
    } else {
      // "Queue" other calls while we load a new token
      return this.tokenSubject.pipe(
        filter(token => token !== null),
        take(1),
        switchMap(token => {
          // Perform the request again now that we got a new token!
          return next.handle(this.addToken(request));
        })
      );
    }
}

We’ve done it!

Whenever the interceptor notices that an API call fails, it will automatically retrieve a new access token with our refresh token and retry the failed call.

Conclusion

The hardest part about the Ionic JWT refresh token flow is actually the automatic renewal of tokens, which can be build with some RxJS magic to perform everything under the hood without noticing the user.

If you would like to see another tutorial on how I built the NestJS API for this tutorial let me know in the comments!

You can also see a detailed explanation of everything in the video below.

The post Building an Ionic JWT Refresh Token Flow appeared first on Devdactic - Ionic Tutorials.

Building the Twitter UI with Ionic Components

$
0
0

What if you could build any popular UI with Ionic components? This tutorial on building a Twitter UI with Ionic is the start to a new series of tutorials!

Although the default Ionic components look great and can be customised, there’s a lot of uncertainty when it comes to building more advanced UIs or establishing a specific UX.

Within this tutorial we will build the popular Twitter timeline UI including a fading header, sticky segment, scrollable story section and of course the tweet view.

twitter-ui-with-ionic

We’re going to touch a lot of files but we can rely mostly on Ionic components and a bit of additional CSS here and there.

Starting the Twitter UI App

To follow along, simply bring up a new Ionic app without any further plugins. We will actually use the tabs template this time since this will save a bit of time, but you can also build an Ionic tab bar easily yourself.

ionic start devdacticTwitter tabs --type=angular

# A custom component for Tweets
ionic g module components/sharedComponents --flat 
ionic g component components/tweet

# Custom directives for manipulating the header
ionic g module directives/sharedDirectives --flat
ionic g directive directives/HideHeader 
ionic g directive directives/StickySegment

We can also directly generate a module for components and directives with a few files so we are done with the file setup for the whole tutorial.

I’ve also prepared some dummy data to build our timeline on, therefore we need to inject the HttpClientModule to make a simple GET request later. Go ahead and add it to the 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 { SplashScreen } from '@ionic-native/splash-screen/ngx';
import { StatusBar } from '@ionic-native/status-bar/ngx';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { HttpClientModule } from '@angular/common/http';

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

Since we are using the tabs template we don’t need any other routing setup. We will also leave out the side menu aspect of the UI and focus only on one page, but adding an Ionic side menu later is no problem as well!

Changing the Ionic Tab Bar UI

Our first real task is changing the tab bar to use some cool Ionicons and on top of that an example of the badge component, which can be used to display a cool notification count.

In fact you could also create a custom div and style it, but we’ll try to rely as much as possible on Ionic components and customise them to our needs!

Go ahead and change the tabs/tabs.page.html to:

<ion-tabs>

  <ion-tab-bar slot="bottom">
    <ion-tab-button tab="tab1">
      <ion-icon name="home-outline"></ion-icon>
    </ion-tab-button>

    <ion-tab-button tab="tab2">
      <ion-icon name="search-outline"></ion-icon>
    </ion-tab-button>

    <ion-tab-button tab="tab3">
      <ion-badge color="primary">11</ion-badge>
      <ion-icon name="notifications-outline"></ion-icon>
    </ion-tab-button>

    <ion-tab-button tab="tab3">
      <ion-icon name="mail-outline"></ion-icon>
    </ion-tab-button>
  </ion-tab-bar>

</ion-tabs>

Right now the circle of the badge doesn’t really look round and Twitter like, so we can reposition it a bit, change the padding and dimensions of it and make sure it looks round and decent.

To do so, simply add the following to the tabs/tabs.page.scss:

ion-badge {
    top: 7px;
    left: calc(50% + 1px);
    font-size: 9px;
    font-weight: 500;
    border-radius: 10px;
    padding: 2px 2px 2px;
    min-width: 18px;
    min-height: 18px;
    line-height: 14px;
}

Cool, step one is done, let’s leave the tabs and get into the timeline.

Creating the Timeline Overview

Now we need to fetch some data so we can build a dynamic, more realistic UI. I’ve hosted a JSOn file I faked with some data over at https://devdactic.fra1.digitaloceanspaces.com/twitter-ui/tweets.json.

We can now load this data into an array of tweets, and while we are here, define some options for the Ionic slides that we will use for the story/fleet section of our view.

These settings will help us to easily display multiple slides on one page so we can scroll through them. By using a value between two numbers you can make sure you see this cut of the next item inside a list, which usually indicates there’s more to scroll for the user!

Now continue with the setup inside the tab1/tab1.page.ts:

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

@Component({
  selector: 'app-tab1',
  templateUrl: 'tab1.page.html',
  styleUrls: ['tab1.page.scss']
})
export class Tab1Page implements OnInit {
  tweets = [];
  segment = 'home';
  opts = {
    slidesPerView: 4.5,
    spaceBetween: 10,
    slidesOffsetBefore: 0
  };

  constructor(private http: HttpClient) {}

  ngOnInit() {
    this.http.get('https://devdactic.fra1.digitaloceanspaces.com/twitter-ui/tweets.json').subscribe((data: any) => {
      console.log('tweets: ', data.tweets);
      this.tweets = data.tweets;
    });
  }
}

We got the data, now we can display it inside our timeline!

But since it’s very likely you reuse a tweet in multiple places inside the app, we directly use the app-tweet component to display a tweet, which is the custom component we generated in the beginning.

There’s also a bit more on this page to discover:

  • The header area and the segment get a template reference which will be used later when we add the fading directive
  • The header contains only some static buttons inside the different available slots
  • The segment is used to switch between different views inside the page. You could also use something like super tabs for swipeable tabs in there.
  • The segment directly set’s the mode of the component to “md”(Material Design) since this fits the UI of Twitter better than the iOS design of the component
  • The story section in our view creates a small avatar image for each tweet, and by passing in our previously created options we can define the UI of the slides from code!

It looks quite plain from the first view, but the power is really in the small details of this page. So go ahead and change the tab1/tab1.page.html to:

<ion-header #header>
  <ion-toolbar>
    <ion-buttons slot="start">
      <ion-button>
        <ion-icon slot="icon-only" name="menu-outline"></ion-icon>
      </ion-button>
    </ion-buttons>
    <ion-title>
      <ion-icon name="logo-twitter" color="primary" size="large"></ion-icon>
    </ion-title>
    <ion-buttons slot="end">
      <ion-button>
        <ion-icon slot="icon-only" name="pulse-outline"></ion-icon>
      </ion-button>
    </ion-buttons>
  </ion-toolbar>
</ion-header>

<ion-segment [(ngModel)]="segment" mode="md" #segmentcontrol>
  <ion-segment-button value="home">
    <ion-label>Home</ion-label>
  </ion-segment-button>
  <ion-segment-button value="content">
    <ion-label>cr-content-suggest</ion-label>
  </ion-segment-button>
</ion-segment>

<ion-content>
  <div [hidden]="segment != 'home'">
    <!-- Stoy section at the top -->
    <ion-slides [options]="opts">
      <ion-slide *ngFor="let tweet of tweets">
        <ion-avatar>
          <img [src]="tweet.img">
        </ion-avatar>
      </ion-slide>
    </ion-slides>

    <!-- List of Tweets -->
    <app-tweet *ngFor="let tweet of tweets" [tweet]="tweet"></app-tweet>
  </div>

  <ion-fab vertical="bottom" horizontal="end" slot="fixed">
    <ion-fab-button>
      <ion-icon name="pencil"></ion-icon>
    </ion-fab-button>
  </ion-fab>

  <div [hidden]="segment != 'content'">
    Other content
  </div>
</ion-content>

Now this looks ok, but we’ll run into a few problems with the content scrolling behind the header later, or the segment being covered by the view and not sticky at all.

Therefore, head over to some small CSS optimisations inside the tab1/tab1page.scss:

ion-toolbar {
    --border-style: none;
}

ion-header {
    background: #fff;
}

ion-slides {
    padding-top: 8px;
    padding-bottom: 8px;
    border-bottom: 1px solid var(--ion-color-light);
}

ion-segment {
    z-index: 10;
    background: #fff;
}

ion-segment-button {
    text-transform: none;
}

ion-avatar {
    border: 2px solid var(--ion-color-primary);
    padding: 2px;
}

For some components we can directly change the styling, for some Ionic components wee need to use the according CSS variable.

Rule of thumb: Check out the component docs for a specific component and see if a CSS variable is defined and use it, otherwise use plain CSS rules.

Your code isn’t compiling right now? No suprise!

We are using a custom component but we haven’t imported the modules we generated in the beginning. Therefore, quickly head over to the tab1/tab1.module.ts and import them as well:

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

@NgModule({
  imports: [
    IonicModule,
    CommonModule,
    FormsModule,
    ExploreContainerComponentModule,
    Tab1PageRoutingModule,
    SharedComponentsModule,
    SharedDirectivesModule
  ],
  declarations: [Tab1Page]
})
export class Tab1PageModule {}

So now we got a first glimpse at the timeline, but the custom component is more like a dummy component right now, so we know what’s next.

Creating a Custom Tweet Component

Within our custom component we got the information of one tweet, so we need to craft a view around that information.

But before, we need to make sure that our module actually exports our component (this might be the reason your code right now is still not working).

Go ahead and add it to the exports inside the components/shared-components.module.ts:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TweetComponent } from './tweet/tweet.component';

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

Each tweet has some information and the actual text, and since the hashtags and mentions on Twitter have a different color, we can apply a simply regex to transform these words with a custom class.

That’s the only change we need to apply for the component, so go ahead and change the components/tweet/tweet.component.ts to:

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

@Component({
  selector: 'app-tweet',
  templateUrl: './tweet.component.html',
  styleUrls: ['./tweet.component.scss'],
  encapsulation: ViewEncapsulation.None
})
export class TweetComponent implements OnInit {
  @Input() tweet: any;

  constructor() { }

  ngOnInit() {
    console.log('one tweet: ', this.tweet);
    this.parseTweet();
  } 

  parseTweet() {
    this.tweet.text = this.tweet.text.replace(/\#[a-zA-Z]+/g,"\<span class\=\"highlight\"\>$&\<\/span\>");
    this.tweet.text = this.tweet.text.replace(/\@[a-zA-Z]+/g,"\<span class\=\"highlight\"\>$&\<\/span\>");    
  }

}

In fact this wasn’t the only change, we also changed the ViewEncapsulation inside the component. This is necessary because we are using the tweet text inside the innerHtml which renders HTML directly in Angular, but our own styling wouldn’t be applied otherwise!

The tweet component is now built with standard Ionic components, especially the grid.

You can nest this component however you want, so first we divide the 12 colums space into 2 for the tweet avatar and 10 for the rest of the tweet.

Then we further divide the space inside the actual tweet content to display the name and meta information, the tweet content and perhaps an image, and finally a row with all the action buttons for a tweet to share, like and retweet.

Here we can also use some conditional styling and change the color of the buttons according to the value of the tweet.retweet or tweet.liked value. This is of course a simplification of the real Twitter API data but shows the general concept!

Go ahead and create the tweet view inside the components/tweet/tweet.component.html now:

<ion-row class="wrapper">
  <ion-col size="2">
    <ion-avatar>
      <ion-img [src]="tweet.img"></ion-img>
    </ion-avatar>
  </ion-col>
  <ion-col size="10">
    <ion-row class="tweet-info">
      <ion-col size="12">
        <span class="name">{{ tweet.username }}</span>
        <span class="handle">@{{ tweet.handle }}</span>
        <span class="handle">- {{ tweet.date*1000 | date: 'shortDate' }}</span>
      </ion-col>
    </ion-row>
    <ion-row>
      <ion-col size="12">
        <div [innerHtml]="tweet.text"></div>
        <img class="preview-img" [src]="tweet.attachment" *ngIf="tweet.attachment">
      </ion-col>
    </ion-row>
    <ion-row class="ion-justify-content-start">
      <ion-col>
        <ion-button fill="clear" color="medium" size="small">
          <ion-icon name="chatbubble-outline" slot="start"></ion-icon>
          {{ tweet.response }}
        </ion-button>
      </ion-col>
      <ion-col>
        <ion-button (click)="tweet.retweet = !tweet.retweet" fill="clear" [color]="tweet.retweet ? 'primary' : 'medium'" size="small">
          <ion-icon name="repeat-outline" slot="start"></ion-icon>
          {{ tweet.retweets }}
        </ion-button>
      </ion-col>
      <ion-col>
        <ion-button (click)="tweet.liked = !tweet.liked" fill="clear" [color]="tweet.liked ? 'primary' : 'medium'" size="small">
          <ion-icon name="heart-outline" slot="start"></ion-icon>
          {{ tweet.like }}
        </ion-button>
      </ion-col>
      <ion-col>
        <ion-button fill="clear" color="medium" size="small">
          <ion-icon name="share-outline" slot="start"></ion-icon>
        </ion-button>
      </ion-col>
    </ion-row>
  </ion-col>
</ion-row>

Finally again some styling to make everything look even more Twitter like, and to stay concise with the Ionic colors we can directly retrieve the Ionic color theme variables by using the var() function.

Wrap up the component by changing the components/tweet/tweet.component.scss to:

.tweet-info {
    font-size: 0.9em;
}

.name {
    font-weight: 600;
}

.handle {
    padding-left: 4px;
    color: var(--ion-color-medium);
}

.wrapper {
    border-bottom: 1px solid var(--ion-color-light);
}

.highlight {
    color: var(--ion-color-primary);
}

.preview-img {
    border: 1px solid var(--ion-color-light);
    border-radius: 10px;
}

Now we got the whole UI for our view done, but there’s one important UX element missing.

Fly out the Header

If you’ve used the Twitter app you might have noticed that the header fades out on scroll, and the segments are sticky at the top.

To implement a behaviour like this, we can create a custom directive that listens to the scroll events of our content and changes some parts of the DOM.

Just like with our custom component, we now need to export the directives accordingly inside the module we created in the beginning, so go ahead and change the directives/shared-directives.module.ts to:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HideHeaderDirective } from './hide-header.directive';
import { StickySegmentDirective } from './sticky-segment.directive';

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

First of all we will take care of the fading header, but to understand the values we are working with better, we can quickly go back to our tab1/tab1.page.html and change the opening content tag to:

<ion-content [fullscreen]="true" scrollEvents="true" [appHideHeader]="header" [appStickySegment]="segmentcontrol">

What this does?

  • Make the content scroll behind the header area by using fullscreen, otherwise it would stop earlier behind a “white box”
  • Emit scroll events from ion-content, which are usually disabled
  • Pass the header template reference to the appHideHeader directive
  • Pass the segmentcontrol template reference to the appStickySegment directive

With this knowledge, we can now build the fading header directive which should move up the header area on scroll and at the same time, fade out the elements.

You could actually also fade out the whole header, but in our case this would result in a strange UI on iOS devices inside the top notch area. We better keep the general header visible and just grab the children of it to change their opacity later.

Basically whenever we receive a scroll event (from ion-content, which is the host of this directive), we grab the current scrollTop value and in combination with the height of the header (which is different for iOS and Android) we calculate the new position to smoothly move the header out.

At the same time, we update the opacity of all the children inside the header and perform this operation using the Ionic DomController which inserts the write operation at the best time for the browser without messing up the view repainting cycle!

Therefore change our first directive inside directives/hide-header.directive.ts to:

import { Directive, Input, HostListener, Renderer2, AfterViewInit } from '@angular/core';
import { DomController, isPlatform } 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);
      }
    });
  }
}

The second directive follows basically the same setup, on scroll we want to move the segment up. But this time we want to stop the repositioning once we have scrolled up the headerHeight amount and then keep it there, so it becomes sticky at the top.

Remember that we added a z-index for the styling of the segment before, therefore it will stay above all the other content!

Now wrap up the last part by changing the directives/sticky-segment.directive.ts to:

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

@Directive({
  selector: '[appStickySegment]'
})
export class StickySegmentDirective {
  @Input('appStickySegment') segment: any;
  private headerHeight = isPlatform('ios') ? 44 : 56;

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

  ngAfterViewInit(): void {
    this.segment = this.segment.el;
  }

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

    if (newPosition < -this.headerHeight) {
      newPosition = -this.headerHeight;
    }

    this.domCtrl.write(() => {
      this.renderer.setStyle(this.segment, 'top', newPosition + 'px');
    });
  }
}

And BOOM there we go – the whole Twitter UI with Ionic is ready!

Conclusion

We’ve built a truly flexible Twitter UI and UX with Ionic, which makes use almost completely of Ionic components and some additional styling.

This tutorial is the first in a series of “Built with Ionic” UI tutorials so if you want to see more of these, leave a comment with some UI that I should replicate with Ionic!

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

The post Building the Twitter UI with Ionic Components appeared first on Devdactic - Ionic Tutorials.

Building the Spotify UI with Ionic

$
0
0

Building more complex Ionic interfaces isn’t always easy, so today we will take a look at another popular app and learn to build the Spotify Ui with Ionic!

In the previous tutorial in the Built with Ionic series we have already implemented the Twitter timeline with Ionic, and now we will take on the Spotify player. More specific, we will build the overview with horizontal scrollable views for albums and the details view for an album or playlist that contains some cool animation to fade and scale an image while scrolling.

ionic-spotify-ui

I’ve taken some assets from this great Github project where Caleb Nance has built something similar with React, but I’ll share another link to a repository for this tutorial as I’ve slightly changed the names of some assets files to make everything work!

Starting the Spotify App

To get started, we create an Ionic app based on the tabs template to save some time. Additionally we can generate another page for the details of an album and a new module with a directive for the animation we gonna implement in the end:

ionic start devdacticSpotify tabs --type=angular --capacitor
cd ./devdacticSpotify

# Additional details page
ionic g page album 

# Directive for a fade animation
ionic g module directives/sharedDirectives --flat
ionic g directive directives/imageFade

Once your project is ready, you can copy over the whole asset folder from the code I hosted here on Github into your app (or also run that app, it’s the finished code).

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

This will make loading local files a breeze!

Custom Ionic Style & Fonts

Of course the default color theme of Ionic won’t fit the Spotify branding, so I extracted some colors from a screenshot and generated new colors using the Ionic color generator.

The output of the generator now goes straight to the src/theme/variables.scss like this:

/** Ionic CSS Variables **/
:root {
  --ion-color-primary: #57b660;
  --ion-color-primary-rgb: 87,182,96;
  --ion-color-primary-contrast: #000000;
  --ion-color-primary-contrast-rgb: 0,0,0;
  --ion-color-primary-shade: #4da054;
  --ion-color-primary-tint: #68bd70;

  --ion-color-secondary: #3dc2ff;
  --ion-color-secondary-rgb: 61,194,255;
  --ion-color-secondary-contrast: #ffffff;
  --ion-color-secondary-contrast-rgb: 255,255,255;
  --ion-color-secondary-shade: #36abe0;
  --ion-color-secondary-tint: #50c8ff;

  --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: #ffffff;
  --ion-color-success-contrast-rgb: 255,255,255;
  --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-dark: #121212;
  --ion-color-dark-rgb: 18,18,18;
  --ion-color-dark-contrast: #ffffff;
  --ion-color-dark-contrast-rgb: 255,255,255;
  --ion-color-dark-shade: #101010;
  --ion-color-dark-tint: #2a2a2a;

  --ion-color-medium: #282822;
  --ion-color-medium-rgb: 40,40,34;
  --ion-color-medium-contrast: #ffffff;
  --ion-color-medium-contrast-rgb: 255,255,255;
  --ion-color-medium-shade: #23231e;
  --ion-color-medium-tint: #3e3e38;

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

Additionally we can inject the Spotify font into our app. Whenever you want to use a custom font, you have to define the different variants inside your src/global.scss to make them available for later use:

ion-content {
  --background: var(--ion-color-dark);
}

@font-face {
  font-family: 'Spotify';
  font-style: normal;
  font-weight: normal;
  src: url('/assets/fonts/spotify-regular.ttf');
}

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

@font-face {
  font-family: 'Spotify';
  font-weight: lighter;
  src: url('/assets/fonts/spotify-light.ttf');
}

We’ve now also changed the general background of our app to the --ion-color-dark color that comes from the variables we defined before. We’re ready to put all of that into action!

Ionic Tab Bar with Stacked Player

The first area to focus on is the tab bar. Because we generated a details page and want to display it from one of our tabs, we have to include the albums page in our src/app/tabs/tabs-routing.module.ts now:

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { TabsPage } from './tabs.page';

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: 'tab1/:title',
        loadChildren: () => import('../album/album.module').then( m => m.AlbumPageModule)
      },
      {
        path: '',
        redirectTo: '/tabs/tab1',
        pathMatch: 'full'
      }
    ]
  },
  {
    path: '',
    redirectTo: '/tabs/tab1',
    pathMatch: 'full'
  }
];

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

If we would keep it in the app routing module (where it was automatically generated, you can remove that entry) we could still navigate to the page, but we would leave the tabs interface since the page wouldn’t be a child of the tabs page.

In some apps you will see that not only the color of the active icon changes, but the icon itself changes. That’s what we see in the Spotify app as well, where basically only the weight of the icon changes.

To achieve a switch of icons, we can listen to the ionTabsDidChange method and manually keep track of the active tab name.

With that value stored in the selected variable, we can easily check for each tab if it’s active and display a different icon!

On top of that the Spotify app displays the currently played music at the bottom, and by adding the custom player element in here we can easily control it from one place and keep it visible at all times.

Go ahead and start changing the src/app/tabs/tabs.page.html to:

<ion-tabs (ionTabsDidChange)="setSelectedTab()">

  <!-- The dummy player stacked above the tabs -->
  <div class="player">
    <div class="progress-bar">
      <div class="progress" [style.width]="progress + '%'"></div>
    </div>
    <ion-row class="ion-no-padding">
      <ion-col size="2" class="ion-no-padding">
        <img src="/assets/images/albums/when-we-all-fall-asleep.jpg">
      </ion-col>
      <ion-col size="8" class="ion-align-self-center">
        <b>All the Good Girls Go to Hell</b><br>
        <span>Billie Eilish</span>
      </ion-col>
      <ion-col size="2" class="ion-text-center ion-no-padding ion-align-self-center">
        <ion-icon name="play-sharp" color="light" size="large"></ion-icon>
      </ion-col>
    </ion-row>
  </div>

  <!-- The Ionic tab bar -->
  <ion-tab-bar slot="bottom">
    <ion-tab-button tab="tab1">
      <ion-icon [name]="selected == 'tab1' ? 'home' : 'home-outline'"></ion-icon>
      <ion-label>Home</ion-label>
    </ion-tab-button>

    <ion-tab-button tab="tab2">
      <ion-icon [name]="selected == 'tab2' ? 'search' : 'search-outline'"></ion-icon>
      <ion-label>Search</ion-label>
    </ion-tab-button>

    <ion-tab-button tab="tab3">
      <ion-icon [name]="selected == 'tab3' ? 'library' : 'library-outline'"></ion-icon>
      <ion-label>Library</ion-label>
    </ion-tab-button>
  </ion-tab-bar>
</ion-tabs>

In the player we also have a dynamic styling for the progress bar, which will simply use a value and set the width according to that value. We keep the rest static as the app won’t play music anyway, but it would be easy to control it with an injected service from here on.

Now we also need to implement the function that gets the change event of the tabs so we can set our selected tab to the current value of the tabs bar, which can easily be accessed as a Viewchild inside our class.

Continue with the src/app/tabs/tabs.page.ts and change it to:

import { Component, ViewChild } from '@angular/core';
import { IonTabs } from '@ionic/angular';

@Component({
  selector: 'app-tabs',
  templateUrl: 'tabs.page.html',
  styleUrls: ['tabs.page.scss']
})
export class TabsPage {
  @ViewChild() tabs: IonTabs;
  selected = '';
  progress = 42;
  
  constructor() {}

  setSelectedTab() {
    this.selected = this.tabs.getSelected();
  }
}

Right now it still wouldn’t look that good since we haven’t applied the right color to the tab bar. We can do this by setting the according CSS variables of the tab bar and of the ion-tab-button.

For the player, we can simply come up with our own fixed values while applying the Spotify font as well. Go ahead and edit the src/app/tabs/tabs.page.scss now:

ion-tab-bar {
    --background: var(--ion-color-medium);
}

ion-tab-button {
    --color-selected: #fff;
}

.player {
    height: 55px;
    background: var(--ion-color-medium);
    img {
        height: 55px;
    }
    font-family: 'Spotify';
    color: #fff;
    font-size: small;

    .progress-bar {
        height: 2px;
        width: 100%;
        background: #707070;
    
        .progress {
            background: #fff;
            height: 100%;
        }
    }    
}

Now we’ve got the first piece of the Spotify UI with Ionic in place, so let’s head over to the home page.

Ionic Horizontal Scroll with Images

We will now integrate the dummy data that you’ve hopefully added to your assets folder. And since we added the changes to our TS config, we can directly import those files and use them now!

Additionally we define some options for the Ionic slides that I will once again use for making a horizontal scroll view with Ionic. This time we also set it to freeMode which means you can naturally swipe through it instead of making it snap.

For opening an album and getting to the details page we just pass the title of the album as the dummy data wasn’t clear on the IDs. Make sure you call encodeURIComponent() on a string if you want to use it inside a URL!

Finally we are adding a dasherize() function as a helper, since the dummy images are stored with dashes and I couldn’t find an Angular pipe (besides inside the Angular schematics) to directly call a dasherize function.

Now go ahead and change the src/app/tab1/tab1.page.ts to this:

import { Component } from '@angular/core';
import { Router } from '@angular/router';

import recentlyPlayed from '../../assets/mockdata/recentlyPlayed.json';
import heavyRotation from '../../assets/mockdata/heavyRotation.json';
import jumpBackIn from '../../assets/mockdata/jumpBackIn.json';

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

  data = [
    {
      title: 'Recently played',
      albums: recentlyPlayed
    },
    {
      title: 'Heavy rotation',
      albums: heavyRotation
    },
    {
      title: 'Jump back in',
      albums: jumpBackIn
    }
  ];

  opts = {
    slidesPerView: 2.4,
    slidesOffsetBefore: 20,
    spaceBetween: 20,
    freeMode: true
  };
  
  constructor(private router: Router) {
    
  }

  openAlbum(album) {
    const titleEscaped = encodeURIComponent(album.title);
    this.router.navigateByUrl(`/tabs/tab1/${titleEscaped}`);
  }

  // Helper function for image names
  dasherize(string) {
    return string.replace(/[A-Z]/g, function(char, index) {
      return (index !== 0 ? '-' : '') + char.toLowerCase();
    });
  };
}

We now have an array of data containing the title of a section and the albums that should be displayed inside, which we loaded directly from the JSON dummy data.

For reference, the recently played list data looks like this:

[
  { "id": 1, "image": "exRe", "title": "Ex:Re" },
  { "id": 2, "image": "swimming", "title": "Swimming" },
  {
    "id": 3,
    "image": "theLegendOfMrRager",
    "title": "Man On The Moon II: The Legend of Mr. Rager"
  },
  { "id": 4, "image": "seaOfCowards", "title": "Sea Of Cowards" },
  { "id": 5, "image": "wishYouWereHere", "title": "Wish You Were Here" },
  { "id": 6, "image": "extraMachine", "title": "Extraordinary Machine" },
  { "id": 7, "image": "theCreekDrank", "title": "The Creek Drank The Cradle" }
]

Based on that information we craft our view and display a settings button on the first iteration of our ngFor using the first boolean. There’s a lot more like last, odd/even or index that you can use within ngFor so always keep that in mind before creating your own complex logic!

The horizontal scroll areas are now simply Ionic slides to which we pass our custom options. Each of the slides is tappable and calls our function which routes us to the details page, and to get the right image name we use the dasherize helper function right in the path.

With that being said, go ahead and change the src/app/tab1/tab1.page.html to:

<ion-content>

  <ion-row class="ion-no-padding" *ngFor="let entry of data; let isFirst = first">
    <!--  Section Title -->
    <ion-col size="9">
      <h2 class="section-header">{{ entry.title }}</h2>
    </ion-col>
    <ion-col size="3" class="ion-text-end">
      <ion-button fill="clear" color="light" *ngIf="isFirst">
        <ion-icon name="settings-outline" slot="icon-only"></ion-icon>
      </ion-button>
    </ion-col>

    <!-- Horizontal scroll view -->
    <ion-slides [options]="opts">
      <ion-slide *ngFor="let album of entry.albums" tappable (click)="openAlbum(album)">
        <img [src]="'/assets/images/albums/'+dasherize(album.image)+'.jpg'">
        <span class="title">{{ album.title }}</span>
      </ion-slide>

    </ion-slides>
  </ion-row>

</ion-content>

Since we don’t have a toolbar in this view, we need to take care of moving the overall content a bit down by setting the --padding-top variable of our content.

Additional styling adds a bit of margin and padding here and there to make everything feel like in the original app, and to display the title of albums below them correctly inside our slides.

Open the src/app/tab1/tab1.page.scss and insert the styling like this:

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

.section-header {
    color: #fff;
    font-family: 'Spotify';
    font-weight: bold;
    margin-left: 20px;
    margin-bottom: 30px;
}

.title {
    color: #fff;
    font-family: 'Spotify';
    font-size: small;
}

ion-slide {
    display: block;
    text-align: left;
}

ion-slides {
    margin-bottom: 20px;
}

That looks good – we’ve got the first real page finished and can now focus on the details page.

Details View with Gradients

This page looked easy upfront, but there’s so much more to a page sometimes that you don’t immediately get.

The biggest issue with the details page is:

  • It should have a dynamic gradient background color
  • It should fade and scale the album image while scrolling behind the content

When you got several requirements for a page you really need to think about position the items in the right place: Which belongs to ion-content and which should be above it? Which element/text is above which part? Where do I need to have the colors set?

But before we get to all of that, let’s start with the easy part. First of all we can retrieve the information from the route and decode it into the original title again.

Now we can directly access the albums object and get the value from there, plus we add the dasherize function in here again (yes, we could create a service for that and inject it everywhere.. not DRY today.).

Start with the src/app/album/album.page.ts now and change it to:

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import albums from '../../assets/mockdata/albums';

@Component({
  selector: 'app-album',
  templateUrl: './album.page.html',
  styleUrls: ['./album.page.scss'],
})
export class AlbumPage implements OnInit {
  data = null;

  constructor(private activatedRoute: ActivatedRoute) { }

  ngOnInit() {
    const title = this.activatedRoute.snapshot.paramMap.get('title');
    const decodedTitle = decodeURIComponent(title);
    this.data = albums[decodedTitle];    
  }

    // Helper function for image names
    dasherize(string) {
      return string.replace(/[A-Z]/g, function(char, index) {
        return (index !== 0 ? '-' : '') + char.toLowerCase();
      });
    };
}

The information we get about one album now looks like this:

{
  "artist": "Billie Eilish",
  "backgroundColor": "#363230",
  "image": "whenWeAllFallAsleep",
  "released": 2019,
  "title": "WHEN WE ALL FALL ASLEEP, WHERE DO WE GO?",
  "tracks": [
    { "title": "!!!!!!!", "seconds": 161 },
    { "title": "Bad Guy", "seconds": 245 },
    { "title": "Xanny", "seconds": 288 },
    { "title": "You Should See Me in a Crown", "seconds": 215 },
    { "title": "All the Good Girls Go to Hell", "seconds": 345 },
    { "title": "Wish You Were Gay", "seconds": 250 },
    { "title": "When the Party's Over", "seconds": 287 },
    { "title": "8", "seconds": 271 },
    { "title": "My Strange Addiction", "seconds": 210 },
    { "title": "Bury a Friend", "seconds": 237 },
    { "title": "Ilomilo", "seconds": 345 },
    { "title": "Listen Before I Go", "seconds": 347 },
    { "title": "I Love You", "seconds": 312 },
    { "title": "Goodbye", "seconds": 271 }
  ]
}

With that information we can create an interesting view, which has different areas that will receive a specific index within the CSS later:

  • The content needs a background color to prevent glitches when scrolling above the edges on iOS (check out the video to see this effect)
  • The image should be sticky at the top when scrolling
  • The main content should start after the image and can be scrolled above it
  • The main content has a gradient that’s behind everything

This is a challenging piece and requires a lot of different pieces.

First of all, we can dynamically set the background color of the Ionic toolbar by using the value from our data and passing it to the according CSS variable – yes, this works directly from your template!

On our content, we define a --custombg CSS variable on our own that we will use in the styling later to create a stunning gradient color as the background.

The image is outside of the main element so we can keep it sticky in place and handle the rest differently.

The information inside that main element is basically retrieved from the data and uses the Ionic grid setup with different Ionic CSS utilities so we don’t have to create all those little CSS changes additionally.

The list of tracks is also just a simple list which we will style from CSS in the next step.

For now, go ahead and change the src/app/album/album.page.html to:

<ion-header>
  <ion-toolbar [style.--background]="data.backgroundColor">
    <ion-buttons slot="start">
      <ion-back-button text="" color="light" defaultHref="/tabs/tab1"></ion-back-button>
    </ion-buttons>
    <ion-title color="light">{{ data?.title }}</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content *ngIf="data" [style.--custombg]="data.backgroundColor">

  <!-- Image of the album -->
  <div class="ion-text-center image-box">
    <img [src]="'/assets/images/albums/'+dasherize(data.image)+'.jpg'" *ngIf="data">
  </div>

  <!-- Rest of the page content -->
  <div class="main">

    <!-- General information -->
    <ion-row>
      <ion-col size="12" class="album-info">
        <p>{{ data.artist }}</p>
        <span>Album {{ data.title }} · {{ data.released }}</span>
      </ion-col>
      <ion-col size="8" class="ion-text-left ion-no-padding">
        <ion-button fill="clear" class="ion-no-margin">
          <ion-icon name="heart-outline" color="light" slot="icon-only"></ion-icon>
        </ion-button>
        <ion-button fill="clear">
          <ion-icon name="arrow-down-circle-outline" color="light" slot="icon-only"></ion-icon>
        </ion-button>
        <ion-button fill="clear">
          <ion-icon name="ellipsis-horizontal" color="light" slot="icon-only"></ion-icon>
        </ion-button>
      </ion-col>
      <ion-col size="4" class="ion-text-right ion-no-padding">
        <ion-button fill="clear">
          <ion-icon name="play-circle" size="large" color="primary" slot="icon-only"></ion-icon>
        </ion-button>
      </ion-col>
    </ion-row>

    <!-- List of tracks -->
    <ion-list>
      <ion-item *ngFor="let t of data.tracks" lines="none">
        <ion-label>{{ t.title }}
          <p>{{ data.artist }}</p>
        </ion-label>
        <ion-icon slot="end" size="small" name="ellipsis-horizontal" color="light"></ion-icon>
      </ion-item>
    </ion-list>
  </div>

</ion-content>

To make sure the page uses the general background color we can make the items background transparent and style our other fields with the custom font or a different color.

It get’s interesting when we reach the image-box since this area should be fixed in our view, and it needs to be behind the rest of the content.

Since now the image isn’t considered for the height in the ion-content, we need to add some padding to our ion-row to make it start below the image area manually.

The main box (which still feels the whole view, only the inside padding for the row changed) uses our custom --custombg CSS variable to create a cool gradient, and the same gradient with a sharp edge is applied to the whole content so we don’t see any edges when pulling further on iOS (again, check out the video at the bottom!).

It looks easy, but figuring out the different order of elements and making everything look natural was the hard part!

Go ahead and add the following to the src/app/album/album.page.scss:

ion-item {
    --ion-item-background: transparent;
    color: #fff;

    p {
        color: #949494;
    }
}

ion-list {
    --ion-background-color: var(--ion-color-dark);
}

ion-title, ion-content {
    font-family: 'Spotify';
}

.album-info {
    color: #fff;
    margin-left: 11px;
    p {
        font-weight: bold;
    }
}

.image-box {
    position: fixed;
    z-index: 0;
    padding-left: 15%;
    padding-right: 15%;
    padding-top: 5%;
}

ion-row {
    padding-top: 40vh;
}

ion-content {
    --background: linear-gradient(var(--custombg) 400px, var(--ion-color-dark) 400px);
}

.main {
    z-index: 2;
    background: linear-gradient(var(--custombg) 20%, var(--ion-color-dark) 30%);
}

The page now has a dynamic gradient background color, the image is sticky and we can scroll the content above the image. There’s only one thing missing…

Custom Image Fade Directive

The last step is a directive that changes the appearance of the image box inside our details view. To get started, we need to make sure our directive is declared and exported correctly in the module we generated in the beginning, so open the src/app/directives/shared-directives.module.ts and add the missing line:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ImageFadeDirective } from './image-fade.directive';

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

We want to fade out the image while we scroll, and just like the custom directive in our Twitter UI with Ionic tutorial, we will react to the scroll event of the ion-content.

Additionally the image element will be passed to the @Input() of our directive so we can change both the opacity and padding on the side to make it appear as it gets smaller.

You can play around with the calculation to achieve your desired result, but the following works pretty well inside the src/app/directives/image-fade.directive.ts:

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

@Directive({
  selector: '[appImageFade]'
})
export class ImageFadeDirective {
  @Input('appImageFade') cover: any;
 
  constructor(
    private renderer: Renderer2,
    private domCtrl: DomController
  ) { }
 
  @HostListener('ionScroll', ['$event']) onContentScroll($event: any) {
    const scrollTop: number = $event.detail.scrollTop;
    let newOpacity = Math.max(100 - (scrollTop/3), 0)

    let newPadding = 15 + (scrollTop/25);
    if (newPadding > 100) {
      newPadding = 100;
    }
 
    this.domCtrl.write(() => {
      this.renderer.setStyle(this.cover, 'opacity', `${newOpacity}%`);
      this.renderer.setStyle(this.cover, 'padding-left', `${newPadding}%`);
      this.renderer.setStyle(this.cover, 'padding-right', `${newPadding}%`);
    });
  }
}

Before we can use the directive we need to import the module in the page where we want to use it. In our case, open the src/app/album/album.module.ts and import it:

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

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

import { AlbumPageRoutingModule } from './album-routing.module';

import { AlbumPage } from './album.page';
import { SharedDirectivesModule } from '../directives/shared-directives.module';

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    IonicModule,
    AlbumPageRoutingModule,
    SharedDirectivesModule
  ],
  declarations: [AlbumPage]
})
export class AlbumPageModule {}

Now we simply need to apply the directive and pass the element we want to change to it. Therefore, we can give our image box element a template reference and pass this directly to our directive, which is added to the ion-content in order to listen to the scroll events.

Go ahead and apply the final changes to the src/app/album/album.page.html now:

<ion-content scrollEvents="true"  [fullscreen]="true" [appImageFade]="cover" *ngIf="data" [style.--custombg]="data.backgroundColor">
  <div class="ion-text-center image-box" #cover>
    <img [src]="'/assets/images/albums/'+dasherize(data.image)+'.jpg'" *ngIf="data">
  </div>
  ...
  ..

And with that last missing piece our Ionic Spotify UI is finished!

Conclusion

Creating complex UIs with Ionic isn’t always easy, but once you get better with the grid, CSS variables and directives, you feel the power of creating any user interface that you want. And having a good feeling about the tools and frameworks you use can have a huge impact on your productivity as well.

If you want to see more of the Built with Ionic tutorials, leave a comment with some UI that I should replicate with Ionic!

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

The post Building the Spotify UI with Ionic appeared first on Devdactic - Ionic Tutorials.

How to Build a Simple Ionic E-Commerce App with Firebase

$
0
0

If you want to build an Ionic E-Commerce app, Firebase is a great backend alternative to existing shop systems. This is a quick yet still robust solution for a full blown shopping app!

In this tutorial we will setup a Firebase project with dummy shopping data inside Firestore. To keep it simple, we are not adding authentication although this would be quite easy afterwards following other material on Firebase authentication.

ionic-e-commerce-firebase

Once we got some dummy data we will build an Ionic app that loads data from Firebase and also uses Firestore to keep track of the cart and stock amount of products until we perform a dummy check out and clear all items.

We won’t integrate payments in this basic tutorial, but we have different courses inside the Ionic Academy in which we use Braintree for Paypal and also Stripe with Firebase!

Firebase Project Setup

Before we dive into the Ionic E-Commerce app, the first step is Firebase, and you need to create a new project (or use any existing). Inside Firebase, make sure you have enabled the Firestore database by clicking “Get Started” in the right tab.

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 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 until our app is ready so we can copy it over!

Upload Dummy Data to Firestore with Node

Most likely you have some existing product data or don’t want to create all listings by hand. For this, I found a great video containing an upload script, and I have modified some element to make it work with the latest version of Firebase.

If you want to build your own Firestore upload tool, simply create a folder and init a new Node project, plus install two dependencies:

mkidr firestore-upload
cd ./firestore-upload

npm init --yes
npm i firebase firebase-admin

Inside that folder you can now create a uploader.js file which basically creates the connection to your Firebase project using a service key.

To generate this key, navigate inside your Firebase project to Users and permissions and select the Service accounts tab. From here, scroll down and hit Generate new private key which you can copy to your just created project and rename it to service_key.json.

Now back to the uploader.js, which only scans a files folder for documents and uploads them directly to Firebase. Go ahead and fill that file with the following code:

var admin = require("firebase-admin");

var serviceAccount = require("./service_key.json");

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
});

const firestore = admin.firestore();
const path = require("path");
const fs = require("fs");
const directoryPath = path.join(__dirname, "files");

fs.readdir(directoryPath, function(err, files) {
  if (err) {
    return console.log("Unable to scan directory: " + err);
  }

  files.forEach(function(file) {
    var lastDotIndex = file.lastIndexOf(".");

    var menu = require("./files/" + file);

    menu.forEach(function(obj) {
      firestore
        .collection(file.substring(0, lastDotIndex))
        .add(obj)
        .then(function(docRef) {
          console.log("Document written");
        })
        .catch(function(error) {
          console.error("Error adding document: ", error);
        });
    });
  });
});

The collection name will be used from the file, and the rest of the information is taken from the JSON data. I slightly changed the script to simply add() all items to the collection which generates a random ID for each document – in the initial version you could also specify a fixed ID instead.

To make life easier, I took an export of the the products data from the cool Fakestore API and changed a few parts of it for our example.

If you want to follow along, create a file at files/products.json now and insert the following data:

[{
    "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 clothing",
    "image": "https://fakestoreapi.com/img/81fPKd-2AYL._AC_SL1500_.jpg",
    "stock": 100
}, {
    "title": "Mens Casual Premium Slim Fit T-Shirts ",
    "price": 22.3,
    "description": "Slim-fitting style, contrast raglan long sleeve, three-button henley placket, light weight & soft fabric for breathable and comfortable wearing. And Solid stitched shirts with round neck made for durability and a great fit for casual fashion wear and diehard baseball fans. The Henley style round neckline includes a three-button placket.",
    "category": "men clothing",
    "image": "https://fakestoreapi.com/img/71-3HjGNDUL._AC_SY879._SX._UX._SY._UY_.jpg",
    "stock": 100
}, {
    "title": "Mens Cotton Jacket",
    "price": 55.99,
    "description": "great outerwear jackets for Spring/Autumn/Winter, suitable for many occasions, such as working, hiking, camping, mountain/rock climbing, cycling, traveling or other outdoors. Good gift choice for you or your family member. A warm hearted love to Father, husband or son in this thanksgiving or Christmas Day.",
    "category": "men clothing",
    "image": "https://fakestoreapi.com/img/71li-ujtlUL._AC_UX679_.jpg",
    "stock": 100
}, {
    "title": "Mens Casual Slim Fit",
    "price": 15.99,
    "description": "The color could be slightly different between on the screen and in practice. / Please note that body builds vary by person, therefore, detailed size information should be reviewed below on the product description.",
    "category": "men clothing",
    "image": "https://fakestoreapi.com/img/71YXzeOuslL._AC_UY879_.jpg",
    "stock": 100
}, {
    "title": "John Hardy Women's Legends Naga Gold & Silver Dragon Station Chain Bracelet",
    "price": 695,
    "description": "From our Legends Collection, the Naga was inspired by the mythical water dragon that protects the ocean's pearl. Wear facing inward to be bestowed with love and abundance, or outward for protection.",
    "category": "jewelery",
    "image": "https://fakestoreapi.com/img/71pWzhdJNwL._AC_UL640_QL65_ML3_.jpg",
    "stock": 100
}, {
    "title": "Solid Gold Petite Micropave ",
    "price": 168,
    "description": "Satisfaction Guaranteed. Return or exchange any order within 30 days.Designed and sold by Hafeez Center in the United States. Satisfaction Guaranteed. Return or exchange any order within 30 days.",
    "category": "jewelery",
    "image": "https://fakestoreapi.com/img/61sbMiUnoGL._AC_UL640_QL65_ML3_.jpg",
    "stock": 100
}, {
    "title": "White Gold Plated Princess",
    "price": 9.99,
    "description": "Classic Created Wedding Engagement Solitaire Diamond Promise Ring for Her. Gifts to spoil your love more for Engagement, Wedding, Anniversary, Valentine's Day...",
    "category": "jewelery",
    "image": "https://fakestoreapi.com/img/71YAIFU48IL._AC_UL640_QL65_ML3_.jpg",
    "stock": 100
}, {
    "title": "Pierced Owl Rose Gold Plated Stainless Steel Double",
    "price": 10.99,
    "description": "Rose Gold Plated Double Flared Tunnel Plug Earrings. Made of 316L Stainless Steel",
    "category": "jewelery",
    "image": "https://fakestoreapi.com/img/51UDEzMJVpL._AC_UL640_QL65_ML3_.jpg",
    "stock": 100
}, {
    "title": "WD 2TB Elements Portable External Hard Drive - USB 3.0 ",
    "price": 64,
    "description": "USB 3.0 and USB 2.0 Compatibility Fast data transfers Improve PC Performance High Capacity; Compatibility Formatted NTFS for Windows 10, Windows 8.1, Windows 7; Reformatting may be required for other operating systems; Compatibility may vary depending on user’s hardware configuration and operating system",
    "category": "electronics",
    "image": "https://fakestoreapi.com/img/61IBBVJvSDL._AC_SY879_.jpg",
    "stock": 100
}, {
    "title": "SanDisk SSD PLUS 1TB Internal SSD - SATA III 6 Gb/s",
    "price": 109,
    "description": "Easy upgrade for faster boot up, shutdown, application load and response (As compared to 5400 RPM SATA 2.5” hard drive; Based on published specifications and internal benchmarking tests using PCMark vantage scores) Boosts burst write performance, making it ideal for typical PC workloads The perfect balance of performance and reliability Read/write speeds of up to 535MB/s/450MB/s (Based on internal testing; Performance may vary depending upon drive capacity, host device, OS and application.)",
    "category": "electronics",
    "image": "https://fakestoreapi.com/img/61U7T1koQqL._AC_SX679_.jpg",
    "stock": 100
}, {
    "title": "Silicon Power 256GB SSD 3D NAND A55 SLC Cache Performance Boost SATA III 2.5",
    "price": 109,
    "description": "3D NAND flash are applied to deliver high transfer speeds Remarkable transfer speeds that enable faster bootup and improved overall system performance. The advanced SLC Cache Technology allows performance boost and longer lifespan 7mm slim design suitable for Ultrabooks and Ultra-slim notebooks. Supports TRIM command, Garbage Collection technology, RAID, and ECC (Error Checking & Correction) to provide the optimized performance and enhanced reliability.",
    "category": "electronics",
    "image": "https://fakestoreapi.com/img/71kWymZ+c+L._AC_SX679_.jpg",
    "stock": 100
}, {
    "title": "WD 4TB Gaming Drive Works with Playstation 4 Portable External Hard Drive",
    "price": 114,
    "description": "Expand your PS4 gaming experience, Play anywhere Fast and easy, setup Sleek design with high capacity, 3-year manufacturer's limited warranty",
    "category": "electronics",
    "image": "https://fakestoreapi.com/img/61mtL65D4cL._AC_SX679_.jpg",
    "stock": 100
}, {
    "title": "Acer SB220Q bi 21.5 inches Full HD (1920 x 1080) IPS Ultra-Thin",
    "price": 599,
    "description": "21. 5 inches Full HD (1920 x 1080) widescreen IPS display And Radeon free Sync technology. No compatibility for VESA Mount Refresh Rate: 75Hz - Using HDMI port Zero-frame design | ultra-thin | 4ms response time | IPS panel Aspect ratio - 16: 9. Color Supported - 16. 7 million colors. Brightness - 250 nit Tilt angle -5 degree to 15 degree. Horizontal viewing angle-178 degree. Vertical viewing angle-178 degree 75 hertz",
    "category": "electronics",
    "image": "https://fakestoreapi.com/img/81QpkIctqPL._AC_SX679_.jpg",
    "stock": 100
}, {
    "title": "Samsung 49-Inch CHG90 144Hz Curved Gaming Monitor (LC49HG90DMNXZA) – Super Ultrawide Screen QLED ",
    "price": 999.99,
    "description": "49 INCH SUPER ULTRAWIDE 32:9 CURVED GAMING MONITOR with dual 27 inch screen side by side QUANTUM DOT (QLED) TECHNOLOGY, HDR support and factory calibration provides stunningly realistic and accurate color and contrast 144HZ HIGH REFRESH RATE and 1ms ultra fast response time work to eliminate motion blur, ghosting, and reduce input lag",
    "category": "electronics",
    "image": "https://fakestoreapi.com/img/81Zt42ioCgL._AC_SX679_.jpg",
    "stock": 100
}, {
    "title": "BIYLACLESEN Women's 3-in-1 Snowboard Jacket Winter Coats",
    "price": 56.99,
    "description": "Note:The Jackets is US standard size, Please choose size as your usual wear Material: 100% Polyester; Detachable Liner Fabric: Warm Fleece. Detachable Functional Liner: Skin Friendly, Lightweigt and Warm.Stand Collar Liner jacket, keep you warm in cold weather. Zippered Pockets: 2 Zippered Hand Pockets, 2 Zippered Pockets on Chest (enough to keep cards or keys)and 1 Hidden Pocket Inside.Zippered Hand Pockets and Hidden Pocket keep your things secure. Humanized Design: Adjustable and Detachable Hood and Adjustable cuff to prevent the wind and water,for a comfortable fit. 3 in 1 Detachable Design provide more convenience, you can separate the coat and inner as needed, or wear it together. It is suitable for different season and help you adapt to different climates",
    "category": "women clothing",
    "image": "https://fakestoreapi.com/img/51Y5NI-I5jL._AC_UX679_.jpg",
    "stock": 100
}, {
    "title": "Lock and Love Women's Removable Hooded Faux Leather Moto Biker Jacket",
    "price": 29.95,
    "description": "100% POLYURETHANE(shell) 100% POLYESTER(lining) 75% POLYESTER 25% COTTON (SWEATER), Faux leather material for style and comfort / 2 pockets of front, 2-For-One Hooded denim style faux leather jacket, Button detail on waist / Detail stitching at sides, HAND WASH ONLY / DO NOT BLEACH / LINE DRY / DO NOT IRON",
    "category": "women clothing",
    "image": "https://fakestoreapi.com/img/81XH0e8fefL._AC_UY879_.jpg",
    "stock": 100
}, {
    "title": "Rain Jacket Women Windbreaker Striped Climbing Raincoats",
    "price": 39.99,
    "description": "Lightweight perfet for trip or casual wear---Long sleeve with hooded, adjustable drawstring waist design. Button and zipper front closure raincoat, fully stripes Lined and The Raincoat has 2 side pockets are a good size to hold all kinds of things, it covers the hips, and the hood is generous but doesn't overdo it.Attached Cotton Lined Hood with Adjustable Drawstrings give it a real styled look.",
    "category": "women clothing",
    "image": "https://fakestoreapi.com/img/71HblAHs5xL._AC_UY879_-2.jpg",
    "stock": 100
}, {
    "title": "MBJ Women's Solid Short Sleeve Boat Neck V ",
    "price": 9.85,
    "description": "95% RAYON 5% SPANDEX, Made in USA or Imported, Do Not Bleach, Lightweight fabric with great stretch for comfort, Ribbed on sleeves and neckline / Double stitching on bottom hem",
    "category": "women clothing",
    "image": "https://fakestoreapi.com/img/71z3kpMAYsL._AC_UY879_.jpg",
    "stock": 100
}, {
    "title": "Opna Women's Short Sleeve Moisture",
    "price": 7.95,
    "description": "100% Polyester, Machine wash, 100% cationic polyester interlock, Machine Wash & Pre Shrunk for a Great Fit, Lightweight, roomy and highly breathable with moisture wicking fabric which helps to keep moisture away, Soft Lightweight Fabric with comfortable V-neck collar and a slimmer fit, delivers a sleek, more feminine silhouette and Added Comfort",
    "category": "women clothing",
    "image": "https://fakestoreapi.com/img/51eg55uWmdL._AC_UX679_.jpg",
    "stock": 100
}, {
    "title": "DANVOUY Womens T Shirt Casual Cotton Short",
    "price": 12.99,
    "description": "95%Cotton,5%Spandex, Features: Casual, Short Sleeve, Letter Print,V-Neck,Fashion Tees, The fabric is soft and has some stretch., Occasion: Casual/Office/Beach/School/Home/Street. Season: Spring,Summer,Autumn,Winter.",
    "category": "women clothing",
    "image": "https://fakestoreapi.com/img/61pHAEJ4NML._AC_UX679_.jpg",
    "stock": 100
}]

Now all you got to do is run the uploader script from the command line and your Firestore data will contain all the great testing data:

node uploader.js

If you followed along, your Firestore data should now look like this:
firebase-dummy-uploader

If you want to manually manage the products you could also create another app to manage the product listings, something we did inside the Marketplace course inside the Ionic Academy!

Setting up the Ionic E-Commerce App

Now we are ready to dive into the Ionic E-Commerce app and start with a blank new app and add the AngularFire schematic which will install all the necessary packages.

Additionally we generate a page and service for the rest of our functionality, so get started now with:

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

ng add @angular/fire

ionic g service services/product
ionic g page cartModal

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: "",
    databaseURL: "",
    projectId: "",
    storageBucket: "",
    messagingSenderId: "",
    appId: ""
  }
};

With the configuration in place we can initialise the connection from our Ionic app to Firebase by adding the according modules to our app/app.module.ts and passing in the environment from before:

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router';

import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
import { SplashScreen } from '@ionic-native/splash-screen/ngx';
import { StatusBar } from '@ionic-native/status-bar/ngx';

import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';

import { AngularFireModule } from '@angular/fire';
import { AngularFirestoreModule } from '@angular/fire/firestore';
import { environment } from '../environments/environment';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule,
    AngularFireModule.initializeApp(environment.firebase),
    AngularFirestoreModule
  ],
  providers: [
    StatusBar,
    SplashScreen,
    { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }
  ],
  bootstrap: [AppComponent]
})
export class AppModule {}

That’s all for the setup and we can now connect to Firebase from our Ionic app!

Creating the Product and Cart Logic

First of all we will implement the backbone of our app, the service that manages the products and cart and connection to Firebase.

Since we are not using authentication at this point (although this would be easy to use with this logic as well!) we need a way to persist the cart of a user, and we can do it like this:

  • If a user opens the app, we check the Capacitor storage for a value stored under our CART_STORAGE_KEY
  • If there is no stored value, we start a new cart by creating a document inside the Firestore collection. We will then use the ID of this new document and store it locally, so we can later retrieve the cart of a user again!
  • If the app starts and we find this ID already, we access the cart document from Firestore and load all the stored values/amounts!

With this approach we update our local cart data and also keep track of all the items inside a Firebase document.

Again: With authentication, you could simply use the unique user id to have a connection between user and cart instead of using the random id generated by Firebase right now.

We can also already create the getProducts() function which returns our product data including the actual ID of the document, and keep track of our cart by using a BehaviorSubject that always holds the ids of items added and the amount.

Get started with our service by changing the services/product.service.ts to:

import { Injectable } from '@angular/core';
import { AngularFirestore, AngularFirestoreCollection } from '@angular/fire/firestore';
import { Plugins } from '@capacitor/core';
import firebase from 'firebase/app';
import { BehaviorSubject } from 'rxjs';
 
const { Storage } = Plugins;
 
const CART_STORAGE_KEY = 'MY_CART';
 
const INCREMENT = firebase.firestore.FieldValue.increment(1);
const DECREMENT = firebase.firestore.FieldValue.increment(-1);
 
@Injectable({
  providedIn: 'root'
})
export class ProductService {
  cart = new BehaviorSubject({});
  cartKey = null;
  productsCollection: AngularFirestoreCollection;
 
  constructor(private afs: AngularFirestore) {
    this.loadCart();
    this.productsCollection = this.afs.collection('products');
  }
 
  getProducts() {
    return this.productsCollection.valueChanges({ idField: 'id' });
  }
 
  async loadCart() {
    const result = await Storage.get({ key: CART_STORAGE_KEY });
    if (result.value) {
      this.cartKey = result.value;
 
      this.afs.collection('carts').doc(this.cartKey).valueChanges().subscribe((result: any) => {
        // Filter out our timestamp
        delete result['lastUpdate'];
 
        this.cart.next(result || {});
      });
 
    } else {
      // Start a new cart
      const fbDocument = await this.afs.collection('carts').add({
        lastUpdate: firebase.firestore.FieldValue.serverTimestamp()
      });
      this.cartKey = fbDocument.id;
      // Store the document ID locally
      await Storage.set({ key: CART_STORAGE_KEY, value: this.cartKey });

      // Subscribe to changes
      this.afs.collection('carts').doc(this.cartKey).valueChanges().subscribe((result: any) => {
        delete result['lastUpdate'];
        console.log('cart changed: ', result);
        this.cart.next(result || {});
      });
    }
  }
}

You might have noticed the INCREMENT and DECREMENT at the top already – these are super helpful functions from Firebase to easily change a value. In our case, we use it to increase/decrease the amount of an item inside our shopping cart and also inside the actual products collection, since we need to change the stock of a product at the same time!

We can now also use a computed property by using brackets around the id, which uses the value of the ID as the property name!

That means, once we add items to the cart, the document inside Firebase looks something like this:

{
  123asd1iasd: 2,
  dasd8349: 3,
  kas2918: 1,
  lastUpdate: ...
}

So we use the ID as a key in the object, and the value is the amount in our cart.

Go ahead and add the following to the same services/product.service.ts now:

addToCart(id) {
  // Update the FB cart
  this.afs.collection('carts').doc(this.cartKey).update({
    [id]: INCREMENT,
    lastUpdate: firebase.firestore.FieldValue.serverTimestamp()
  });

  // Update the stock value of the product
  this.productsCollection.doc(id).update({
    stock: DECREMENT
  });
}

removeFromCart(id) {
  // Update the FB cart
  this.afs.collection('carts').doc(this.cartKey).update({
    [id]: DECREMENT,
    lastUpdate: firebase.firestore.FieldValue.serverTimestamp()
  });

  // Update the stock value of the product
  this.productsCollection.doc(id).update({
    stock: INCREMENT
  });
}

async checkoutCart() {
  // Create an order
  await this.afs.collection('orders').add(this.cart.value);

  // Clear old cart
  this.afs.collection('carts').doc(this.cartKey).set({
    lastUpdate: firebase.firestore.FieldValue.serverTimestamp()
  });
}

At last we also have a simple checkout function which writes the current cart to another orders collection and clears our cart by using the set() function which will replace the whole document to the new value (before we only used update()!).

Building the Shopping List

Before we now dive into the product list, let’s quickly already import the module of the cart page that we generated in the beginning since we will later open the modal from this page.

Open the src/app/home/home.module.ts and import it 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 { CartModalPageModule } from '../cart-modal/cart-modal.module';

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

Now we can retrieve the products from our service and assign it to an Observable to which we will subscribe from our view in the next step so we don’t need to manage it inside our class at this point.

We will also subscribe to any changes of the cart so we can set the value in our class to the new value of the cart, which will help to display the amount of an item inside our view,

As a little bonus, I also added an Ionic animation which we simply create once upfront and then call play() whenever we add or remove items from our cart.

Finally those functions simply call the according service functionality – everything is handled in there, and also the cart updated will be received automatically so we don’t need to worry about the state of our data!

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

import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core';
import { Animation, AnimationController, ModalController } from '@ionic/angular';
import { Observable } from 'rxjs';
import { CartModalPage } from '../cart-modal/cart-modal.page';
import { ProductService } from '../services/product.service';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})
export class HomePage implements OnInit, AfterViewInit {
  products: Observable<any[]>;
  @ViewChild('myfab', { read: ElementRef }) carBtn: ElementRef;
  cart = {};
  cartAnimation: Animation;

  constructor(private productService: ProductService, private animationCtrl: AnimationController, private modalCtrl: ModalController) {}

  ngOnInit() {
    this.products = this.productService.getProducts();

    // Listen to Cart changes
    this.productService.cart.subscribe(value => {
      this.cart = value;
    });
  }

  ngAfterViewInit() {
    // Setup an animation that we can reuse
    this.cartAnimation = this.animationCtrl.create('cart-animation');
    this.cartAnimation
    .addElement(this.carBtn.nativeElement)
    .keyframes([
      { offset: 0, transform: 'scale(1)' },
      { offset: 0.5, transform: 'scale(1.2)' },
      { offset: 0.8, transform: 'scale(0.9)' },
      { offset: 1, transform: 'scale(1)' }
    ])
    .duration(300)
    .easing('ease-out');
  }

  addToCart(event, product) {
    event.stopPropagation();
    this.productService.addToCart(product.id);
    this.cartAnimation.play();
  }

  removeFromCart(event, product) {
    event.stopPropagation();
    this.productService.removeFromCart(product.id);
    this.cartAnimation.play();
  }

  async openCart() {
    const modal = await this.modalCtrl.create({
      component: CartModalPage
    });
    await modal.present();
  }
}

In our functions we also have stopPropagation() because they are triggered from buttons inside an ion-item which has a click handler itself to expand a read more section.

By passing the event to those functions and stopping it right there, we make sure that the parent click event isn’t also executed. Nice and easy hack that comes in helpful every now and then!

Inside the template we can now subscribe to the products Observable using the async pipe to handle all the subscription automatically. Additionally we display a bit of the information from a product like the image, stock and the expandable section with a super simple click logic.

At the bottom we keep our fab button with the #myfab template reference – remember how we used that for our Ionic animation before?

Inside the product we can now access the cart object and check if the ID of a product is included and display the value (or 0 as fallback) and also use this logic to disable a remove button. Nobody wants -1 items in their cart!

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

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

<ion-content>

  <ion-list>
    <ion-item *ngFor="let p of products | async" (click)="p.expanded = !p.expanded">
      <ion-thumbnail slot="start">
        <ion-img [src]="p.image"></ion-img>
      </ion-thumbnail>
      <ion-label class="ion-text-wrap">
        {{ p.title }}<br>
        <b>{{ p.price | currency:'USD' }}</b>
        <p>{{ p.stock }} left</p>
        <div [hidden]="!p.expanded">
          {{ p.description }}
        </div>
      </ion-label>
      <ion-row slot="end" class="ion-no-padding ion-align-items-center">
        <ion-col size="5">
          <ion-button (click)="addToCart($event, p)" fill="clear">
            <ion-icon name="add" slot="icon-only"></ion-icon>
          </ion-button>
        </ion-col>

        <ion-col size="2">
          {{ cart[p.id] || 0 }}
        </ion-col>

        <ion-col size="5">
          <ion-button (click)="removeFromCart($event, p)" fill="clear" [disabled]="!cart[p.id] || cart[p.id] == 0">
            <ion-icon name="remove" slot="icon-only"></ion-icon>
          </ion-button>
        </ion-col>
      </ion-row>

    </ion-item>
  </ion-list>

  <ion-fab vertical="bottom" horizontal="end" slot="fixed">
    <ion-fab-button (click)="openCart()" color="secondary" #myfab>
      <ion-icon name="cart"></ion-icon>
    </ion-fab-button>
  </ion-fab>

</ion-content>

Now we already got a nice list of product items in place and can wrap up everything with a quick checkout view.

Adding the Checkout Page

This page is quick to add, but we need our brain one more time to implement a logic which:

  • Loads all product information
  • Checks if a product of our cart exists in that data
  • Combines the product data and the value for each product we have in our cart

In reality you could also do it the other way and load the Firebase data for each item inside your cart. Really depends on how many products you actually got and which operation would take longer.

To create a nice array with all of that information for the checkout page we load the products once using the take(1) operator.

We then filter() out all products that don’t exist inside our cart, and finally we map each product to a new value that now combines information using the ... spread operator so we got the actual product and the count in one object!

Additionally we can add the code for the checkout which uses the function of our service and closes the modal with a little alert for testing.

Now open the src/app/cart-modal/cart-modal.page.ts and change it to:

import { Component, OnInit } from '@angular/core';
import { AlertController, ModalController } from '@ionic/angular';
import { ProductService } from '../services/product.service';
import { take } from 'rxjs/operators';

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

  constructor(private productService: ProductService, private modalCtrl: ModalController, private alertCtrl: AlertController) { }

  ngOnInit() {
    const cartItems = this.productService.cart.value;

    this.productService.getProducts().pipe(take(1)).subscribe(allProducts => {
      this.products = allProducts.filter(p => cartItems[p.id]).map(product => {
        return { ...product, count: cartItems[product.id] };
      });
    });
  }

  async checkout() {
    const alert = await this.alertCtrl.create({
      header: 'Success',
      message: 'Thanks for your order',
      buttons: ['Continue shopping']
    });

    await alert.present();

    this.productService.checkoutCart();
    this.modalCtrl.dismiss();
  }

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

The last step is to create another simple view to list all of the items with their count inside the cart and add a checkout button inside our src/app/cart-modal/cart-modal.page.html:

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

<ion-content>
<ion-list>
  <ion-item *ngFor="let p of products">
    <p slot="start">
      {{ p.count }}x
    </p>
    <ion-label>
      {{ p.title }}
    </ion-label>

    <ion-avatar slot="end">
      <ion-img [src]="p.image"></ion-img>
    </ion-avatar>
  </ion-item>
</ion-list>
</ion-content>

<ion-footer>
  <ion-toolbar color="success">
    <ion-button expand="full" (click)="checkout()" fill="clear" color="light">
      Checkout
    </ion-button>
  </ion-toolbar>
</ion-footer>

We will not dive into the creation of a checkout form and payment logic but you can find courses on those topics inside the Ionic Academy!

Automatically clear Cart after X Minutes?

Right now we reduce the stock for products whenever we add them to the cart, but what happens if a user doesn’t finish the checkout?

To fix that problem, I recommend you create a CRON job with Firebase cloud functions and simply iterate the cart collection inside that function.

Because we keep a server timestamp of the last update in each cart document you can easily check when a cart was last changed.

If it’s too long ago, simply use the keys inside the cart to once again increment the stock of each product and then clear the cart in the background with this logic!

Conclusion

It’s possible to build an Ionic E-Commerce shop based on Firebase with a lot of the Firebase benefits! You can manually upload all your products with a script (or build your own admin area), and you can keep the cart of a user inside the Firestore database as well with a nice sync to your actual Ionic app.

If you want to see more about this topic, leave a comment below!

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

The post How to Build a Simple Ionic E-Commerce App with Firebase appeared first on Devdactic - Ionic Tutorials.

Building the Netflix UI with Ionic

$
0
0

Building a complex UI with Ionic is not always easy, but can be learned by going through practical examples like we do today with the Netflix UI with Ionic!

In fact we will focus only on the home page of the original app, but that page already requires many different components that spice up the while view.

ionic-netflix-ui

We will work with directives to fade the header out, create a semi transparent modal with blurred background and also a bottom drawer with backdrop – on top of the overall UI with horizontal slides and different sections on the page!

Starting the Netflix App

To get started we generate a tabs interface and add two pages to add another tab and a modal page for later. We also create the base for our custom directive and component that we will need in the end, so go ahead and run:

ionic start devdacticNetflix tabs --type=angular --capacitor
cd ./devdacticNetflix

# Additional Pages
ionic g page tab4
ionic g page modal

# Directive
ionic g module directives/sharedDirectives --flat
ionic g directive directives/hideHeader

# Custom component
ionic g module components/sharedComponents --flat
ionic g component components/drawer
ionic g service services/drawer

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 the local JSON data with Angular, we need to add two properties to our tsconfig.json:

"compilerOptions": {
    "resolveJsonModule": true,
    "allowSyntheticDefaultImports": true
    ...
}

The previous commands also generated entries in our global routing file that we don’t need so clean them up right now by changing the app/app-routing.module.ts to:

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

const routes: Routes = [
  {
    path: '',
    loadChildren: () => import('./tabs/tabs.module').then(m => m.TabsPageModule)
  }
];
@NgModule({
  imports: [
    RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })
  ],
  exports: [RouterModule]
})
export class AppRoutingModule {}

To style the application we can also change the default dark color inside the src/theme/variables.scss now:

/** dark **/
  --ion-color-dark: #000000;
  --ion-color-dark-rgb: 0,0,0;
  --ion-color-dark-contrast: #ffffff;
  --ion-color-dark-contrast-rgb: 255,255,255;
  --ion-color-dark-shade: #000000;
  --ion-color-dark-tint: #1a1a1a;

On top of that we will set the overall background of our app to black so we don’t need to change this for each and every component.

You can apply the changes inside the src/global.scss like this:

:root {
  /* Set the background of the entire app */
  --ion-background-color: #000;

  /* Set the font family of the entire app */
  --ion-text-color: #fff;
}

Now we have a dark background and light text and can work on the different areas of our app.

Changing the Tabs UI

The first area to change is the tab bar, which should include another tab (that we generated in the beginning) so go ahead and include it inside the src/app/tabs/tabs-routing.module.ts:

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { TabsPage } from './tabs.page';

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

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

Just like we did in the Spotify UI with Ionic tutorial we will implement a logic to change the icon of a tab based on the selected tab.

This requires to listen for the ionTabsDidChange event and storing the currently active tab, which can then be used inside the src/app/tabs/tabs.page.html to dynamically change the icon:

<ion-tabs (ionTabsDidChange)="setSelectedTab()">

  <ion-tab-bar slot="bottom" color="dark">
    <ion-tab-button tab="tab1">
      <ion-icon [name]="selected == 'tab1' ? 'home' : 'home-outline'"></ion-icon>
      <ion-label>Home</ion-label>
    </ion-tab-button>

    <ion-tab-button tab="tab2">
      <ion-icon [name]="selected == 'tab2' ? 'copy' : 'copy-outline'"></ion-icon>
      <ion-label>Coming Soon</ion-label>
    </ion-tab-button>

    <ion-tab-button tab="tab3">
      <ion-icon [name]="selected == 'tab3' ? 'search' : 'search-outline'"></ion-icon>
      <ion-label>Search</ion-label>
    </ion-tab-button>

    <ion-tab-button tab="tab4">
      <ion-icon [name]="selected == 'tab4' ? 'download' : 'download-outline'"></ion-icon>
      <ion-label>Downloads</ion-label>
    </ion-tab-button>
  </ion-tab-bar>

</ion-tabs>

Now we just need to implement a function which gets the current active tab whenever the tab changes, so we can keep track of the current value locally inside the src/app/tabs/tabs.page.ts like this:

import { Component, ViewChild } from '@angular/core';
import { IonTabs } from '@ionic/angular';

@Component({
  selector: 'app-tabs',
  templateUrl: 'tabs.page.html',
  styleUrls: ['tabs.page.scss']
})
export class TabsPage {
  @ViewChild(IonTabs) tabs;
  selected = '';
  
  constructor() {}
 
  setSelectedTab() {
    this.selected = this.tabs.getSelected();
  }

}

The first part is finished, next step will be more challenging!

Building the Netflix Home View

For now we want to implement the basic home view with hero/spotlight section at the top, and different sliding sections for series.

First step is a preparation for later in order to use the directive and modal page from here later, so we need to include it in the imports of the 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 { Tab1PageRoutingModule } from './tab1-routing.module';
import { SharedDirectivesModule } from '../directives/shared-directives.module';
import { ModalPageModule } from '../modal/modal.module';

@NgModule({
  imports: [
    IonicModule,
    CommonModule,
    FormsModule,
    Tab1PageRoutingModule,
    SharedDirectivesModule,
    ModalPageModule,
  ],
  declarations: [Tab1Page]
})
export class Tab1PageModule {}

Now we need some dummy data and for reference I’ll also add the basic JSON that is used on this home page which looks like this (that you can find on Github):

{
    "spotlight": {
        "id": 2,
        "name": "Firefly",
        "rating": "#5 in Germany Today",
        "desc": "One found fame and fortune. The other love and family. Lifelong best friends who are as different as can be — and devoted as it gets."
    },
    "sections": [
        {
            "title": "Continue Watching for Simon",
            "type": "continue",
            "series": [
                {
                    "id": 1,
                    "progress": 42,
                    "title": "Bridergton",
                    "season": "S1:E3"
                },
                {
                    "id": 2,
                    "progress": 80,
                    "title": "Lupin",
                    "season": "S1:E5"
                },
                {
                    "id": 3,
                    "progress": 12,
                    "title": "Money Heist",
                    "season": "S3:E4"
                }
            ]
        },
        {
            "title": "Netflix Originals",
            "type": "original",
            "series": [
                {
                    "id": 1
                },
                {
                    "id": 2
                },
                {
                    "id": 3
                }
            ]
        },
        {
            "title": "Trending Now",
            "type": "series",
            "series": [
                {
                    "id": 4
                },
                {
                    "id": 5
                },
                {
                    "id": 6
                },
                {
                    "id": 7
                },
                {
                    "id": 8
                }
            ]
        }
    ]
}

So we can extract the information for the spotlight section and an array with information about the different sections, and we will do this easily inside the src/app/tab1/tab1.page.ts:

import { Component } from '@angular/core';
import { ModalController } from '@ionic/angular';
import homeData from '../../assets/mockdata/home.json';
import { DrawerService } from '../services/drawer.service';

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

  sections = homeData.sections;
  spotlight = homeData.spotlight;

  opts = {
    slidesPerView: 2.4,
    spaceBetween: 10,
    freeMode: true
  };

  constructor(private modalCtrl: ModalController, private drawerService: DrawerService) {
  }

  openInfo(series) {
    
  }

  async openCategories() {
 
  }

}

Don’t worry about the empty functions right now, we will come back to them in a later step.

Everything until here was basic preparation, and now finally dive into the view!

Let’s cover everything that we got in this first view:

  • The header area holds no title or specific buttons, but a logo and bar with 3 buttons from which we will trigger out modal later
  • The content area starts with the spotlight section that uses the image information from the JSON as background image
  • We an empty div element that will be used to display a gradient across the background image
  • The rest of the spotlight is piecing together elements, which will be put into position correctly later with CSS

Below the spotlight area starts the area with our slides, based on the sections of our JSON data (see above).

Overall it’s a basic dynamic slide setup, but for the type “continue” we also want to change the appearance so it holds another progress bar block with two additional buttons, that will later trigger our drawer component.

It’s hard to exactly describe how everything came together, I highly recommend you check out the video version of this tutorial (link at the end of the tutorial) or at least the part covering this initial screen setup!

For now, continue by changing the src/app/tab1/tab1.page.html to:

<ion-header #header class="ion-no-border">
  <ion-toolbar>
    <img class="logo" src="/assets/mockdata/logo.png">
    <ion-row class="ion-justify-content-center ion-text-center">
      <ion-col size="4" class="ion-text-right">
        TV Shows
      </ion-col>
      <ion-col size="4">
        Movies
      </ion-col>
      <ion-col size="4" tappable (click)="openCategories()" class="ion-text-left">
        Categories <ion-icon name="caret-down-outline"></ion-icon>
      </ion-col>
    </ion-row>
  </ion-toolbar>
</ion-header>

<ion-content [fullscreen]="true">

  <div class="spotlight" [style.background-image]="'url(/assets/mockdata/'+spotlight.id+'-cover.jpg)'">
    <div class="gradient"></div>
    <div class="info">
      <img class="title" [src]="'/assets/mockdata/'+spotlight.id+'-title.png'">
      <span class="rating">{{ spotlight.rating }}</span>
      <ion-row class="ion-align-items-center">
        <ion-col size="4" class="ion-tex-center">
          <div class="btn-vertical">
            <ion-icon name="add"></ion-icon>
            <span>My List</span>
          </div>
        </ion-col>
        <ion-col size="4" class="ion-tex-center">
          <div class="btn-play">
            <ion-icon name="play" color="dark"></ion-icon>
            <span>Play</span>
          </div>
        </ion-col>
        <ion-col size="4" class="ion-tex-center">
          <div class="btn-vertical">
            <ion-icon name="information-circle-outline"></ion-icon>
            <span>Info</span>
          </div>
        </ion-col>
      </ion-row>
    </div>
  </div>

  <div *ngFor="let section of sections" class="ion-padding">
    <span class="section-title">{{ section.title }}</span>
    <ion-slides [options]="opts">

      <ion-slide *ngFor="let series of section.series">
        <img *ngIf="section.type != 'continue'" [src]="'/assets/mockdata/'+section.type+'-'+series.id+'.jpg'">
        <div class="continue" *ngIf="section.type == 'continue'">

          <img [src]="'/assets/mockdata/'+section.type+'-'+series.id+'.jpg'">
          <div class="progress-bar">
            <div class="progress" [style.width]="series.progress + '%'"></div>
          </div>
          <ion-row class="ion-no-padding">
            <ion-col size="6" class="ion-text-left ion-no-padding">
              <ion-button fill="clear" color="medium" size="small">
                <ion-icon name="information-circle-outline" slot="icon-only"></ion-icon>
              </ion-button>
            </ion-col>
            <ion-col size="6" class="ion-text-right ion-no-padding">
              <ion-button fill="clear" (click)="openInfo(series)" color="medium" size="small">
                <ion-icon name="ellipsis-vertical" slot="icon-only"></ion-icon>
              </ion-button>
            </ion-col>
          </ion-row>
        </div>
      </ion-slide>

    </ion-slides>
  </div>


</ion-content>

Right now this screen looks quite ugly, but we will massively change the UI with our CSS rules.

Especially challenging was/is the gradient part:

Inside the Netflix app we can see a gradient behind the header area, and also at the end of the spotlight section. Therefore we use two different gradients that I generated with a CSS gradient tool.

On top of that we need to change the --offset-top of our content to make sure the whole content starts directly at the top, otherwise the transparent toolbar background wouldn’t really work.

Most other elements are minor changes to the appearance or position, so go ahead and change the src/app/tab1/tab1.page.scss to:

// General styling
ion-content {
  --offset-top: 0px;
  position: absolute;
}

.section-title {
  font-weight: 600;
  font-size: large;
}

ion-slides {
  margin-top: 4px;
}

.continue {
  background: #191919;
}

// Progress bar inside slides
.progress-bar {
  height: 2px;
  width: 100%;
  background: #262626;

  .progress {
    background: #E40A15;
    height: 100%;
  }
}

// Spotlight section with gradient background
.spotlight {
  width: 100%;
  height: 50vh;
  background-position: center;
  background-size: cover;
  margin-bottom: 20px;
  position: relative;

  // Image overlay gradient
  .gradient {
    background: linear-gradient(#ffffff00 40%, #000000c2, #000 95%);
    width: 100%;
    height: 100%;
  }

  .info {
    width: 100%;
    position: absolute;
    bottom: 10px;
    text-align: center;

    img {
      max-width: 50%;
    }

    .rating {
      display: block;
      font-weight: 700;
      padding-top: 10px;
      padding-bottom: 10px;
    }

    // Action button row
    .btn-vertical {
      font-weight: 500;
      display: flex;
      flex-direction: column;
      align-items: center;
    }

    .btn-play {
      background: #fff;
      font-weight: 500;
      border-radius: 2px;
      color: #000;
      padding: 4px;

      display: flex;
      flex-direction: row;
      align-items: center;
      justify-content: center;
    }
  }
}

// Header area
.logo {
  margin-left: 16px;
  width: 20px;
}

ion-toolbar {
  --background: linear-gradient(180deg, rgba(0, 0, 0, 0.8715861344537815) 0%, rgba(0, 0, 0, 0.8463760504201681) 57%, rgba(0, 0, 0, 0.6923144257703081) 80%, rgba(0, 0, 0, 0.5438550420168067) 89%, rgba(0, 0, 0, 0) 100%);
}

Worth looking at are also the btn-vertical and btn-play which show a way to create your own buttons using flexbox.

In many cases, the Ionic default buttons won’t work for your exact styling and by simply styling a custom div you get the benefits of the exact UI you want plus you can still combine this and use Ionicons in your button.

And with that, our basic home view is already finished now!

Adding a Fading Header Directive

We’ve already implemented a header like this in our Twitter UI with Ionic tutorial, and the idea is simple: React to scroll events of the content and transform the header however we want to. In our case, slowly scroll and fade it out!

To get started, include our generated directive inside the module we also generated at src/app/directives/shared-directives.module.ts and export it so we can use it later:

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

The actual directive now keeps a reference to the header element and the children so we can fade out each of them separately (if we want to) and also implements the HostListener to handle all scroll events of the content.

Whenever the content is scrolled, we calculate a new position and opacity for our header and apply it to that element. Feel free to play around with those values to fade/move the header area faster or slower in your own apps to get a feeling for how this directive works.

Now open the src/app/directives/hide-header.directive.ts and change it to:

import { Directive, Input, HostListener, Renderer2, AfterViewInit } from '@angular/core';
import { DomController, isPlatform } 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;

    if (scrollTop < 0) {
      return;
    }
    
    let newPosition = -scrollTop/2;

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

Now we just need to apply this directive to our content area and pass the header element (to which we already added the #header template reference before!) and activate the output of scroll events inside the src/app/tab1/tab1.page.html like this:

<ion-content [fullscreen]="true" scrollEvents="true" [appHideHeader]="header">

Whenever we scroll the content, the header now moves slowly up and fades our just like in the original Netflix app.

Creating an Ionic Modal with Blur & Animation

This part was maybe the most interesting for me personal as I’ve created transparent modals before, but not with a blurred background.

On top of that we want to apply a custom animation to the enter/leave of the modal since the Ionic default for iOS slides the modal in, which is not in line with the Netflix app.

You can find a more detailed explanation about building custom Ionic animations in my guest post on the Ionic blog as well.

Today, we will simply define a custom enter and leave animation which will only change the opacity of the modal DOM elements and not use any movement at all.

To do so, create a new file at src/app/modal-animation.ts and insert:

import { Animation, createAnimation } from '@ionic/angular';

//
// ENTER
//
export const modalEnterAnimation = (
  baseEl: HTMLElement,
  presentingEl?: HTMLElement,
): Animation => {

  const backdropAnimation = createAnimation()
    .addElement(baseEl.querySelector('ion-backdrop')!)
    .fromTo('opacity', 0.01, 'var(--backdrop-opacity)')
    .beforeStyles({
      'pointer-events': 'none'
    })
    .afterClearStyles(['pointer-events']);

  const wrapperAnimation = createAnimation()
    .addElement(baseEl.querySelectorAll('.modal-wrapper, .modal-shadow')!)
    .beforeStyles({ 'opacity': 0, 'transform': 'translateY(0)' })
    .fromTo('opacity', 0, 1);

  const baseAnimation = createAnimation()
    .addElement(baseEl)
    .easing('cubic-bezier(0.32,0.72,0,1)')
    .duration(400)
    .addAnimation([wrapperAnimation, backdropAnimation]);

  return baseAnimation;
};

//
// LEAVE
//
export const modalLeaveAnimation = (
  baseEl: HTMLElement,
  presentingEl?: HTMLElement,
  duration = 500
): Animation => {

  const backdropAnimation = createAnimation()
    .addElement(baseEl.querySelector('ion-backdrop')!)
    .fromTo('opacity', 'var(--backdrop-opacity)', 0.0);

  const wrapperAnimation = createAnimation()
    .addElement(baseEl.querySelectorAll('.modal-wrapper, .modal-shadow')!)
    .beforeStyles({ 'opacity': 0 })
    .fromTo('opacity', 1, 0)

  const baseAnimation = createAnimation()
    .addElement(baseEl)
    .easing('ease-out')
    .duration(300)
    .addAnimation([wrapperAnimation, backdropAnimation]);

  return baseAnimation;
};

These animations could be applied globally to our Ionic app at the root module level, but if you only want to use them in some place you can also directly pass this to the create() function of the modal like we do in here.

On top of that we will add the custom transparent-modal class so we can apply our own styling in the next steps.

For now, open the src/app/tab1/tab1.page.ts and open the modal like this:

import { Component } from '@angular/core';
import { ModalController } from '@ionic/angular';
import homeData from '../../assets/mockdata/home.json';
import { ModalPage } from '../modal/modal.page';
import { modalEnterAnimation, modalLeaveAnimation } from '../modal-animation';
import { DrawerService } from '../services/drawer.service';

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

  sections = homeData.sections;
  spotlight = homeData.spotlight;

  opts = {
    slidesPerView: 2.4,
    spaceBetween: 10,
    freeMode: true
  };

  constructor(private modalCtrl: ModalController, private drawerService: DrawerService) {
  }

  async openCategories() {
    const modal = await this.modalCtrl.create({
      component: ModalPage,
      cssClass: 'transparent-modal',
      enterAnimation: modalEnterAnimation,
      leaveAnimation: modalLeaveAnimation
    });

    await modal.present();
  }

  openInfo(series) {
    
  }

}

Because the modal lives above the rest of our application, we now need to apply our CSS changes directly inside the src/global.scss:

.transparent-modal {
  --background: #0000005c;

  .modal-wrapper {
    backdrop-filter: blur(12px);
  }

  ion-content {
    --background: transparent;
  }
}

This makes the modal background mostly transparent with a tint of black, but more importantly puts a blur on the background through which we will still see our epic home screen, but blurred!

Now we just need to quickly load some data in our modal, which is actually just an array of strings. Go ahead by quickly changing the src/app/modal/modal.page.ts to:

import { Component, OnInit } from '@angular/core';
import { ModalController } from '@ionic/angular';
import categoryData from '../../assets/mockdata/categories.json';

@Component({
  selector: 'app-modal',
  templateUrl: './modal.page.html',
  styleUrls: ['./modal.page.scss'],
})
export class ModalPage implements OnInit {
  categories = categoryData;
  
  constructor(private modalCtrl: ModalController) { }

  ngOnInit() {
  }

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

The modal itself looks really simple, since we only want to display a list of categories in the center of the view, which can be done with the Ionic grid quite fast.

On top of that we want to keep the close button fixed at the bottom, and in such cases the ion-footer is a great place for stuff like that.

Open the src/app/modal/modal.page.html and change it to this:

<ion-content [fullscreen]="true">
  <ion-row>
    <ion-col size="12" *ngFor="let cat of categories" class="ion-text-center">
      {{ cat }}
    </ion-col>
  </ion-row>
</ion-content>

<ion-footer class="ion-text-center">
  <ion-toolbar>
    <ion-button (click)="dismiss()" fill="clear" color="light">
      <ion-icon name="close-circle" slot="icon-only"></ion-icon>
    </ion-button>
  </ion-toolbar>
</ion-footer>

Again, the whole view doesn’t look exactly like we want to as we don’t have a header and all elements are too close to the top (and too small as well).

Also, the toolbar is not yet transparent so we need to overwrite a few Ionic CSS variables and make our icon stand out a bit more by changing its font size at the same time.

Go ahead with the src/app/modal/modal.page.scss to wrap up the modal:

ion-row {
  margin-top: 10vh;
}

ion-col {
  margin-bottom: 30px;
  color: var(--ion-color-medium);
}

ion-toolbar {
  --background: transparent;
  --border-style: none;

  ion-button {
    ion-icon {
      font-size: 50px; 
    }
  }
}

Now we can call the modal, got a custom Ionic animation to fade it in and out, and also got the transparent blurred background that I will 100% reuse in future apps again!

Presenting a Drawer

Now we want to slide in a simple component from the bottom, and we will use a logic based on my Ionic bottom drawer tutorial. Only this time we will skip the scroll gesture handling and “simply” make it scroll in and add a dark backdrop overlay.

It would be quite easy, but theres a problem:

  • We want to present the component above the tabs, so it can’t be added to a child page of the tab bar
  • We want to trigger the component from a child page, which actually has no really connection to the component itself
  • We need a connection between the drawer and the presenting component to understand when the backdrop needs to be displayed or hidden

But let’s take it step by step and start by setting up the shared module to declare and export our custom component inside the src/app/components/shared-components.module.ts:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DrawerComponent } from './drawer/drawer.component';
import { IonicModule } from '@ionic/angular';

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

The component template itself is pretty easy as we only want to dynamically show the title and the rest is some static data for now. Go ahead and open the src/app/components/drawer/drawer.component.html and change it to:

<div class="drawer ion-padding" #drawer>

  <ion-row class="ion-align-items-center">
    <ion-col size="10">
      <h2>{{ title }}</h2>
    </ion-col>
    <ion-col size="2" class="ion-text-right">
      <ion-button fill="clear" (click)="closeDrawer()" color="medium" size="large">
        <ion-icon name="close-circle"></ion-icon>
      </ion-button>
    </ion-col>

    <ion-col size="2">
      <ion-icon name="information-circle-outline" size="large"></ion-icon>
    </ion-col>
    <ion-col size="10">
      Episodes And info
    </ion-col>

    <ion-col size="2">
      <ion-icon name="download-outline" size="large"></ion-icon>
    </ion-col>
    <ion-col size="10">
      Download Episode
    </ion-col>

    <ion-col size="2">
      <ion-icon name="thumbs-up-outline" size="large"></ion-icon>
    </ion-col>
    <ion-col size="10">
      Like
    </ion-col>

    <ion-col size="2">
      <ion-icon name="thumbs-down-outline" size="large"></ion-icon>
    </ion-col>
    <ion-col size="10">
      Not For Me
    </ion-col>

  </ion-row>
</div>

Inside the drawer we now need to implement functions that can be called from the outside to open or close the drawer component. In those cases, we will simply transform the Y value to slide it in, or set it back to move it out with a small transition.

The close function is also used inside the component when clicking the X icon, so we need a way to inform the parent component that the component was dismissed in order to hide the backdrop.

Therefore we add an Event emitter so our component can emit values back to the parent component when it implements the right functionality. We will do so whenever the open state changes, meaning the backdrop should be shown or hidden!

Go ahead and change the src/app/components/drawer/drawer.component.ts to:

import { Component, ElementRef, EventEmitter, Output, ViewChild } from '@angular/core';

@Component({
  selector: 'app-drawer',
  templateUrl: './drawer.component.html',
  styleUrls: ['./drawer.component.scss'],
})
export class DrawerComponent {
  @ViewChild('drawer', { read: ElementRef }) drawer: ElementRef;
  @Output('openStateChanged') openState: EventEmitter<boolean> = new EventEmitter();

  title = '';

  constructor() { }

  openDrawer(title) {
    this.title = title;
    const drawer = this.drawer.nativeElement;
    drawer.style.transition = '.2s ease-in';
    drawer.style.transform = `translateY(-300px) `;
    this.openState.emit(true);
  }

  closeDrawer() {
    const drawer = this.drawer.nativeElement;
    drawer.style.transition = '.2s ease-out';
    drawer.style.transform = '';
    this.openState.emit(false);
  }
}

In order to give the component a fixed width and correct position we now need to apply a bit of custom styling inside the src/app/components/drawer/drawer.component.scss:

.drawer {
    position: absolute;
    box-shadow: rgba(0, 0, 0, 0.12) 0px 4px 16px;
    width: 100%;
    border-radius: 4px;
    bottom: -300px;
    height: 300px;
    z-index: 11;
    background: #2B2B2C;
    color: #fff;
}

That means, initially the component is outside of the view and not visible and it will come up from the bottom when the according function is called.

Since the component is used in the TabsPage but we want to trigger it from the Tab1Page, we now implement a super simple service that can be used by the different pages to coordinate the presentation more easily.

Open the src/app/services/drawer.service.ts and add this simple code, which broadcasts a new value to the Subject when the drawer should be opend:

import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

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

  drawerOpen = new BehaviorSubject(null);

  constructor() { }

  openDrawer(title) {
    this.drawerOpen.next({open: true, title});
  }
}

Now we can change the according function in oursrc/app/tab1/tab1.page.ts to call this service whenever the drawer should be shown, and we can also pass in the title that should change based on which card we clicked in the view:

openInfo(series) {
    this.drawerService.openDrawer(series.title);
  }

Finally, we need to add the actual component but before we do it, we need to import the shared module inside the src/app/tabs/tabs.module.ts:

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

import { TabsPageRoutingModule } from './tabs-routing.module';

import { TabsPage } from './tabs.page';
import { SharedComponentsModule } from '../components/shared-components.module';

@NgModule({
  imports: [
    IonicModule,
    CommonModule,
    FormsModule,
    TabsPageRoutingModule,
    SharedComponentsModule
  ],
  declarations: [TabsPage]
})
export class TabsPageModule {}

With that in place, we add the app-drawer component below our tabs implementation, and we also add a custom div which acts as the backdrop to the page.

The backdrop will fade in our out based on the state of the component (which we will handle from the class), and to our component we add the openStateChanged which means we can listen for the events from the event emitter we implemented before!

Go ahead and change our tab bar at src/app/tabs/tabs.page.html to:

<ion-tabs (ionTabsDidChange)="setSelectedTab()">
  <div class="backdrop" [ngClass]="backdropVisible ? 'fade-in' : 'fade'" tappable (click)="closeDrawer()"></div>

  <ion-tab-bar slot="bottom" color="dark">
    <ion-tab-button tab="tab1">
      <ion-icon [name]="selected == 'tab1' ? 'home' : 'home-outline'"></ion-icon>
      <ion-label>Home</ion-label>
    </ion-tab-button>

    <ion-tab-button tab="tab2">
      <ion-icon [name]="selected == 'tab2' ? 'copy' : 'copy-outline'"></ion-icon>
      <ion-label>Coming Soon</ion-label>
    </ion-tab-button>

    <ion-tab-button tab="tab3">
      <ion-icon [name]="selected == 'tab3' ? 'search' : 'search-outline'"></ion-icon>
      <ion-label>Search</ion-label>
    </ion-tab-button>

    <ion-tab-button tab="tab4">
      <ion-icon [name]="selected == 'tab4' ? 'download' : 'download-outline'"></ion-icon>
      <ion-label>Downloads</ion-label>
    </ion-tab-button>
  </ion-tab-bar>

</ion-tabs>

<app-drawer (openStateChanged)="toggleBackdrop($event)"></app-drawer>

Inside the tabs page we now need three things:

  1. Listen to the Subject of the drawer service, which means we should open the component
  2. React to changes inside toggleBackdrop to show or hide the backdrop when the drawer was closed directly from within the component
  3. Implement the close function when clicking on the backdrop right here

Since this page can now access the drawer as a ViewChild, the whole operation finally works together and we can change our src/app/tabs/tabs.page.ts to:

import { ChangeDetectorRef, Component, ViewChild } from '@angular/core';
import { IonTabs } from '@ionic/angular';
import { DrawerComponent } from '../components/drawer/drawer.component';
import { DrawerService } from '../services/drawer.service';

@Component({
  selector: 'app-tabs',
  templateUrl: 'tabs.page.html',
  styleUrls: ['tabs.page.scss']
})
export class TabsPage {
  @ViewChild(IonTabs) tabs;
  selected = '';
  
  @ViewChild(DrawerComponent) drawer: DrawerComponent;
  backdropVisible = false;
  
  constructor(private drawerService: DrawerService, private changeDetectorRef: ChangeDetectorRef) {
    this.drawerService.drawerOpen.subscribe(drawerData => {
      if (drawerData && drawerData.open) {
        this.drawer.openDrawer(drawerData.title);
      }
    })
  }
 
  setSelectedTab() {
    this.selected = this.tabs.getSelected();    
  }

  closeDrawer() {
    this.drawer.closeDrawer();
  }

  toggleBackdrop(isVisible) {    
    this.backdropVisible = isVisible;
    this.changeDetectorRef.detectChanges();
  }
}

The last piece is the styling for our backdrop which should cover the whole screen with a slightly transparent dark background, and the CSS animations with a short transition to fade in/out the whole backdrop.

Add the last missing piece to the src/app/tabs/tabs.page.scss now:

.backdrop {
  width: 100%;
  height: 100%;
  background: #000000d2;
  z-index: 10;
  position: absolute;
}

.fade {
  transition: 0.4s linear all;
  opacity: 0;
  z-index: -1;
}

.fade-in {
  transition: 0.4s linear all;
  opacity: 1;
}

Now we can control the drawer component from different places, while it’s still visible above the tab bar.

This could have been a lot easier but since we had a more complex case and different pages involved, we had to implement the whole logic including the service like this!

Enjoy your finished Netflix UI with Ionic and play around with the different elements now to get a better feeling for all the cool things we added.

Conclusion

The Netflix UI didn’t look like a huge challenge at first, but if you look closer at popular apps you can see what makes them great: Small details.

A fade in the right place here, a custom animation there and overall a composition of complex elements and behaviours.

But like we’ve seen before in the Built with Ionic series – we can achieve almost the same results with Ionic!

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

The post Building the Netflix UI with Ionic appeared first on Devdactic - Ionic Tutorials.

Viewing all 183 articles
Browse latest View live