Quantcast
Channel: Devdactic – Ionic Tutorials

How to Build a Native App from Angular Projects with Capacitor

$
0
0

The popularity of Capacitor is constantly rising, and given the fact that you can turn basically any web project into a native mobile app with Capacitor is the simple reason for Capacitors fame.

This blog is usually about Ionic, but I’ve used plain Angular web projects for many of my own projects in the past.

So how can we transform a web project that’s using Angular into a native mobile app?

angular-capacitor-app

We’ll go through all necessary steps in this tutorial and finally even add on device live reload to our Angular Capacitor app on both iOS and Android!

Setting up the Angular App

If you already got your Angular app you don’t need to start a new one, otherwise simply follow along for testing and generate a new one using the Angular CLI:

ng new angularCapacitor --routing --style=scss
cd ./angularCapacitor

# Add Capacitor
npm install @capacitor/core
npm install @capacitor/cli --save-dev

# Setup the Capacitor config
npx cap init

After creating the Angular app we have now installed two packages for Capacitor:

  • @capacitor/core: The core package necessary for adding any other plugin
  • @capacitor/cli: The CLI as a dev dependency to run Capacitor commands in our project

After the installation I’ve triggered the init command using npx to run a local script, which will ask you some general questions about the app:

angular-capacitor-init

Don’t worry too much about this, you could easily see and change this after this afterwwards inside your capacitor.config.ts, the file that was created within this step:

import { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  appId: 'com.devdactic.angular',
  appName: 'Angular App',
  webDir: 'dist/angularCapacitor',
  bundledWebRuntime: false
};

export default config;

This file contains information about the native iOS and Android project like the appId, which is used as the bundle identifier for your app.

Note: I have changed the webDir because in plain Angular projects the output of your project is in a subfolder of dist, so make sure you use the right path to the output of your usual build command in here!

Otherwise Capacitor won’t be able to find your web assets and your native app will be blank.

Adding Native iOS & Android

So far we’ve only installed the core packages for Capacitor, but how do we turn our app into a native app?

This can be done by first of all installing the according packages for iOS and Android (if you want both!) and then running the Capacitor CLI commands to add the native projects to your app:

npm install @capacitor/ios @capacitor/android
npx cap add ios
npx cap add android

As a result, this will add two new folders in your project. These folders contain native projects, just like you would have when developing a native iOS or Android app!

angular-capacitor-structure

Note: While you can change some of the settings through the previously generated Capacitor config, usually Capacitor won’t overwrite your native project settings as that’s one of the core Capacitor philosophies!

Now that we got those folders, it’s time to build our Angular app the usual way and then sync our changes to the native projects by running:

# Build the Angular app
ng build --prod

# Sync the build folder to native projects
npx cap sync

This will copy over the build folder in the right place of the iOS/Android project, and you can finally see the result of your work on a device.

How?

The Capacitor CLI got you again. There’s a command to directly open Android Studio or Xcode (install them if you haven’t used them before!) with your project:

npx cap open ios
npx cap open android

If you never touched mobile development, make sure you set up your environment now with all necessary packages!

You can now use the native tooling of AS and Xcode to deploy your app directly to a connected device.

Using Native Device Features: The Camera

So far we have only used Capacitor to build our native app, but you can use Capacitor as well to access native device functionality, which is the second big reason to use Capacitor.

Let’s try and add the Camera plugin so we can easily capture images within our app.

Since Capacitor 3 we need to install every plugin separately, so go ahead and run:

npm install @capacitor/camera

# If used inside a PWA also install
npm install @ionic/pwa-elements

The second installation of pwa-elements is only necessary if you want a nice little camera overlay in your PWA or for testing locally. To use the package you would also need to define it inside the src/main.ts like this:

import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppModule } from './app/app.module';
import { environment } from './environments/environment';

import { defineCustomElements } from '@ionic/pwa-elements/loader';

if (environment.production) {
  enableProdMode();
}

platformBrowserDynamic().bootstrapModule(AppModule)
  .catch(err => console.error(err));

  defineCustomElements(window);

Again, not necessary if you only want to use the camera on iOS and Android.

Let’s now quickly throw in a button to trigger a function in the automatically generated src/app/app.component.html, somewhere inside the content area:

<div class="card-container">
    <button class="card card-small" (click)="captureImage()">
      <span>Capture image</span>
    </button>
  </div>

  <img [src]="image" *ngIf="image" [style.width]="'300px'">

On click we will now call the Capacitor camera to capture an image and hopefully assign the result to the image variable.

The call to native plugins is always async, so we await the getPhoto() call and use the resultType base64 in our example.

After we get the result, we simply assign the value to our image including the base64 information and hopefully the image comes up.

Go ahead and change the src/app/app.component.ts to:

import { Component } from '@angular/core';
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  title = 'angularCapacitor';
  image = '';

  async captureImage() {
    const image = await Camera.getPhoto({
      quality: 90,
      allowEditing: true,
      source: CameraSource.Prompt,
      resultType: CameraResultType.Base64
    });
  
    if (image) {
      this.image = `data:image/jpeg;base64,${image.base64String}`!;
    }
  }
}

Since you are now adding functionality that runs inside a native mobile app you need to think about permissions as well.

For the camera plugin, we need to change the ios/App/App/Info.plist of our iOS project to include information why we want to use this particular functionality:

<key>NSCameraUsageDescription</key>
		<string>To capture images</string>
	<key>NSPhotoLibraryAddUsageDescription</key>
		<string>To add images</string>
	<key>NSPhotoLibraryUsageDescription</key>
		<string>To select images</string>

Make sure you use reasonable information in here, otherwise your app has a high chance of being rejected (not kidding).

Same is true for Android, here we need to touch the bottom of the android/app/src/main/AndroidManifest.xml and include two new permissions for the storage to use the camera:

<!-- Permissions -->

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

You can now run another build of your app and sync the changes to your native projects and run the app on a device again or…

Or is there maybe a faster way to achieve all of this?

Capacitor Live Reload

By now you are used to have live reload with all modern frameworks, and we can have the same functionality even on a mobile device with minimum effort!

Usually this can be achieved quite easily with the Ionic CLI, however I wanted to find a more Capacitor like way without adding another dependency and turns out, it’s possible.

The idea is to make your locally served app with live reload available on your network, and the Capacitor app will simply load the content from that URL.

First step is figuring out your local IP, which you can get on a Mac by running:

ipconfig getifaddr en0

On Windows, run ipconfig and look for the IPv4 address.

With that information you can now tell Angular to use it directly as a host (instead of the keyword localhost) or you can simply use 0.0.0.0 which did the same in my test:

ng serve -o --host 0.0.0.0

# Alternative
ng serve -o --host 192.168.x.xx

Now we only need to tell Capacitor to load the app directly from this server, which we can do right in our capacitor.config.ts with another entry:

import { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  appId: 'com.devdactic.angular',
  appName: 'Angular App',
  webDir: 'dist/angularCapacitor',
  bundledWebRuntime: false,
  server: {
    url: 'http://192.168.x.xx:4200',
    cleartext: true
  },
};

export default config;

Make sure you use the right IP and port, I’ve simply used the default Angular port in here.

To apply those changes we can now copy over the changes to our native project:

npx cap copy

Copy is mostly like sync, but will only copy over the changes of the web folder and config, not update the native project.

Now you can deploy your app one more time through Android Studio or Xcode and then change something in your Angular app – the app will automatically reload and show the changes!

Caution: If you install new plugins like the camera, this still requires a rebuild of your native project because native files are changed which can’t be done on the fly.

What about Ionic UI components?

At this point you have a native Angular app which can access native device functionality – pretty impressive already!

If you now also want a more native like styling of components, you could still add Ionic to your project as it’s basically a UI toolkit for developing mobile apps.

This step is completely optional, but if you want to follow along, simply install the according Ionic package for Angular:

npm i @ionic/angular

To use it we need to add the import of the Ionic module to our root module in src/app/app.module.ts:

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

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { IonicModule } from '@ionic/angular';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    IonicModule.forRoot()
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Since Ionic components rely heavily on custom styling for iOS and Android, you should also import the default Ionic styling files inside your src/styles.scss like this:

/* Core CSS required for Ionic components to work properly */
@import "~@ionic/angular/css/core.css";

/* Basic CSS for apps built with Ionic */
@import "~@ionic/angular/css/normalize.css";
@import "~@ionic/angular/css/structure.css";
@import "~@ionic/angular/css/typography.css";
@import '~@ionic/angular/css/display.css';

/* Optional CSS utils that can be commented out */
@import "~@ionic/angular/css/padding.css";
@import "~@ionic/angular/css/float-elements.css";
@import "~@ionic/angular/css/text-alignment.css";
@import "~@ionic/angular/css/text-transformation.css";
@import "~@ionic/angular/css/flex-utils.css";

What’s missing is the import for Ionic variables for default colors, which are normally included in Ionic projects. That means right now only the default styling is used, but you could add those CSS variables as well to use them just like inside any other Ionic project!

If you want to test wether your Ionic UI integration works, simple add a card or other component to your markup like this:

<ion-card>
    <ion-card-header>
      <ion-card-title>My Ionic card</ion-card-title>
    </ion-card-header>
    <ion-card-content>
      This really works!
    </ion-card-content>
    <ion-button expand="full" color="secondary">My Button</ion-button>
  </ion-card>

If you see a styled card that looks different on iOS and Android you’ve finished your integration!

Tip: To quickly check out different styling you can enable the device preview inside the developer tools of your browser and switch between iOS and Android devices. Make sure you reload the page once when changing platforms to see the new styling in action!

Conclusion

As Capacitor is growing in popularity, it becomes clear why people choose it to build native apps: The ease of adding Capacitor to any web app (Angular, React, Vue…) and the access to native device functionality makes it super easy for web developers to get into mobile app development.

And as a result you have one codebase for web and native iOS + Android.

If you enjoy working with native apps and want to learn more about it, check out the Ionic Academy – my online school that helps you build epic mobile apps using Ionic and Capacitor!

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

The post How to Build a Native App from Angular Projects with Capacitor appeared first on Devdactic - Ionic Tutorials.


How to Setup Deep Links With Capacitor (iOS & Android)

$
0
0

Taking your users directly into your app with a universal link on iOS or App Link on Android is one of the easiest ways to open a specific app of your Ionic page, and becomes a super easy task with Capacitor.

In this post we will integrate these links, also known as deep links for both platforms so we are able to jump right into the app if it is installed.

capacitor-deep-links

For this we won’t need a lot of code, but a bit of additional setup to make those deep links work correctly. In the end, you will be able to open a URL like “www.yourdomain.com/details/22” in the browser and your app will automatically open the right page!

Ionic Deeplink Setup

Let’s begin with the easiest part, which is actually setting up a new Ionic app and generating one details page for testing:

ionic start devdacticLinks blank --type=angular
cd ./devdacticLinks
ionic g page details

ionic build
ionic cap add ios
ionic cap add android

You can also create the native builds after creating the app since we will have to work with the native files later as well. For this I recommend you put your correct bundle ID into the capacitor.config.json or TS file, because it will be used only during this initial setup.

In my case I used “com.devdactic.deeplinks” as the ID like this inside the file:

{
  "appId": "com.devdactic.deeplinks",
  "appName": "devdacticLinks",
  "webDir": "www",
  "bundledWebRuntime": false
}

Next step is some basic routing, and we will simply make our details page accessible with a wildcard in the URL that we will later use in the deep link to see that our setup works.

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

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

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

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

Now we can retrieve the ID information from the URL just like we do in a normal Angular routing scenario on our src/app/details/details.page.ts:

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';

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

  constructor(private route: ActivatedRoute) {}

  ngOnInit() {
    this.id = this.route.snapshot.paramMap.get('id');
  }
}

Finally let’s also display the information we got from the URL on the src/app/details/details.page.html:

<ion-header>
  <ion-toolbar color="primary">
    <ion-buttons slot="start">
      <ion-back-button defaultHref="/"></ion-back-button>
    </ion-buttons>
    <ion-title>Details</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content> My ID: {{ id }} </ion-content>

All of this was basic Ionic Angular stuff and by no means related to deep links at all!

The magic now comes from handling the appUrlOpen event, which we can do easily by using Capacitor.

We simply add a listener on this event and from there get access to the URL with which our app was opened from the outside!

Since that URL contains your domain as well, we need to split the URL to remove that part, and then use the rest of the URL for our app routing.

This might be different for your own app since you have other pages or a different routing, but you could also simply add some logic in there and check the different path components of the URL and then route the user to the right place in your app!

Go ahead and change the src/app/app.component.ts to this now:

import { Component, NgZone } from '@angular/core';
import { Router } from '@angular/router';
import { App, URLOpenListenerEvent } from '@capacitor/app';

@Component({
  selector: 'app-root',
  templateUrl: 'app.component.html',
  styleUrls: ['app.component.scss'],
})
export class AppComponent {
  constructor(private router: Router, private zone: NgZone) {
    this.initializeApp();
  }

  initializeApp() {
    App.addListener('appUrlOpen', (event: URLOpenListenerEvent) => {
      this.zone.run(() => {
        const domain = 'devdactic.com';

        const pathArray = event.url.split(domain);
        // The pathArray is now like ['https://devdactic.com', '/details/42']

        // Get the last element with pop()
        const appPath = pathArray.pop();
        if (appPath) {
          this.router.navigateByUrl(appPath);
        }
      });
    });
  }
}

Also notice that we didn’t install a single additional plugin? No more Cordova plugins with specific parameters, everything we need is already available inside the Capacitor App package!

But this was the easy part – now we need some customisation for iOS and Android to actually make deep links work.

iOS Configuration

If you don’t have an app ID created inside your iOS Developer account, now is the time.

First of all you need to be enrolled in the Apple Developer Program, which is also necessary to submit your apps in the end.

Your app needs a valid identifier that you also always need when you submit your app. If you want to create a new one, just go to the identifiers list inside your account and add a new App id.

ios-app-id-deep-links

It’s important to enable Associated Domains for your app id in this screen!

In that screen you need to note 2 things (which you can see in the image above):

  • The bundle id (app id) you specified
  • Your Team id

Now we need to create another validation file, which is called apple-app-site-association. Without any ending, only this name!

The content should look like this, but of course insert your team id and bundle ID, for example “12345.com.devdactic.wpapp”:

{
    "applinks": {
        "apps": [],
        "details": [
            {
                "appID": "YOURTEAMID.com.your.bundleid",
                "paths": ["*"]
            }
        ]
    }
}

You can create that file simply anywhere on your computer, it doesn’t have to be inside the project. It doesn’t matter, because it actually needs to be served on your domain!

So the next step is upload the validation file to your hosting.

You can add the file to the same .well-known folder, and your file needs to be accessible on your domain.

You can find my file here: http://devdactic.com/.well-known/apple-app-site-association

The file validates your domain for iOS, and you can also specify which paths should match. I used the * wildcard to match any routes, but if you only want to open certain paths directly in the app you could specify something like “products/*” or event multiple different paths!

If you think you did everything correctly, you can insert your data in this nice testing tool for iOS.

The last step is to add the domains to your Xcode plist. You can do this directly inside Xcode by adding a new entry and using the format “applinks:yourdomain.com“.

ios-capabilities-deep-links

At the end of this tutorial I also share another way to do this kind of customisation directly from code with a cool Capacitor tool.

Note: After going through this process I noticed a bug with Chrome on iOS which you should keep an eye on.

Anyway, that’s already everything we need to create universal links for iOS with Ionic!

Android Configuration

Now we want to make our links work on Android, where the name for these special links is App Links.

We need to take a few steps to verify that we own a URL (just like we did for iOS) and that we have a related app:

  1. Generate a keystore file used to sign your apps (if you haven’t already)
  2. Get the fingerprint from the keystore file
  3. Create/generate an assetlinks.json file
  4. Upload the file to your server

So first step is to create a keystore file and get the fingerprint data. This file is used to sign your app, so perhaps you already have it. Otherwise, go ahead with these:

keytool -genkey -v -keystore my-release-key.keystore -alias alias_name -keyalg RSA -keysize 2048 -validity 10000
keytool -list -v -keystore my-release-key.keystore

Now we can use the cool tool right here to generate our file by adding your domain data and fingerprint data.

android-deep-link-tester

You can paste the generated information into an assetlinks.json file that you need to upload to your domain. The file content has this form:

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.devdactic.deeplinks",
      "sha256_cert_fingerprints": [
        "CB:2B:..."
      ]
    }
  }
]

In my case, you can see the file at https://devdactic.com/.well-known/assetlinks.json and you need to upload it to the path on your domain of course as well.

Once you have uploaded the file, you can test if everything is fine right within the testing tool again and the result should be a green circle!

The last step is to change your android/app/src/main/AndroidManifest.xml and include and additional intent-filter inside the activity element:

<activity ....>
            <intent-filter android:autoVerify="true">
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data android:scheme="https" android:host="devdactic.com" />
            </intent-filter>
</activity>

Now you just need to build your app and sign it, since I found issues when not signing my app. You can do this by running:

cd ./android
./gradlew assembleRelease 
cd ..

jarsigner -verbose -sigalg SHA1withRSA -digestalg SHA1 -keystore my-release-key.keystore ./android/app/build/outputs/apk/release/app-release-unsigned.apk alias_name
zipalign -v 4 ./android/app/build/outputs/apk/release/app-release-unsigned.apk devdactic.apk

adb install devdactic.apk

Run all the commands and the app will be installed on your connected device.

You could also create a signed APK from Android Studio, just make sure you specify the same keystore file for signing as you used for generating the SHA information in the beginning.

Capacitor Configure for native project settings

We have applied a custom setting for iOS by changing it inside Xcode, but you can also automate a bunch of things with a new tool as well.

If you want to do this in a cooler way, I highly recommend you integrate the new Capacitor configure package and do this from the command line instead. It’s a super helpful tool for customising your native iOS and Android projects!

Get started by installing it inside your project first:

npm i @capacitor/configure

Now you can create a config.yaml at the root level of your project with the following content_

vars:
  BUNDLE_ID:
    default: com.devdactic.deeplinks
  PACKAGE_NAME:
    default: com.devdactic.deeplinks

platforms:
  ios:
    targets:
      App:
        bundleId: $BUNDLE_ID

        entitlements:
          - com.apple.developer.associated-domains: ["applinks:devdactic.com"]
  android:
    packageName: $PACKAGE_NAME

Of course you should use your own bundle ID and package name, and insert your domain name for the entitlements.

All you need to do now is run the configure tool with this config by executing:

npx cap-config run config.yaml

And voila, the settings you specified in the YAML file are applied to your native projects!

Conclusion

Compared to deep links with Cordova, the process for links with Capacitor is a lot easier since we don’t need any additional plugin and only the core Capacitor functionalities.

Still, the important part remains the setup and verification of your domains for both iOS and Android, so make sure the according testing tools show a green light after uploading your file.

If that’s not the case, this is the place to debug and potentially fix permissions or serving headers for your files so that Android and Apple accept your domain as authorised for deep links!

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

The post How to Setup Deep Links With Capacitor (iOS & Android) appeared first on Devdactic - Ionic Tutorials.

Building an Ionic App with Firebase Authentication & File Upload using AngularFire 7

$
0
0

If you want a full blown cloud backend for your Ionic app, Firebase offers everything you need out of the box so you can setup your Ionic app with authentication and file upload in minutes!

In this tutorial we will implement the whole flow from connecting our Ionic app to Firebase, adding user authentication and protecting pages after a login to finally selecting an image with Capacitor and uploading it to Firebase cloud storage!

ionic-firebase-9-auth

All of this might sound intimidating but you will see, it’s actually a breeze with these tools. To handle our Firebase interaction more easily we will use the AngularFire library version 7 which works with the Firebase SDK version 9 which I used in this post.

Note: At the time writing there was a problem when running the app on iOS devices – read until the end for a solution!

Creating the Firebase Project

Before we dive into the Ionic app, we need to make sure we actually have a Firebase app configured. If you already got something in place you can of course skip this step.

Otherwise, make sure you are signed up (it’s free) and then hit Add project inside the Firebase console. Give your new app a name, select a region and then create your project!

Once you have created the project you can see the web configuration which looks like this:

ionic-4-firebase-add-to-app

If it’s a new project, click on the web icon below “Get started by adding Firebase to your app” to start a new web app and give it a name, you will see the configuration in the next step now.

Leave this config block open just for reference, it will hopefully be copied automatically later by a schematic.

Additionally we have to enable the database, so select Firestore Database from the menu and click Create database.

ionic-4-firestore

Here we can set the default security rules for our database and because this is a simple tutorial we’ll roll with the test mode which allows everyone access.

Because we want to work with users we also need to go to the Authetnication tab, click Get started again and activate the Email/Password provider. This allows us to create user with a standard email/ps combination.

firebase-auth-provider

The last step is enabling Storage in the according menu entry as well, and you can go with the default rules because we will make sure users are authenticated at the point when they upload or read files.

The rules should look like this:

rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    match /{allPaths=**} {
      allow read, write: if request.auth != null;
    }
  }
}

Note: For real applications you need to create secure rules for storage and your Firestore database, otherwise people can easily access all your data from the outside!

You can learn more about Firebase and security rules inside the Ionic Academy.

Starting our Ionic App & Firebase Integration

Now we can finally begin with the actual Ionic app, and all we need is a blank template, an additional page and two services for the logic in our app:

ionic start devdacticFire blank --type=angular
cd ./devdacticFire

ionic g page login
ionic g service services/auth
ionic g service services/avatar

# For image upload with camera
npm i @capacitor/camera
npm i @ionic/pwa-elements

ng add @angular/fire

Besides that we can already install the Capacitor camera package to capture images later (and the PWA elements for testing on the browser).

To use those PWA elements, quickly bring up your src/main.ts and import plus call the defineCustomElements function:

import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
import { defineCustomElements } from '@ionic/pwa-elements/loader';

if (environment.production) {
  enableProdMode();
}

platformBrowserDynamic()
  .bootstrapModule(AppModule)
  .catch((err) => console.log(err));

defineCustomElements(window);

The last command is the most important as it starts the AngularFire schematic, which has become a lot more powerful over the years! You should select the according functions that your app needs, in our case select Cloud Storage, Authentication and Firestore.

ionic-firebase-add-cli

After that a browser will open to log in with Google, which hopefully reads your list of Firebase apps so you can select the Firebase project and app your created in the beginning!

As a result the schematic will automatically fill your environments/environment.ts file – if bot make sure you manually add the Firebase configuration from the first step like this:

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

On top of that the schematic injected everything necessary into our src/app/app.module.ts using the new Firebase 9 modular approach:

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

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

import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
import { initializeApp, provideFirebaseApp } from '@angular/fire/app';
import { environment } from '../environments/environment';
import { provideAuth, getAuth } from '@angular/fire/auth';
import { provideFirestore, getFirestore } from '@angular/fire/firestore';
import { provideStorage, getStorage } from '@angular/fire/storage';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [
    BrowserModule,
    IonicModule.forRoot(),
    AppRoutingModule,
    provideFirebaseApp(() => initializeApp(environment.firebase)),
    provideAuth(() => getAuth()),
    provideFirestore(() => getFirestore()),
    provideStorage(() => getStorage()),
  ],
  providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
  bootstrap: [AppComponent],
})
export class AppModule {}

Again, if the schematic failed for some reason that’s how your module should look like before you continue!

Now we can also quickly touch the routing of our app to display the login page as the first page, and use the default home page for the inside area.

We don’t have authentication implemented yet, but we can already use the AngularFire auth guards in two cool ways:

  • Protect access to “inside” pages by redirecting unauthorized users to the login
  • Preventing access to the login page for previously authenticated users, so they are automatically forwarded to the “inside” area of the app

This is done with the helping pipes and services of AngularFire that you can now add inside the src/app/app-routing.module.ts:

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

const redirectUnauthorizedToLogin = () => redirectUnauthorizedTo(['']);
const redirectLoggedInToHome = () => redirectLoggedInTo(['home']);

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

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

Now we can begin with the actual authentication of users!

Building the Authentication Logic

The whole logic will be in a separate service, and we need jsut three functions that simply call the according Firebase function to create a new user, sign in a user or end the current session.

For all these calls you need to add the Auth reference, which we injected inside the constructor.

Since these calls sometimes fail and I wasn’t very happy about the error handling, I wrapped them in try/catch blocks so we have an easier time when we get to our actual page.

Let’s begin with the src/app/services/auth.service.ts now and change it to:

import { Injectable } from '@angular/core';
import {
  Auth,
  signInWithEmailAndPassword,
  createUserWithEmailAndPassword,
  signOut
} from '@angular/fire/auth';

@Injectable({
  providedIn: 'root',
})
export class AuthService {
  constructor(private auth: Auth) {}

  async register({ email, password }) {
    try {
      const user = await createUserWithEmailAndPassword(
        this.auth,
        email,
        password
      );
      return user;
    } catch (e) {
      return null;
    }
  }

  async login({ email, password }) {
    try {
      const user = await signInWithEmailAndPassword(this.auth, email, password);
      return user;
    } catch (e) {
      return null;
    }
  }

  logout() {
    return signOut(this.auth);
  }
}

That’s already everything in terms of logic. Now we need to capture the user information for the registration, and therefore we import the ReactiveFormsModule in our src/app/login/login.module.ts now:

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

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

import { LoginPageRoutingModule } from './login-routing.module';

import { LoginPage } from './login.page';

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

Since we want to make it easy, we’ll handle both registration and signup with the same form on one page.

But since we added the whole logic already to a service, there’s not much left for us to do besides showing a casual loading indicator or presenting an alert if the action failed.

If the registration or login is successful and we get back an user object, we immediately route the user forward to our inside area.

Go ahead by changing the src/app/login/login.page.ts to:

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

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

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

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

  get password() {
    return this.credentials.get('password');
  }

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

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

    const user = await this.authService.register(this.credentials.value);
    await loading.dismiss();

    if (user) {
      this.router.navigateByUrl('/home', { replaceUrl: true });
    } else {
      this.showAlert('Registration failed', 'Please try again!');
    }
  }

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

    const user = await this.authService.login(this.credentials.value);
    await loading.dismiss();

    if (user) {
      this.router.navigateByUrl('/home', { replaceUrl: true });
    } else {
      this.showAlert('Login failed', 'Please try again!');
    }
  }

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

The last missing piece is now our view, which we connect with the formGroup we defined in our page. On top of that we can show some small error messages using the new Ionic 6 error slot.

Just make sure that one button inside the form has the submit type and therefore triggers the ngSubmit action, while the other has the type button if it should just trigger it’s connected click event!

Bring up the src/app/login/login.page.html now and change it to:

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

<ion-content class="ion-padding">
  <form (ngSubmit)="login()" [formGroup]="credentials">
    <ion-item fill="solid" class="ion-margin-bottom">
      <ion-input type="email" placeholder="Email" formControlName="email"></ion-input>
      <ion-note slot="error" *ngIf="(email.dirty || email.touched) && email.errors">Email is invalid</ion-note>
    </ion-item>
    <ion-item fill="solid" class="ion-margin-bottom">
      <ion-input type="password" placeholder="Password" formControlName="password"></ion-input>
      <ion-note slot="error" *ngIf="(password.dirty || password.touched) && password.errors">Password needs to be 6 characters</ion-note>
    </ion-item>

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

And at this point we are already done with the first half of our tutorial, since you can now really register users and also log them in.

You can confirm this by checking the Authentication area of your Firebase console and hopefully a new user was created in there!

ionic-firebase-user

For a more extensive login and registration UI tutorial you can also check out the Ionic App Navigation with Login, Guards & Tabs Area tutorial!

Uploading image files to Firebase with Capacitor

Just like before we will now begin with the service implementation, which makes life really easy for our page later down the road.

The service should first of all return the document of a user in which we plan to store the file reference/link to the user avatar.

In many tutorials you directly create a document inside your Firestore database for a user right after the sign up, but it’s also no problem that we haven’t done by now.

The data can be retrieved using the according docData() function – you can learn more about the way of accessing collections and documents with Firebase 9 here.

Besides that we can craft our uploadImage() function and expect a Photo object since this is what we get back from the Capacitor camera plugin.

Now we just need to create a path to where we want to upload our file and a reference to that path within Firebase storage.

With that information we can trigger the uploadString() function since we simply upload a base64 string this time. But there’s also a function to upload a Blob in case you have some raw data.

When the function is finished, we need to call another getDownloadURL() function to get the actual path of the image that we just uploaded.

This information is now written to the user document so we can later easily retrieve it.

All of that sounds challenging, but it’s actually just a few lines of code inside our src/app/services/avatar.service.ts:

import { Injectable } from '@angular/core';
import { Auth } from '@angular/fire/auth';
import { doc, docData, Firestore, setDoc } from '@angular/fire/firestore';
import {
  getDownloadURL,
  ref,
  Storage,
  uploadString,
} from '@angular/fire/storage';
import { Photo } from '@capacitor/camera';

@Injectable({
  providedIn: 'root',
})
export class AvatarService {
  constructor(
    private auth: Auth,
    private firestore: Firestore,
    private storage: Storage
  ) {}

  getUserProfile() {
    const user = this.auth.currentUser;
    const userDocRef = doc(this.firestore, `users/${user.uid}`);
    return docData(userDocRef, { idField: 'id' });
  }

  async uploadImage(cameraFile: Photo) {
    const user = this.auth.currentUser;
    const path = `uploads/${user.uid}/profile.png`;
    const storageRef = ref(this.storage, path);

    try {
      await uploadString(storageRef, cameraFile.base64String, 'base64');

      const imageUrl = await getDownloadURL(storageRef);

      const userDocRef = doc(this.firestore, `users/${user.uid}`);
      await setDoc(userDocRef, {
        imageUrl,
      });
      return true;
    } catch (e) {
      return null;
    }
  }
}

In the end, we should therefore see an entry inside Firestore with the unique user ID inside the path and the image stored for that user like in the image below.

ionic-firebase-firestore-image

Now let’s put that service to use in our page!

First, we subscribe to the getUserProfile() function as we will then get the new value whenever we change that image.

Besides that we add a logout function, and finally a function that calls the Capacitor camera plugin. The image result will be passed to our service which handles all the rest – we just need some loading and error handling in here again!

Therefore go ahead now and change the src/app/home/home.page.ts to:

import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
import { AlertController, LoadingController } from '@ionic/angular';
import { AuthService } from '../services/auth.service';
import { AvatarService } from '../services/avatar.service';

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

  constructor(
    private avatarService: AvatarService,
    private authService: AuthService,
    private router: Router,
    private loadingController: LoadingController,
    private alertController: AlertController
  ) {
    this.avatarService.getUserProfile().subscribe((data) => {
      this.profile = data;
    });
  }

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

  async changeImage() {
    const image = await Camera.getPhoto({
      quality: 90,
      allowEditing: false,
      resultType: CameraResultType.Base64,
      source: CameraSource.Photos, // Camera, Photos or Prompt!
    });

    if (image) {
      const loading = await this.loadingController.create();
      await loading.present();

      const result = await this.avatarService.uploadImage(image);
      loading.dismiss();

      if (!result) {
        const alert = await this.alertController.create({
          header: 'Upload failed',
          message: 'There was a problem uploading your avatar.',
          buttons: ['OK'],
        });
        await alert.present();
      }
    }
  }
}

We’re almost there!

Now we need a simple view to display either the user avatar image if it exists, or just a placeholder if we don’t have a user document (or avatar image) yet.

That’s done pretty easily by changing our src/app/home/home.page.html to:

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

<ion-content class="ion-padding">
  <div class="preview">
    <ion-avatar (click)="changeImage()">
      <img *ngIf="profile?.imageUrl; else placheolder_avatar;" [src]="profile.imageUrl" />
      <ng-template #placheolder_avatar>
        <div class="fallback">
          <p>Select avatar</p>
        </div>
      </ng-template>
    </ion-avatar>
  </div>
</ion-content>

To make everything centered and look a bit nicer, just put the following quickly into your src/app/home/home.page.scss:

ion-avatar {
  width: 128px;
  height: 128px;
}

.preview {
  margin-top: 50px;
  display: flex;
  justify-content: center;
}

.fallback {
  width: 128px;
  height: 128px;
  border-radius: 50%;
  background: #bfbfbf;

  display: flex;
  justify-content: center;
  align-items: center;
  font-weight: 500;
}

And BOOM – you are done and have the whole flow from user registration, login to uploading files as a user implemented with Ionic and Capacitor!

You can check if the image was really uploaded by also taking a look at the Storage tab of your Firebase project.

ionic-firebase-storage-files

If you want the preview to show up correctly in there, just supply the right metadata during the upload task, but the image will be displayed inside your app no matter what.

Native iOS and Android Changes

To make all of this also work nicely on your actual native apps, we need a few changes.

First, go ahead and add those platforms:

ionic build

ionic cap add ios
ionic cap add android

Because we are accessing the camera we also need to define the permissions for the native platforms, so let’s start with iOS and add the following permissions (with a good reason in a real app!) to your ios/App/App/Info.plist:

<key>NSCameraUsageDescription</key>
	<string>To capture images</string>
	<key>NSPhotoLibraryAddUsageDescription</key>
	<string>To add images</string>
	<key>NSPhotoLibraryUsageDescription</key>
	<string>To store images</string>

For Android we need to do the same. Therefore, bring up the android/app/src/main/AndroidManifest.xml and after the line that already sets the internet permission add two more lines:

<uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

Finally when I ran the app on my device, I just got a white screen of death.

There was a problem with the Firebase SDK and Capacitor, but there’s actually an easy fix.

We only need to change our src/app/app.module.ts and use the native authentication directly from the Firebase SDK when our app runs as a native app:

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

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

import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
import { initializeApp, provideFirebaseApp } from '@angular/fire/app';
import { environment } from '../environments/environment';
import { provideAuth, getAuth } from '@angular/fire/auth';
import { provideFirestore, getFirestore } from '@angular/fire/firestore';
import { provideStorage, getStorage } from '@angular/fire/storage';
import { Capacitor } from '@capacitor/core';
import { indexedDBLocalPersistence, initializeAuth } from 'firebase/auth';
import { getApp } from 'firebase/app';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [
    BrowserModule,
    IonicModule.forRoot(),
    AppRoutingModule,
    provideFirebaseApp(() => initializeApp(environment.firebase)),
    provideAuth(() => {
      if (Capacitor.isNativePlatform()) {
        return initializeAuth(getApp(), {
          persistence: indexedDBLocalPersistence,
        });
      } else {
        return getAuth();
      }
    }),
    provideFirestore(() => getFirestore()),
    provideStorage(() => getStorage()),
  ],
  providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
  bootstrap: [AppComponent],
})
export class AppModule {}

Because of the modular approach this change is super easy to add, and now you can also enjoy the Firebase app with upload on your iOS device!

Conclusion

Firebase remains one of the best choices as a cloud backend for your Ionic application if you want to quickly add features like user authentication, database or file upload.

For everyone more into SQL, you should also check out the rising star Supabase which offers already all essential functionality that Firebase has in an open source way.

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

The post Building an Ionic App with Firebase Authentication & File Upload using AngularFire 7 appeared first on Devdactic - Ionic Tutorials.

Building the YouTube UI with Ionic

$
0
0

We are once again building a popular UI with Ionic, and this time it’s the YouTube home video feed!

You like to see popular apps built with Ionic? Check out my latest eBook Built with Ionic for even more real world app examples!

Today we will focus only on the the tab bar setup and the home page of the YouTube app – leave a comment if you would like to see an example of the video details page as well!

ionic-youtube-ui

Additionally we need to create a directive that scrolls our header out or in while also moving the content, so we got quite a challenge today!

Starting the YouTube App with Ionic

To get started we generate a new Ionic app using the tabs layout and generate a few additional pages that we will need. On top of that we generate a module and directive which we will need for our header animation in the end!

ionic start youtube tabs --type=angular

ionic g page tab4
ionic g page sheet

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

Additionally I created some mock data that you can download from Github and place inside the assets folder of your new app!

Because we want to load some local JSON data with Angular, we need to add two properties to our tsconfig.json:

"compilerOptions": {
    "resolveJsonModule": true,
    "allowSyntheticDefaultImports": true
    ...
}

To apply the Ionic typical styling for the application we can also change the defaul colors inside the src/theme/variables.scss now:

:root {
  --ion-color-primary: #000000;
  --ion-color-primary-rgb: 0, 0, 0;
  --ion-color-primary-contrast: #ffffff;
  --ion-color-primary-contrast-rgb: 255, 255, 255;
  --ion-color-primary-shade: #000000;
  --ion-color-primary-tint: #1a1a1a;

  --ion-color-secondary: #ff0000;
  --ion-color-secondary-rgb: 255, 0, 0;
  --ion-color-secondary-contrast: #ffffff;
  --ion-color-secondary-contrast-rgb: 255, 255, 255;
  --ion-color-secondary-shade: #e00000;
  --ion-color-secondary-tint: #ff1a1a;

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

  --ion-color-success: #2dd36f;
  --ion-color-success-rgb: 45, 211, 111;
  --ion-color-success-contrast: #000000;
  --ion-color-success-contrast-rgb: 0, 0, 0;
  --ion-color-success-shade: #28ba62;
  --ion-color-success-tint: #42d77d;

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

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

  --ion-color-medium: #92949c;
  --ion-color-medium-rgb: 146, 148, 156;
  --ion-color-medium-contrast: #000000;
  --ion-color-medium-contrast-rgb: 0, 0, 0;
  --ion-color-medium-shade: #808289;
  --ion-color-medium-tint: #9d9fa6;

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

We got the basics in place – let’s get started with the custom tab bar!

Building the Tab Bar

The YouTube app comes with a slightly different tab bar as. we have 5 buttons, of which 4 lead to a different tab and the button in the center calls a different function.

ionic-yt-tabbar

We can move in that direction by first of all integrating the tab we generated into the src/app/tabs/tabs-routing.module.ts like this:

const routes: Routes = [
  {
    path: 'tabs',
    component: TabsPage,
    children: [
      {
        path: 'tab1',
        loadChildren: () =>
          import('../tab1/tab1.module').then((m) => m.Tab1PageModule),
      },
      {
        path: 'tab2',
        loadChildren: () =>
          import('../tab2/tab2.module').then((m) => m.Tab2PageModule),
      },
      {
        path: 'tab3',
        loadChildren: () =>
          import('../tab3/tab3.module').then((m) => m.Tab3PageModule),
      },
      {
        path: 'tab4',
        loadChildren: () =>
          import('../tab4/tab4.module').then((m) => m.Tab4PageModule),
      },
      {
        path: '',
        redirectTo: '/tabs/tab1',
        pathMatch: 'full',
      },
    ],
  },
  {
    path: '',
    redirectTo: '/tabs/tab1',
    pathMatch: 'full',
  },
];

Now we can add two new buttons to the tab bar setup, and the button in the center won’t be linked to a tab but instead simply come with a click handler to trigger the bottom sheet later!

Besides that we are adding a template reference to every tab, and then use the selected property of a button to conditionally show different icons.

You will notice that in many apps the icons change from outline to a filled style when selected, and that’s what we are replicating here.

Go ahead now and change the src/app/tabs/tabs.page.html to:

<ion-tabs>
  <ion-tab-bar slot="bottom">
    <ion-tab-button tab="tab1" #tab1>
      <ion-icon [name]="tab1.selected ? 'home' : 'home-outline'"></ion-icon>
      <ion-label>Home</ion-label>
    </ion-tab-button>

    <ion-tab-button tab="tab2" #tab2>
      <ion-icon [name]="tab2.selected ? 'videocam' : 'videocam-outline'"></ion-icon>
      <ion-label>Shorts</ion-label>
    </ion-tab-button>

    <ion-tab-button (click)="add()">
      <ion-icon name="add-circle-outline"></ion-icon>
    </ion-tab-button>

    <ion-tab-button tab="tab3" #tab3>
      <ion-icon [name]="tab3.selected ? 'albums' : 'albums-outline'"></ion-icon>
      <ion-label>Subscriptions</ion-label>
    </ion-tab-button>

    <ion-tab-button tab="tab4" #tab4>
      <ion-icon [name]="tab4.selected ? 'library' : 'library-outline'"></ion-icon>
      <ion-label>Library</ion-label>
    </ion-tab-button>
  </ion-tab-bar>
</ion-tabs>

The bar should also come with a white background, and we can even fine tune the color of the icons and the stroke width of an Ionicon by changing these things within our src/app/tabs/tabs.page.scss like this:

ion-tab-bar {
  --background: #fff;
}

ion-tab-button {
  --color: var(--ion-color-primary);
  ion-icon {
    --ionicon-stroke-width: 16px;
  }
}

To open the modal in a bottom sheet way we just need to pass in the breakpoints and initialBreakpoint properties and Ionic will do the magic, so let’s display the modal on click within our src/app/tabs/tabs.page.ts:

import { Component } from '@angular/core';
import { ModalController } from '@ionic/angular';
import { SheetPage } from '../sheet/sheet.page';

@Component({
  selector: 'app-tabs',
  templateUrl: 'tabs.page.html',
  styleUrls: ['tabs.page.scss'],
})
export class TabsPage {
  constructor(private modalCtrl: ModalController) {}

  async add() {
    const modal = await this.modalCtrl.create({
      component: SheetPage,
      breakpoints: [0.5],
      initialBreakpoint: 0.5,
      handle: false,
    });

    await modal.present();
  }
}

Now within that sheet we just want to display a few items with icon, but to make the life inside the template easier we can define those different items simply as an array inside our modal page at src/app/sheet/sheet.page.ts:

import { Component, OnInit } from '@angular/core';
import { ModalController } from '@ionic/angular';

@Component({
  selector: 'app-sheet',
  templateUrl: './sheet.page.html',
  styleUrls: ['./sheet.page.scss'],
})
export class SheetPage implements OnInit {
  items = [
    {
      text: 'Create a Short',
      icon: 'videocam-outline',
    },
    {
      text: 'Upload a video',
      icon: 'push-outline',
    },
    {
      text: 'Go live',
      icon: 'radio-outline',
    },
    {
      text: 'Add to your story',
      icon: 'add-circle-outline',
    },
    {
      text: 'Create a post',
      icon: 'create-outline',
    },
  ];

  constructor(private modalCtrl: ModalController) {}

  ngOnInit() {}

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

The modal page itself comes with some custom styling so the header doesn’t look to much like a header and more like a title. We achieve a left hand position by simply setting the mode of the title to md because the title is always on the left on Android!

Besides that we just got the iteration of items, so let’s change the src/app/sheet/sheet.page.html to:

<ion-header class="ion-no-border">
  <ion-toolbar color="light">
    <ion-title mode="md">Create</ion-title>
    <ion-buttons slot="end">
      <ion-button (click)="dismiss()" fill="clear">
        <ion-icon name="close" slot="icon-only"></ion-icon>
      </ion-button>
    </ion-buttons>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-item *ngFor="let item of items" lines="none">
    <ion-icon [name]="item.icon" slot="start"></ion-icon>
    <ion-label> {{ item.text }} </ion-label>
  </ion-item>
</ion-content>

Finally those items need more room to breath – therefore we add some more padding and margin and apply a nice round background to our icons inside the src/app/sheet/sheet.page.scss:

ion-item {
  margin-top: 10px;
  margin-bottom: 10px;
  ion-icon {
    background: #f2f2f2;
    padding: 10px;
    border-radius: 50%;
    --ionicon-stroke-width: 16px;
  }
}

ionic-yt-bottom-sheet

And with that our YouTube tab bar setup including custom button to trigger a bottom drawer component is already done!

Basic Home Screen

Now we can focus on the home screen, for which we want to achieve a few different things:

  • Create the header with buttons and additional scrollable segments row
  • Show skeleton views while the (fake) data is loading
  • Build the video feed list

First of all we can setup some data for the segments and the video items, which we create right here or import from our dummy JSON data. On top of that we can add functionality that will only set this data after 1.5 seconds so we can actually see our loading skeletons – in a normal app you would make an API call and display them while you are loading of course!

Additionally we can add a function to select on segment from our items, and a fake function to complete an Ionic refresher event after a second.

Now bring up the src/app/tab1/tab1.page.ts and change it to:

import { Component } from '@angular/core';
import { RefresherCustomEvent } from '@ionic/angular';
import homeData from '../../assets/data/home.json';

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

  constructor() {
    this.segments = [
      'All',
      'New to you',
      'Gaming',
      'Sitcoms',
      'Computer program',
      'Documentary',
      'Music',
    ].map((val) => ({
      title: val,
      selected: false,
    }));
    setTimeout(() => {
      this.selectSegment(0);
      this.videos = homeData;
    }, 1000);
  }

  doRefresh(event: RefresherCustomEvent) {
    setTimeout(() => {
      event.target.complete();
    }, 1500);
  }

  selectSegment(i) {
    this.segments.map((item) => (item.selected = false));
    this.segments[i].selected = true;
  }
}

Now we can start with the template and craft the header by simply using two ion-toolbar elements inside the header below each other!

The first holds the logo and some small buttons, while the second toolbar holds our custom segment. We create it like this because customising the Ionic segment would take mostly the same time if not longer, and it’s quite easy to create a horizontal scrollable segment view.

Those segment buttons get a conditional class based on the selected property and trigger the function we created before.

Therefore continue with the src/app/tab1/tab1.page.html and change the header area to:

<ion-header>
  <ion-toolbar color="light">
    <img src="./assets/data/logo.png" width="100px" />

    <ion-buttons slot="end">
      <ion-button size="small"> <ion-icon name="tv-outline"></ion-icon> </ion-button>
      <ion-button size="small"> <ion-icon name="notifications-outline"></ion-icon> </ion-button>
      <ion-button size="small"> <ion-icon name="search-outline"></ion-icon> </ion-button>
      <ion-button size="small"> <ion-icon name="person-circle-outline"></ion-icon> </ion-button>
    </ion-buttons>
  </ion-toolbar>
  <ion-toolbar color="light">
    <div class="button-bar">
      <ion-button
        size="small"
        shape="round"
        *ngFor="let seg of segments; let i = index;"
        [ngClass]="{'activated': seg.selected, 'inactive': !seg.selected}"
        (click)="selectSegment(i)"
      >
        {{ seg.title }}
      </ion-button>
    </div>
  </ion-toolbar>
</ion-header>

Add this point it’s not a horizontal list, but we can make it a flex layout and scrollable quite fast by adding the following to our src/app/tab1/tab1.page.scss:

.button-bar {
  display: flex;
  overflow-x: scroll;
}

::-webkit-scrollbar {
  display: none;
}

.activated {
  --background: #606060;
  --color: #fff;
}

.inactive {
  --background: #edefef;
  --color: var(--ion-color-primary);
}

Additionally this hides the scrollbar which you can see in the preview normally!

Now the official YouTube app displays some placeholder images while its loading data, and we can mimic the same behaviour using the ion-skeleton-text element like this when we don’t have any video data in our array yet:

<ion-content>
  <div *ngIf="!videos.length">
    <div *ngFor="let i of [].constructor(4)" class="ion-margin-bottom">
      <ion-skeleton-text animated style="width: 100%; height: 30vh !important"></ion-skeleton-text>
      <ion-skeleton-text style="width: 75%; height: 20px !important; margin: 10px"></ion-skeleton-text>
      <ion-skeleton-text style="width: 40%; height: 20px !important; margin: 10px"></ion-skeleton-text>
    </div>
  </div>
</ion-content>

This will now show for a second because we used setTimeout() but I recommend you remove that delay while working on the video list for now.

Our next step is the list of video elements for which we need:

  • The preview image
  • The channel image
  • The title and author name
  • The duration floating above the video

On top of that we can add a simple refresher that calls the function we added before when we pull it!

Now we can generate those video items and use the Ionic grid layout to setup a row and different columns for the video information below the actual poster image.

The duration is on top of all those things, but we will have to reposition it with CSS to make it appear above the image in the next step.

For now you can add the following below the previous skeleton list:

<ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
  <ion-refresher-content refreshingSpinner="crescent" pullingIcon="refresh-outline"></ion-refresher-content>
</ion-refresher>

<div *ngFor="let video of videos" class="video ion-margin-bottom">
  <div class="duration">{{ video.duration * 1000 | date:'mm:ss' }}</div>

  <img [src]="'./assets/data/' + video.id + '.jpeg'" />
  <ion-row>
    <ion-col size="2" class="ion-align-items-center">
      <ion-avatar>
        <ion-img [src]="'./assets/data/' + video.id + '-channel.jpeg'"></ion-img>
      </ion-avatar>
    </ion-col>
    <ion-col size="8">
      <ion-text>{{ video.title }}</ion-text>
      <div>
        <ion-text color="medium" style="font-size: small"> {{ video.author }} · {{ video.views }} views · {{ video.ago }} ago </ion-text>
      </div>
    </ion-col>
    <ion-col size="2" class="ion-text-right">
      <ion-button size="small" fill="clear"><ion-icon name="ellipsis-vertical"></ion-icon></ion-button>
    </ion-col>
  </ion-row>
</div>

In reality those images will actually start playing, something I have implemented inside the Netflix app of the Built with Ionic book!

If we simply give the duration an absolute position, all duration elements would be stacked in one place as they use the position relative to the whole view.

To overcome this, we can set the position of the parent .video element to relative instead, which will make the duration start their position calculation based on the actual border of the parent element.

This is a really simple yet powerful construct to understand in order to position items correctly.

With that information go ahead and add the following to the src/app/tab1/tab1.page.scss:

.video {
  position: relative;
}

.duration {
  position: absolute;
  right: 15px;
  top: 175px;
  color: #fff;
  font-weight: 500;
  background: #000;
  padding: 4px;
}

ionic-yt-home-feed

And now we already got a fully functional home feed with videos and the right layout for all elements. including the previous header area we created.

Animated Header Bar

The one more thing of this tutorial is to implement the functionality to hide the header on scroll, and bring it back in when the user scrolls in the opposite direction again.

For this we will borrow some code from my previous Ionic Gmail clone and extend the code as we need even more functionality.

The idea is actually simple:

  • We listen to the scroll events of our content
  • We change the position of our header to move it out or in while scrolling

Additionally we also need to take care of the ion-content element as we need to reposition it as well. The best way to see how all of this comes together is actually watching the video (at least the important part) that’s linked at the bottom of this tutorial!

But let’s begin easy by adding the generated directive from the beginning to the src/app/directives/shared-directives.module.ts and making sure it is exported:

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

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

To use our directive we also need to import this now in our src/app/tab1/tab1.module.ts like this:

import { IonicModule } from '@ionic/angular';
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Tab1Page } from './tab1.page';
import { ExploreContainerComponentModule } from '../explore-container/explore-container.module';

import { Tab1PageRoutingModule } from './tab1-routing.module';
import { SharedDirectivesModule } from '../directives/shared-directives.module';

@NgModule({
  imports: [
    IonicModule,
    CommonModule,
    FormsModule,
    ExploreContainerComponentModule,
    Tab1PageRoutingModule,
    SharedDirectivesModule,
  ],
  declarations: [Tab1Page],
})
export class Tab1PageModule {}

Although we haven’t created the directive yet you can already apply it within the src/app/tab1/tab1.page.html and add a template reference to the header so we get access to that element later as well:

<ion-header #header>
</ion-header>

<ion-content [appHideHeader]="header" scrollEvents="true">
</ion-content>

The logic is based on some calculations and ideas:

  • We need to store the last Y position within saveY to notice in which direction we scroll
  • When we notice that we changed directions, we store that exact position inside previousY so we can use it for our calculation
  • We will change the top and opacity properties of our search bar
  • The scrollDistance is the value at which the element will be gone completely, which is different for iOS and Android../li>

On top of that we need to calculate the safe area at the top, because otherwise our component would still be slightly visible sometimes.

To achieve this, we can get the value of a CSS variable inside the ngAfterViewInit() by accessing the document and using getComputedStyle().

At that point we also set the ion-content to an absolute position with the right distance from top, as we can then later reposition it easily inside the logic that calculates the new value for the header and content element when we scroll.

I tried my best to add comments in all places to understand correctly what is calculated, so go ahead and change your src/app/directives/hide-header.directive.ts to this:

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

enum Direction {
  downup = 1,
  down = 0,
}
@Directive({
  selector: '[appHideHeader]',
})
export class HideHeaderDirective implements AfterViewInit {
  @Input('appHideHeader') header: any;
  content: any;

  scrollDistance = isPlatform('ios') ? 88 : 112;
  previousY = 0;
  direction: Direction = Direction.down;
  saveY = 0;

  constructor(
    private renderer: Renderer2,
    private domCtrl: DomController,
    private elRef: ElementRef,
    @Inject(DOCUMENT) private document: Document
  ) {}

  @HostListener('ionScroll', ['$event']) onContentScroll($event: any) {
    // Skip some events that create ugly glitches
    if ($event.detail.currentY <= 0 || $event.detail.currentY === this.saveY) {
      return;
    }

    const scrollTop: number = $event.detail.scrollTop;
    let newDirection = Direction.down;

    // Calculate the distance from top based on the previousY
    // which is set when we change directions
    let newPosition = -scrollTop + this.previousY;

    // We are scrolling downup the page
    // In this case we need to reduce the position first
    // to prevent it jumping from -50 to 0
    if (this.saveY > $event.detail.currentY) {
      newDirection = Direction.downup;
      newPosition -= this.scrollDistance;
    }

    // Make our maximum scroll distance the end of the range
    if (newPosition < -this.scrollDistance) {
      newPosition = -this.scrollDistance;
    }

    const contentPosition = this.scrollDistance + newPosition;

    // Move and set the opacity of our element
    this.domCtrl.write(() => {
      this.renderer.setStyle(
        this.header,
        'top',
        Math.min(0, newPosition) + 'px'
      );

      this.renderer.setStyle(
        this.content,
        'top',
        Math.min(this.scrollDistance, contentPosition) + 'px'
      );
    });

    // Store the current Y value to see in which direction we scroll
    this.saveY = $event.detail.currentY;

    // If the direction changed, store the point of change for calculation
    if (newDirection !== this.direction) {
      this.direction = newDirection;
      this.previousY = scrollTop;
    }
  }

  ngAfterViewInit(): void {
    this.header = this.header.el;
    this.content = this.elRef.nativeElement;

    this.renderer.setStyle(this.content, 'position', `absolute`);
    this.renderer.setStyle(this.content, 'top', `${this.scrollDistance}px`);

    // Add the safe area top to completely fade out the header
    const safeArea = getComputedStyle(
      this.document.documentElement
    ).getPropertyValue('--ion-safe-area-top');

    const safeAreaValue = +safeArea.split('px')[0];
    this.scrollDistance = this.scrollDistance + safeAreaValue;
  }
}

All this results in a smooth hide and appear whenever we scroll our apps view – and you can easily reuse this directive in your own app as you just need to pass in the reference to the header element and the component will do the rest!

Teardown

We’ve done it and cloned another popular UI, but we haven’t finished the details page. If you are also interested in that UI and the gestures around the video player, leave a comment below.

And of course if you got a request for a future tutorial in the Built with Ionic series just let me know!

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

The post Building the YouTube UI with Ionic appeared first on Devdactic - Ionic Tutorials.

Celebrating 5 Years Ionic Academy

$
0
0

Exactly today the Ionic Academy started 5 years ago – and to kick off the anniversary celebration week I’m offering the initial monthly discount from 5 years ago again!

Only until the end of March 2022 you can grab the monthly Ionic Academy membership for just $19 instead of the usual $25:

Get the monthly Ionic Academy Membership for 25% off

That means you can get access to everything within the Academy for just $19 per month – that’s like paying 63 cents per day for Ionic support 🤯

I’ve also just released a video covering the evolution of the Ionic Academy and where it stands now (and in the future) so if you’ve been a member in the past, go check out what changed during those 5 years!

I’m beyond grateful for over 5000 developers that have gone through the Ionic Academy during that time, and I still love helping each new member just as much as 5 years ago.

Looking forward to seeing you inside soon!

The post Celebrating 5 Years Ionic Academy appeared first on Devdactic - Ionic Tutorials.

How to Secure your App with Ionic Identity Vault

$
0
0

If you are serious about your Ionic app development and want the most secure functionality when working with user data and APIs, you should take a closer look at Ionic Identity Vault.

Ionic Identity Vault is basically an all-in-one solution for managing sensitive data on your device.

  • Want to protect data when the app goes to background?
  • Want to easily show native biometric authentication dialogs?
  • Protect the privacy of your app when put to background?
  • Automatically log access to private data after time in background?

All of these things can be accomplished with other solutions, but Ionic Identity Vault combines 12 APIs into one plugin, so you get the best possible security for your enterprise data.

I could test Ionic Identity Vault for free as an Ionic community expert, but otherwise it’s a paid plugin for which you need a dedicated subscription.

ionic-identity-vault

The idea is basically to let Ionic do the hard work while you can rest assured that your data is protected in the best possible way.

Let’s create a simple Ionic app and test drive some Ionic Identity Vault functionality!

Starting a new Ionic App

We can start as always with a blank Ionic app and add one additional page and service:

ionic start identityApp blank --type=angular
cd ./identityApp

ionic g page login 
ionic generate service services/authVault

Before you add your native iOS and Android project you should also define your app ID inside the capacitor.config.json:

{
  "appId": "com.devdactic.identityapp",
  "appName": "Devdactic Identity",
  "webDir": "www",
  "bundledWebRuntime": false
}

Finally we can also change our routing so we start on the dummy login page first. For this, bring up the src/app/app-routing.module.ts and change it to:

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

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

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

At this point you can also create the first build of your app and add the native platforms:

ionic build
ionic cap add android
ionic cap add ios

Now we can integrate Identity Vault into our app.

Ionic Identity Vault Integration

To install Identity Vault you need to use your enterprise key from your Ionic dashboard, which you need to assign to one of your apps first.

ionic-identity-vault-key

So before installing the actual Identity Vault package, run the register function and insert your Native Plugin Key if you never did this before:

# If you never did this
ionic enterprise register

npm install @ionic-enterprise/identity-vault

To use the biometric functionality we now also need to add the permission for iOS inside the ios/App/App/Info.plist:

<key>NSFaceIDUsageDescription</key>
<string>Use Face ID to authenticate yourself and login</string>

Now we are ready to use the package and integrate it in our app flow!

Creating a Vault Service

We begin by creating a service that interacts with the actual vault, so we have all data storing and locking mechanism in one central service and not scattered around our pages.

To get started we need a configuration to initialise our vault. In here we define how the vault behaves:

  • The key identifies the vault, and you could have multiple vaults with different keys
  • The type defines how your data is protected and stored on the device
  • The deviceSecurityType defines which authentication is used to unlock the vault

Additionally there are some more settings which are pretty easy to understand, and you can find all possible values in the Identity Vault docs as well!

On top of that we keep track of the state or session with another variable, so we keep the data in memory as well. This is optional, but helps to make working with the stored data easier as you don’t need to make a call to retrieve a value from the vault all the time while the app is unlocked!

Since Identity Vault 5 there’s also a great fallback for the web which doesn’t exactly work like the native implementation, but it allows us to build the app more easily on the browser. For this, we create the right type of Vault in our constructor with a simple check.

Let’s begin our service now by changing the src/app/servies/auth-vault.service.ts to:

import { Injectable, NgZone } from '@angular/core';
import { Capacitor } from '@capacitor/core';
import {
  BrowserVault,
  Device,
  DeviceSecurityType,
  IdentityVaultConfig,
  Vault,
  VaultType,
} from '@ionic-enterprise/identity-vault';
import { NavController } from '@ionic/angular';

const DATA_KEY = 'mydata';
const TOKEN_KEY = 'mytoken';

@Injectable({
  providedIn: 'root',
})
export class AuthVaultService {
  vault: Vault | BrowserVault;

  config: IdentityVaultConfig = {
    key: 'com.devdactic.myvault',
    type: VaultType.DeviceSecurity,
    deviceSecurityType: DeviceSecurityType.Both,
    lockAfterBackgrounded: 100,
    shouldClearVaultAfterTooManyFailedAttempts: true,
    customPasscodeInvalidUnlockAttempts: 2,
    unlockVaultOnLoad: false,
  };

  state = {
    isLocked: false,
    privateData: '',
  };

  constructor(private ngZone: NgZone, private navCtrl: NavController) {
    this.vault =
      Capacitor.getPlatform() === 'web'
        ? new BrowserVault(this.config)
        : new Vault(this.config);
    this.init();
  }
}

After this setup we need to define some more things, especially how the vault behaves when we lock or unlock it later.

Those are the points where you clear the stored data in memory, or set the value by using the getValue() method of the vault to read stored data, just like you would do with Ionic Storage!

We also got access to a special Device object from the Identity Vault package through which we can trigger or define specific native functionalities. In our case, we define that whenever our app is put in the background, it will show the splash screen instead of a screenshot from the app.

This is another level of privacy so you can’t see the content of the app when going through the app windows on your device!

Continue with the service and add the following function now:

async init() {
  this.state.isLocked = await this.vault.isLocked();

  Device.setHideScreenOnBackground(true);

  // Runs when the vault is locked
  this.vault.onLock(() => {
    this.ngZone.run(() => {
      this.state.isLocked = true;
      this.state.privateData = undefined;
    });
  });

  // Runs when the vault is unlocked
  this.vault.onUnlock(() => {
    this.ngZone.run(async () => {
      this.state.isLocked = false;
      this.state.privateData = await this.vault.getValue(DATA_KEY);
    });
  });
}

Finally we need some more helper functionalities in order to test our vault from our pages.

We can directly call the lock() or unlock() methods, which in response will trigger the events we defined before to clear or read the data.

On a possible login we will also directly store a token – one of the most common operations you would use vault for as this makes sure any kind of token that was issued by your server is stored safely.

By using isEmpty() we can check if there are any values in the vault even without authentication, which will be used later to automatically log in users if there is any stored data (which might require a bit more logic to check the value in a real world scenario).

If we log out the user, we would also clear() all data from the vault and our state object so there is no piece of sensitive information left anywhere when the user really ends the session!

Now finish our service by adding these additional functions:

lockVault() {
  this.vault.lock();
}

unlockVault() {
  this.vault.unlock();
}

async login() {
  // Store your session token upon successful login
  return this.vault.setValue(TOKEN_KEY, 'JWT-value-1123');
}

async setPrivateData(data: string) {
  await this.vault.setValue(DATA_KEY, data);
  this.state.privateData = data;
}

async isEmpty() {
  return this.vault.isEmpty();
}

async logout() {
  // Remove all stored data
  this.state.privateData = undefined;
  await this.vault.clear();

  this.navCtrl.navigateRoot('/', { replaceUrl: true });
}

With all of this in place we can finally build our pages and run through the different functions.

Creating the Dummy Login

We are not using a real API in this example, but you could easily imagine another call to your server with your users credentials upon which your app receives some kind of token.

This token would be stored inside the vault, and if we enter the login page and notice that there is data inside the vault, we can directly try to unlock the vault which will trigger the defined biometric/passcode authentication and skip to the inside area of our app.

Again, this is a bit simplified as you might have other information inside the vault next to the actual token for your API, or you might have to get a new access token based on the refresh token in your app.

Bring up the src/app/login/login.page.ts and insert our dummy login functions:

import { Component, OnInit } from '@angular/core';
import { NavController } from '@ionic/angular';
import { AuthVaultService } from '../servies/auth-vault.service';

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

  constructor(
    private authVaultService: AuthVaultService,
    private navCtrl: NavController
  ) {}

  async ngOnInit() {}

  async ionViewWillEnter() {
    // Check if we have data in our vault and skip the login
    const isEmpty = await this.authVaultService.isEmpty();

    if (!isEmpty) {
      await this.authVaultService.unlockVault();
      this.navCtrl.navigateForward('/home', { replaceUrl: true });
    }
  }

  async login() {
    await this.authVaultService.login();
    this.navCtrl.navigateForward('/home', { replaceUrl: true });
  }
}

The template for this page will be very minimal so we can just trigger the login, the fields are actually not really necessary in our dummy.
Anyway, open the src/app/login/login.page.html and change it to:

<ion-header>
  <ion-toolbar>
    <ion-title>Login</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-item>
    <ion-label position="floating">E-Mail</ion-label>
    <ion-input type="email" [(ngModel)]="state.email"></ion-input>
  </ion-item>
  <ion-item>
    <ion-label position="floating">Password</ion-label>
    <ion-input type="password" [(ngModel)]="state.password"></ion-input>
  </ion-item>
  <ion-button (click)="login()" expand="full">Login</ion-button>
</ion-content>

Now we can already move to the inside area, and here we want to interact a bit more with our vault.

Working with identity Vault

At this point we want to show the secret data that a user might have inside the store, so we create a connection to the state object of our service and display the values which it contains in our template next.

Besides that we just need functions to call the according methods of our service, so let’s prepare the src/app/home/home.page.ts like this:

import { Component } from '@angular/core';
import { AuthVaultService } from '../servies/auth-vault.service';

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

  constructor(private authVaultService: AuthVaultService) {
    this.state = this.authVaultService.state;
  }

  async logout() {
    await this.authVaultService.logout();
  }

  savePrivateData() {
    this.authVaultService.setPrivateData(this.myInput);
  }

  lockVault() {
    this.authVaultService.lockVault();
  }

  unlockVault() {
    this.authVaultService.unlockVault();
  }
}

Now we need to react to the isLocked state of our service, which is updated whenever the vault is locked or unlocked.

Therefore we display either a lock or an unlock button first of all, which will help us to unlock the vault if it’s currently locked.

We could also define an automatic unlock behaviour by setting the unlockVaultOnLoad flag inside the vault config to true instead!

Below that we display a little input field so we can type some data and then store it away in our vault, plus a card that shows the current value of that field from the state object.

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

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

<ion-content>
  <ion-button
    expand="full"
    (click)="unlockVault()"
    *ngIf="state.isLocked"
    color="success"
  >
    <ion-icon name="lock-open-outline" slot="start"></ion-icon>
    Unlock vault</ion-button
  >
  <ion-button
    expand="full"
    (click)="lockVault()"
    *ngIf="!state.isLocked"
    color="warning"
  >
    <ion-icon name="lock-closed-outline" slot="start"></ion-icon>
    Lock vault</ion-button
  >

  <div *ngIf="!state.isLocked">
    <ion-item>
      <ion-label position="floating">Private</ion-label>
      <ion-textarea [(ngModel)]="myInput"></ion-textarea>
    </ion-item>
    <ion-button expand="full" (click)="savePrivateData()">Save data</ion-button>

    <ion-card>
      <ion-card-content> {{ state.privateData }}</ion-card-content>
    </ion-card>
  </div>
</ion-content>

Now we got our simply vault in place and we can play around with it to see how the page and app reacts to locking the vault, putting the app in the background for some time or even killing the app!

Teardown

Ionic Identity Vault is a great solution that combines a ton of different APIs and plugins to make storing secure and sensitive data inside your Ionic application a breeze.

However, it comes with a decent price so it might only appeal to enterprise customers at this point, but if you care about the privacy of your users and API, investing this money should be a no brainer.

In the end, Identity Vault will make your life a lot easier, protect your data in the best possible way and do all of that with a simple API so you don’t need to piece together different plugins and somehow build your own solution.

You can also see a detailed explanation of this tutorial in the video below!

The post How to Secure your App with Ionic Identity Vault appeared first on Devdactic - Ionic Tutorials.

How to Write Unit Tests for your Ionic Angular App

$
0
0

Did you ever wonder what the *.spec file that is automatically generated for your pages and services is useful for? Then this tutorial is exactly for you!

The spec file inside your Angular project describes test cases, more specific unit tests for specific functionalities (“units”) of your code.

Running the tests is as easy as writing one command, but writing the tests looks a bit different and requires some general knowledge.

In our Ionic Angular app, everything is set up automatically to use Jasmine as a behaviour driven testing framework that gives us the tool to write easy test cases.

On top of that our tests are executed with the help of Karma, a test runner that runs our defined cases inside a browser window.

Why Unit Tests?

We have one spec file next to every page, component, service or whatever you generate with the Ionic CLI.

The reason is simple: We want to test a specific piece of code within a unit test, and not how our whole system operates!

That means, we should test the smallest possible unit (usually a function) within unit tests, which in the end results in a code coverage percentage that describes how much of our code is covered in unit tests.

In the end, this also means you can rely on those functions to be correct – or if you want to use Test Driven Development (TDD) you could come up with the tests first to describe the behaviour of your app, and then implement the actual code.

However you approach it, writing unit tests in your Ionic Angular application makes your code less prone to errors in the future when you change it, and removes any guessing about whether it works or not.

Enough of the theory, let’s dive into some code!

Creating an Ionic App for testing

We start with a blank new Ionic app and add a few services and a page so we can take a look at different test cases:

ionic start devdacticTests blank --type=angular
cd ./devdacticTests

ionic g page list
ionic g service services/api
ionic g service services/data
ionic g service services/products

npm install @capacitor/storage

I’ve also installed the Capacitor storage plugin so we can test a real world scenario with dependencies to another package.

Now you can already run the tests, which will automatically update when you change your code:

npm test

This will run the test script of your package.json and open a browser when ready!

ionic-jasmine-tests

We will go through different cases and increase the difficulty along the way while adding the necessary code that we can test.

Testing a Simple Service

The easiest unit test is definitely for a service, as by default the service functionalities usually have a defined scope.

Begin by creating a simple dummy service like this inside the src/app/services/data.service.ts:

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

@Injectable({
  providedIn: 'root',
})
export class DataService {
  constructor() {}

  // Basic Testing
  getTodos(): any[] {
    const result = JSON.parse(localStorage.getItem('todos'));
    return result || [];
  }
}

We can now test whether the function returns the right elements by changing local storage from our test cases.

Before we get into the actual cases, we need to understand the Angular TestBed: This is almost like a ngModule, but this one is only for testing like a fake module.

We create this module beforeEach so before every test case runs, and we call the inject function to add the service or class we want to test. Later inside pages this will come with some more settings, but for a single service that’s all we need at this point.

Test cases are written almost as you would speak: it should do something and we expect a certain result.

Let’s go ahead and change the src/app/services/data.service.spec.ts to this now:

import { TestBed } from '@angular/core/testing';
import { DataService } from './data.service';

describe('DataService', () => {
  let service: DataService;

  beforeEach(() => {
    TestBed.configureTestingModule({});
    service = TestBed.inject(DataService);
  });

  afterEach(() => {
    service = null;
    localStorage.removeItem('todos');
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  it('return an empty array', () => {
    expect(service.getTodos()).toEqual([]);
  });

  it('return an array with one object', () => {
    const arr = ['First Todo'];
    localStorage.setItem('todos', JSON.stringify(arr));

    expect(service.getTodos()).toEqual(arr);
    expect(service.getTodos()).toHaveSize(1);
  });

  it('return the correct array size', () => {
    const arr = [1, 2, 3, 4, 5];
    localStorage.setItem('todos', JSON.stringify(arr));

    expect(service.getTodos()).toHaveSize(arr.length);
  });
});

We added three cases, in which we test the getTodos() of our service.

There are several Jasmine matchers and we have only used a few of them to compare the result that we get from the service to a value we expect.

At this point you should see the new cases inside your browser window (if you haven’t started the test command, go back to the beginning), and all of them should be green and just fine.

This is not what I recommend!

If your tests don’t fail in the first place, you can never be sure that you wrote them correctly. They could be green because you made a mistake and they don’t really work like they should. Therefore:

Always make your tests fail first, then add the expected value!

Testing a Service with Promises

The previous case was pretty easy with synchronous functions, but that’s very rarely the reality unless you develop a simple calculator for your CV.

Now we add some more dummy code to the src/app/services/api.service.ts so we can also test asynchronous operations:

import { Injectable } from '@angular/core';
import { Storage } from '@capacitor/storage';

@Injectable({
  providedIn: 'root',
})
export class ApiService {
  constructor() {}

  async getStoredTodos(): Promise<any[]> {
    const data = await Storage.get({ key: 'mytodos' });

    if (data.value && data.value !== '') {
      return JSON.parse(data.value);
    } else {
      return [];
    }
  }

  async addTodo(todo) {
    const todos = await this.getStoredTodos();
    todos.push(todo);
    return await Storage.set({ key: 'mytodos', value: JSON.stringify(todos) });
  }

  async removeTodo(index) {
    const todos = await this.getStoredTodos();
    todos.splice(index, 1);
    return await Storage.set({ key: 'mytodos', value: JSON.stringify(todos) });
  }
}

Again, just testing this service in isolation is quite easy as we can simply await the results of those calls just like we would do when we call them inside a page.

Go ahead and change the src/app/services/api.service.spec.ts to include some new test cases that handle a Promise:

import { TestBed } from '@angular/core/testing';

import { ApiService } from './api.service';
import { Storage } from '@capacitor/storage';

describe('ApiService', () => {
  let service: ApiService;

  beforeEach(async () => {
    TestBed.configureTestingModule({});
    service = TestBed.inject(ApiService);
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  afterEach(async () => {
    await Storage.clear();
    service = null;
  });

  it('should return an empty array', async () => {
    const value = await service.getStoredTodos();
    expect(value).toEqual([]);
  });

  it('should return the new item', async () => {
    await service.addTodo('buy milk');
    const updated = await service.getStoredTodos();
    expect(updated).toEqual(['buy milk']);
  });

  it('should remove an item', async () => {
    await service.addTodo('buy milk');
    await service.addTodo('buy coffee');
    await service.addTodo('buy ionic');

    const updated = await service.getStoredTodos();
    expect(updated).toEqual(['buy milk', 'buy coffee', 'buy ionic']);

    await service.removeTodo(1);

    const newValue = await service.getStoredTodos();
    expect(newValue).toEqual(['buy milk', 'buy ionic']);
  });
});

Again, it’s easy in this case without any dependencies or long running operations, but we already have a dependency to Capacitor Storage which does work fine, but imagine a usage of the camera – there is no camera when you test!

In those cases you could inject plugin mocks for different services to encapsulate the behaviour and make sure you are testing this specific function without outside dependencies!

Testing a Basic Ionic Page

Now we move on to testing an actual page, so let’s change our src/app/home/home.page.ts in order for it to have at least one function to test:

import { Component } from '@angular/core';
import { DataService } from '../services/data.service';

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

  constructor(private dataService: DataService) {}

  loadTodos() {
    this.todos = this.dataService.getTodos();
  }
}

The setup for the test is now a bit longer, but the default code already handles the injection into the TestBed for us.

We now also create a fixture element, which has a reference to both the class and the template!

Therefore we are able to extract the component from this fixture after injecting it with createComponent().

Our test cases itself are pretty much the same, as we simply call the function of the page and fake some values for storage.

Go ahead with the src/app/home/home.page.spec.ts and add this now:

import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';

import { HomePage } from './home.page';

describe('HomePage', () => {
  let component: HomePage;
  let fixture: ComponentFixture;

  beforeEach(waitForAsync(() => {
    TestBed.configureTestingModule({
      declarations: [HomePage],
      imports: [IonicModule.forRoot()],
    }).compileComponents();

    fixture = TestBed.createComponent(HomePage);
    component = fixture.componentInstance;
    fixture.detectChanges();
  }));

  afterEach(() => {
    localStorage.removeItem('todos');
    component = null;
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('get an empty array', () => {
    component.loadTodos();
    expect(component.todos).toEqual([]);
  });

  it('set an array with objects', () => {
    const arr = [1, 2, 3, 4, 5];
    localStorage.setItem('todos', JSON.stringify(arr));
    component.loadTodos();
    expect(component.todos).toEqual(arr);
    expect(component.todos).toHaveSize(arr.length);
  });
});

This is once again a very simplified test, and there’s something we need to be careful about:

We want to test the page, not the service – therefore we should in reality fake the behaviour of the service and the value that is returned.

And we can do this using a spy, but before we get into that let’s quickly venture almost into end to end testing…

Testing Pages with Ionic UI elements

As said before, we can access both the class and the template from our fixture element – that means we can also query view elements from our unit test!

To try this, let’s work on our second page and change the src/app/list/list.page.ts to this:

import { Component, OnInit } from '@angular/core';
import { ApiService } from '../services/api.service';

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

  constructor(private apiService: ApiService) {}

  ngOnInit() {
    this.loadStorageTodos();
  }

  async loadStorageTodos() {
    this.todos = await this.apiService.getStoredTodos();
  }
}

Additionally we create a very simple UI with a card for an empty list or an iteration of all the items like this inside the src/app/list/list.page.html:

<ion-header>
  <ion-toolbar>
    <ion-title>My List</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-card *ngIf="!todos.length">
    <ion-card-content> No todos found </ion-card-content>
  </ion-card>

  <ion-list *ngIf="todos.length > 0">
    <ion-item *ngFor="let t of todos">
      <ion-label>{{ t }}</ion-label>
    </ion-item>
  </ion-list>
</ion-content>

In our tests we can now access the debugElement of the fixture and run different queries against it to see if certain UI elements are present, or even which text exists inside them!

Let’s do this by changing our src/app/list/list.page.spec.ts to this:

import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { IonCard, IonicModule, IonItem } from '@ionic/angular';

import { ListPage } from './list.page';

describe('ListPage', () => {
  let component: ListPage;
  let fixture: ComponentFixture<ListPage>;

  beforeEach(waitForAsync(() => {
    TestBed.configureTestingModule({
      declarations: [ListPage],
      imports: [IonicModule.forRoot()],
    }).compileComponents();

    fixture = TestBed.createComponent(ListPage);
    component = fixture.componentInstance;
    fixture.detectChanges();
  }));

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('should show a card if we have no todos', () => {
    const el = fixture.debugElement.query(By.directive(IonCard));
    expect(el).toBeDefined();
    expect(el.nativeNode.textContent.trim()).toBe('No todos found');
  });

  it('should show todos after setting them', () => {
    const arr = [1, 2, 3, 4, 5];

    let el = fixture.debugElement.query(By.directive(IonCard));
    expect(el).toBeDefined();
    expect(el.nativeNode.textContent.trim()).toBe('No todos found');

    component.todos = arr;

    // Important!
    fixture.detectChanges();

    el = fixture.debugElement.query(By.directive(IonCard));
    expect(el).toBeNull();

    const items = fixture.debugElement.queryAll(By.directive(IonItem));
    expect(items.length).toBe(arr.length);
  });
});

And there’s again something to watch out for in our second test case: We need to trigger the change detection manually!

Normally the view of your app updates when the data changes, but that’s not the case inside a unit test.

In our case, we set the array of todos inside the page, and therefore expect that the view now shows a list of IonItem nodes.

However, this only happens after we call detectChanges() on the fixture, so be careful when you access any DOM elements like this.

Overall I don’t think you should have massive UI tests in your unit tests. You can test your Ionic app more easily using Cypress end to end tests!

Testing Pages with Spy

Now we are coming back to the idea from before that we should actually fake the return values of our service to minimize any external dependencies.

The idea is to create a spy for a specific function of our service, and define which result will be returned. When we now call the function of our page that uses the getStoredTodos() from a service, the test will actually use the spy instead of the real service!

That means, we don’t need to worry about the service dependency anymore at this point!

We continue with the testing file for our list from before and now take a look at three different ways to handle asynchronous code using a spy:

  • Use the Jasmine done() callback to end a Promise
  • Run our code inside the waitForAsync() zone and use whenStable()
  • Run our code inside the fakeAsync() zone and manually trigger a tick()

Let’s see the code for this first of all by changing the src/app/list/list.page.spec.ts to this (I simply removed the UI tests):

import {
  ComponentFixture,
  fakeAsync,
  TestBed,
  tick,
  waitForAsync,
} from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { IonCard, IonicModule, IonItem } from '@ionic/angular';
import { ApiService } from '../services/api.service';
import { ListPage } from './list.page';

describe('ListPage', () => {
  let component: ListPage;
  let fixture: ComponentFixture<ListPage>;
  let service: ApiService;

  beforeEach(waitForAsync(() => {
    TestBed.configureTestingModule({
      declarations: [ListPage],
      imports: [IonicModule.forRoot()],
    }).compileComponents();

    fixture = TestBed.createComponent(ListPage);
    component = fixture.componentInstance;
    fixture.detectChanges();

    service = TestBed.inject(ApiService);
  }));

  it('should load async todos', (done) => {
    const arr = [1, 2, 3, 4, 5];
    const spy = spyOn(service, 'getStoredTodos').and.returnValue(
      Promise.resolve(arr)
    );
    component.loadStorageTodos();

    spy.calls.mostRecent().returnValue.then(() => {
      expect(component.todos).toBe(arr);
      done();
    });
  });

  it('waitForAsync should load async todos', waitForAsync(() => {
    const arr = [1, 2, 3];
    const spy = spyOn(service, 'getStoredTodos').and.returnValue(
      Promise.resolve(arr)
    );
    component.loadStorageTodos();

    fixture.whenStable().then(() => {
      expect(component.todos).toBe(arr);
    });
  }));

  it('fakeAsync should load async todos', fakeAsync(() => {
    const arr = [1, 2];
    const spy = spyOn(service, 'getStoredTodos').and.returnValue(
      Promise.resolve(arr)
    );
    component.loadStorageTodos();
    tick();
    expect(component.todos).toBe(arr);
  }));
});

The first way is basically the default Jasmine way, and the other two are more Angular like.

Both of them are just fine, the waitForAsync simply waits until all Promises are finished and then we can run our matcher.

In the fakeAsync we manually trigger the passage of time, and the code flow now looks more like when you are using async/await.

Feel free to try both and use the one you feel more comfortable with!

PS: You could already inject the spy directly into the TestBed to define functions that the spy will mock!

Testing Services with Http Calls

Alright we increased the complexity and difficulty along this tutorial and now reach the end, which I’m pretty sure you were waiting for!

To test HTTP calls we first of all need to write some code that actually performs a call, so begin by updating the src/app/app.module.ts to inject the HttpClientModule as always (this is not related to the actual test case!):

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

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

import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
import { HttpClientModule } from '@angular/common/http';

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

Now bring up the src/app/services/products.service.ts and add this simple HTTP call:

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class ProductsService {
  constructor(private http: HttpClient) {}

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

To test this function, we ned to create a whole HTTP testing environment because we don’t want to perform an actual HTTP call inside our test! This would take time and change your backend, and we just want to make sure the function sends out the call and returns some kind of data that we expect from the API.

To get started we now need to import the HttpClientTestingModule in our TestBed, and also inject the HttpTestingController to which we keep a reference.

Now we can define a mockResponse that will be sent back from our fake HTTp client, and then simply call the according getProducts() like we would normally and handle the Observable.

Inside the subscribe block we can compare the result we get to our mock response, because that’s what we will actually receive in this test case!

How?

The magic is in the lines below it, but let’s add the code to our src/app/services/products.service.spec.ts first of all:

import { TestBed } from '@angular/core/testing';

import { ProductsService } from './products.service';
import {
  HttpClientTestingModule,
  HttpTestingController,
} from '@angular/common/http/testing';
import { HttpClient } from '@angular/common/http';

describe('ProductsService', () => {
  let service: ProductsService;
  let httpClient: HttpClient;
  let httpTestingController: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
    });
    service = TestBed.inject(ProductsService);
    httpClient = TestBed.inject(HttpClient);
    httpTestingController = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpTestingController.verify();
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  it('should make an API call', () => {
    const mockResponse = [
      {
        id: 1,
        title: 'Simons Product',
        price: 42.99,
        description: 'Epic product test',
      },
    ];

    service.getProducts().subscribe((res) => {
      expect(res).toBeTruthy();
      expect(res).toHaveSize(1);
      const product = res[0];
      expect(product).toBe(mockResponse[0]);
    });

    const mockRequest = httpTestingController.expectOne(
      'https://fakestoreapi.com/products'
    );

    expect(mockRequest.request.method).toEqual('GET');

    // Resolve with our mock data
    mockRequest.flush(mockResponse);
  });
});

We can create a fake request using the httpTestingController and already add one expectation about the URL that should be called and the request method.

Finally we can let the client return our mockResponse by calling the flush() function.

So what’s happening under the hood?

  • The getProducts() from our service is called
  • The function wants to make an HTTP call to the defined URL
  • The HTTP testing module intercepts this call
  • The HTTP testing controller returns some fake data
  • The getProducts() returns this data thinking it made a real API call
  • We can compare the mock response to the result of the service function!

It’s all a bit tricky, but a great way to even test the API call functions of your app.

Get you Ionic Test Code Coverage

Finally if you’re interested in metrics or want to present them for your team, you should run the following command:

ng test --no-watch --code-coverage

This will generate a nice code coverage report in which you can see how much of your code is covered by tests.
ionic-angular-code-coverage

In our simple example we managed to get 100% coverage – how much will your company get?

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

The post How to Write Unit Tests for your Ionic Angular App appeared first on Devdactic - Ionic Tutorials.

From React Web to Native Mobile App with Capacitor & Ionic

$
0
0

You can build a native mobile app from your React app without even using React Native. Sounds crazy?

All of this is possible due to the advancements in web technology. Just like Electron claimed its space as the default to build desktop applications with web code, you can use Capacitor to do the same for mobile apps!

In this tutorial we will create a simple React app with a dummy login page and inside page and then convert it into a native mobile application using Capacitor.

react-capacitor-app-ionic

The fascinating thing is that the process takes like 10 minutes, and you open up your app (and your developer career) to infinite possibilities!

Prefer watching videos? Here’s the tutorial on my YouTube channel (to which you should definitely subscribe if you haven’t):

Want to continue reading? Here we go with our React mobile app!

App Setup

Let’s start with a pretty basic React app using create-react-app and the typescript template:

npx create-react-app capacitor-app --template typescript
cd ./capacitor-app

npm i react-router-dom

The only additionally dependency we need right now is the router to easily navigate to another page.

Now add an empty folder at src/routes and create two new files at:

  • src/routes/Dashboard.tsx
  • src/routes/Login.tsx

You can fill both of them with some placeholder code like this:

function Dashboard() {


  return (
    <>
      Dashboard
    </>
  );
}

export default Dashboard;

Follow the same scheme for the login page and rename the keywords to Login!

With our two pages in place we can define the routes for the React router inside of our src/index.tsx:

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import {
  BrowserRouter,
  Routes,
  Route,
  Link,
} from "react-router-dom";
import Login from './routes/Login';
import Dashboard from './routes/Dashboard';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<App />}>
          <Route path="" element={<Login />} />
          <Route path="/app/dashboard" element={<Dashboard/>} />
          <Route
            path="*"
            element={
              <main style={{ padding: "1rem" }}>
                <p>There's nothing here!</p>
                <Link to="/">Back home!</Link>
              </main>
            }
          />
        </Route>
      </Routes>
    </BrowserRouter>
  </React.StrictMode>
);

Let me explain what we did within the BrowserRouter:

  • Defined a route / to use the App component
  • Added all other routes below this so the router will always load the app file first and then resolve the rest of the path
  • If no other path component is present the Login will be rendered, and for /app/dashboard the Dashboard will be used
  • If no path match we have a fallback HTML code right in there, but we could easily supply our own 404 page here

For the fallback page we are using the wildcard path and also directly use the Link element from the React router so it triggers the right route of our app!

Additionally we need to add an Outlet somewhere in our view so the React router can render the actual elements within.

Because the App element is the parent of all routes, we can do it in there so bring up the src/App.tsx and change it to:

import './App.css';
import { Outlet } from "react-router-dom";

function App() {
  return (
    <div style={{ margin: "1rem" }}>
    <h1>Simons App</h1>
    <Outlet />
  </div>
  );
}

export default App;

Now we have some header text and under it the routes would be rendered when navigating to the URL.

At this point you should be able to serve your application by running:

npm run start

You can see the default page being the login, and you can also directly navigate to http://localhost:3000/app/dashboard to see the second page.

Off to a great start, now let’s implement a dummy login flow.

Creating a Simple React Login

Because we don’t really have a backend we gonna keep things easy and just fake a loading of our login and then use the useNavigate hook to route to our dashboard from code.

Besides that we can put in useState to show or hide a loading indicator while we are “submitting” our form.

Go ahead and change the src/routes/Login.tsx to this now:

import { useState } from "react";
import { useNavigate } from "react-router-dom";

function Login() {
  const [loading, setLoading] = useState(false);
  const navigate = useNavigate();

  const onSubmit = (event: any) => {
    event.preventDefault();
    setLoading(true);

    console.log("submit!");

    setTimeout(() => {
      setLoading(false);
      navigate("/app/dashboard");
    }, 1500)
  };

  return (
    <main style={{textAlign: "center"}}>
      <h2>Login</h2>
      <form onSubmit={onSubmit}>
        <div style={{display: "flex", flexDirection: "column", alignItems: "center", gap: "10px"}}>
          <div>
            <label>Email:</label>
            <input type="text" ></input>
          </div>

          <div>
            <label>Password:</label>
            <input type="password"></input>
          </div>

          {loading ? <div>Loading...</div> :
          <button type="submit">Login</button>
        }
        </div>
      </form>
    </main>
  );
}

export default Login;

Not sure about those hooks? Check out the useState docs to learn more about it!

On our dashboard page we will simply render a different title and button to go back, so bring up the src/routes/Dashboard.tsx and quickly change it to:

import { useNavigate } from "react-router-dom";

function Dashboard() {
  const navigate = useNavigate();

  const onLogout = () => {
    navigate('/');
  };

  return (
    <>
      <h2>Dashboard</h2>
      <button onClick={onLogout}>Logout</button>
    </>
  );
}

export default Dashboard;

Nothing really new in here!

react-login-page

I know, probably not the best login page you’ve ever seen. More like the worst.

At this point we are able to navigate around and finished our most basic example of a React login page. Now let’s see how fast we can build a mobile app from this.

Installing Capacitor

To wrap any web app into a native mobile container we need to take a few steps – but those have to be done just one initial time, later it’s as easy as running a sync command.

First of all we can install the Capacitor CLI locally and then initialise it in our project.

Now we need to install the core package and the respective packages for the iOS and Android platform.

Finally you can add the platforms, and that’s it!

# Install the Capacitor CLI locally
npm install @capacitor/cli --save-dev
 
# Initialize Capacitor in your React project
npx cap init
 
# Install the required packages
npm install @capacitor/core @capacitor/ios @capacitor/android
 
# Add the native platforms
npx cap add ios
npx cap add android

At this point you should see a new ios and android folder in your React project.

Those are real native projects!

To open the Android project later you should install Android Studio, and for iOS you need to be on a Mac and install Xcode.

Additionally you should see a capacitor.config.ts in your project, which holds some basic settings for Capacitor that are used during sync. The only thing you need to worry about is the webDir which should point to the output of your build command, but usually this should already be correct.

Give it a try by running the following commands:

npm run build
npx cap sync

The first command will simply build your React project, while the second command will sync all the web code into the right places of the native platforms so they can be displayed in an app.

Additionally the sync command might update the native platforms and install plugins to access native functionality like camera, but we are not going that far today.

Without noticing you are now actually done, so let’s see the app on a device!

Build and Deploy native apps

You now need Xcode for iOS and Android Studio for Android apps on your machine. Additionally you need to be enrolled in the Apple Developer Program if you want to build and distribute apps on the app store, and same for the Google Play Store.

If you never touched a native mobile project, you can easily open both native projects by running:

npx cap open ios
npx cap open android

Inside Android Studio you now just need to wait until everything is ready, and you can deploy your app to a connected device without changing any of the settings!

android-studio-deploy-angular

Inside Xcode it’s almost the same, but you need to setup your signing account if you wan to deploy your app to a real device and not just the simulator. Xcode guides you through this if you’ve never done it (but again, you need to be enrolled in the Developer Program).

After that it’s as easy as hitting play and run the app on your connected device which you can select at the top!
xcode-deploy-app-angular

Congratulations, you have just deployed your React web app to a mobile device!

react-app-wrong-size

But there are still some challenges ahead, especially on iOS the UI doesn’t look good yet. Before we fix that, let’s make our debugging process faster.

Capacitor Live Reload

By now you are used to have live reload with all modern frameworks, and we can have the same functionality even on a mobile device with minimum effort!

The idea is to make your locally served app with live reload available on your network, and the Capacitor app will simply load the content from that URL.

First step is figuring out your local IP, which you can get on a Mac by running:

ipconfig getifaddr en0

On Windows, run ipconfig and look for the IPv4 address.

Now we only need to tell Capacitor to load the app directly from this server, which we can do right in our capacitor.config.ts with another entry:

import { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  appId: 'com.example.app',
  appName: 'capacitor-app',
  webDir: 'build',
  bundledWebRuntime: false,
  server: {
    url: 'http://192.168.x.xx:3000',
    cleartext: true
  },
};

export default config;

Make sure you use the right IP and port, I’ve simply used the default React port in here.

To apply those changes we can now copy over the changes to our native project:

npx cap copy

Copy is mostly like sync, but will only copy over the changes of the web folder and config, not update the native project.

Now you can deploy your app one more time through Android Studio or Xcode and then change something in your React app – the app will automatically reload and show the changes!

Caution: If you install new plugins like the camera, this still requires a rebuild of your native project because native files are changed which can’t be done on the fly.

Fix the Mobile UI of React

Now we can tackle the open issues of our mobile app more easily.

To begin with, we need to change a meta tag inside our public/index.html and also include viewport-fit:

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />

By doing this we can now access an environment variable that gives us information about the iOS notch area at the top which you usually need to keep empty.

Therefore bring up the src/App.tsx and include the value as paddingTop for our wrapper element:

import './App.css';
import { Outlet } from "react-router-dom";

function App() {
  return (
    <div style={{ margin: "0 1rem 0 1rem", paddingTop: "env(safe-area-inset-top)" }}>
    <h1>Simons App</h1>
    <Outlet />
  </div>
  );
}

export default App;

Now we got the padding right in our app, and if you have built a responsive good looking React app before you are pretty much done at this point!

However, if you also want to test a simply way to add adaptive mobile styling to our React app then we can take things a step further.

Using Ionic UI Components with React

I’ve worked years with Ionic to build awesome cross platform applications and it’s one of the best choices if you want a really great looking mobile UI that adapts to iOS and Android specific styling.

To use it, we only need to install the Ionic react package now:

npm install @ionic/react

Additionally Ionic usually ships with Ionicons, a great icon library that again adapts to the platform it’s running on. Let’s also add it by running:

npm install ionicons

In order to use the styling of Ionic we now need to change our src/App.tsx and include:

/* Core CSS required for Ionic components to work properly */
import '@ionic/react/css/core.css';

/* Basic CSS for apps built with Ionic */
import '@ionic/react/css/normalize.css';
import '@ionic/react/css/structure.css'; // Remove if nothing is visible
import '@ionic/react/css/typography.css';

/* Optional CSS utils that can be commented out */
import '@ionic/react/css/padding.css';
import '@ionic/react/css/float-elements.css';
import '@ionic/react/css/text-alignment.css';
import '@ionic/react/css/text-transformation.css';
import '@ionic/react/css/flex-utils.css';
import '@ionic/react/css/display.css';
import { setupIonicReact } from '@ionic/react';

setupIonicReact();

To use Ionic components we need to import them one by one from the package we just installed and use them instead of the div elements we had before.

Learning the Ionic syntax for all of those components takes time, but I’m running an online school called Ionic Academy to help developers learn Ionic in the fastest possible way!

In our case we can re-style our login page to hold some items, labels and input fields and a button with one of those Ionicons.

Additionally there are even some hooks included like useIonAlert or useIonLoading which allow us to call certain Ionic overlays or components directly from code!

This means we can now easily display a loading screen calling present() and showing a native alert modal using alert() with the according values for the elements within the alert.

Go ahead now and change the src/routes/Login.tsx to this:

import { IonButton, IonCard, IonCardContent, IonIcon, IonInput, IonItem, IonLabel, useIonAlert, useIonLoading } from "@ionic/react";
import { useNavigate } from "react-router-dom";
import { logIn } from 'ionicons/icons';

function Login() {
  const navigate = useNavigate();
  const [alert] = useIonAlert();
  const [present, dismiss] = useIonLoading();

  const onSubmit = async (event: any) => {
    event.preventDefault();
    await present({message: 'Loading...'})
    
    setTimeout(() => {
      dismiss();
      if(Math.random() < 0.5) {
        alert({
          header: 'Invalid credentials',
          message: 'There is no user with that name and password.',
          buttons: [{text: 'Ok'}]
        })
      } else {
        navigate("/app/dashboard");
      }
    }, 1500)
  };

  return (
    <>
      <IonCard>
        <IonCardContent>
          <form onSubmit={onSubmit}>
            <IonItem>
              <IonLabel position="floating">Email</IonLabel>
              <IonInput type="email"></IonInput>
            </IonItem>

            <IonItem>
              <IonLabel position="floating">Password</IonLabel>
              <IonInput type="password"></IonInput>
            </IonItem>

            <div className="ion-margin-top">
            <IonButton expand="full" type="submit" color="secondary">
                <IonIcon icon={ logIn } slot="start"/>
                Login</IonButton>
            </div>
          </form>
        </IonCardContent>
      </IonCard>
    </>
  );
}

export default Login;

With a little random factor included we can now sometimes see the alert or move to the (yet unstyled) dashboard of our React app!

react-ionic-alert

All of this took us just a few minutes of setup time, and we could now already roll out this app to the native app stores.

Conclusion

Going from a React app to a native mobile app for iOS and Android can be that easy – no need to use React Native and learn a ton of new libraries.

If you now bring the good old performance argument, please stop and take a look at the Ionic vs React Native performance comparison first.

With Capacitor you are betting on web technology, so you benefit from all advancements made in that area and improvements to mobile web views and devices in general.

The post From React Web to Native Mobile App with Capacitor & Ionic appeared first on Devdactic - Ionic Tutorials.


Angular Material Mobile App with Capacitor

$
0
0

Angular Material is an awesome library of Material Design components for Angular apps, and with its sidenav plus the help of Capacitor we can easily build a native mobile app from one codebase!

In this tutorial we will go through all the steps of setting up an Angular project and integrating Angular Material. We will create a responsive navigation which shows a side menu on smaller screen and a regular top navigation bar on desktops screens.

angular-material-capacitor

Finally we will install Capacitor to quickly build a native iOS and Android app from our Angular app.

This means we are able to cover 3 different platforms all from one codebase!

Creating an Angular Material Sidenav

Let’s begin with a new Angular project. After creating the project you can use a schematic to add all required packages and changes to our project for using Angular Material.

You can basically select yes for everything during that wizard, and afterwards run another schematic from Angular Material which automatically bootstraps the basic sidenav component for us!

# Start a new app
ng new materialApp --routing --style=scss
cd ./materialApp

# Schematic to add Angular Material
ng add @angular/material

# Generate a component with sidenav
ng generate @angular/material:navigation app-navigation

When we are done, we can change our src/app/app.component.html as we want to display our new component instead of all the dummy code that default app comes with:

<app-app-navigation></app-app-navigation>

This will load our component instead, and now it’s a good time to take a look at the general setup of the sidenav:

  • The whole code is surrounded by the mat-sidenav-container component
  • The actual side menu that we can display is inside mat-sidenav
  • Everything inside mat-sidenav-content is the actual main area which later shows our different pages

If you run the app right now you will see the side menu even on bigger screens, but usually you will use a top navigation in that case (although some websites also use a side menu).

To achieve this behaviour, we will change the src/app/app-navigation/app-navigation.component.html and remove the opened property from the mat-sidenav:

<mat-sidenav-container class="sidenav-container">
  <mat-sidenav
    #drawer
    class="sidenav"
    [attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
    [mode]="(isHandset$ | async) ? 'over' : 'side'"
  >
    <mat-toolbar>Menu</mat-toolbar>
    <mat-nav-list>
      <a mat-list-item href="#">Link 1</a>
      <a mat-list-item href="#">Link 2</a>
      <a mat-list-item href="#">Link 3</a>
    </mat-nav-list>
  </mat-sidenav>

  <mat-sidenav-content>
    <mat-toolbar color="primary">
      <button
        type="button"
        aria-label="Toggle sidenav"
        mat-icon-button
        (click)="drawer.toggle()"
        *ngIf="isHandset$ | async"
      >
        <mat-icon aria-label="Side nav toggle icon">menu</mat-icon>
      </button>
      <span>My App</span>
    </mat-toolbar>
    <!-- Add Content Here -->
    MY CONTENT HERE
  </mat-sidenav-content>
</mat-sidenav-container>

If you now run the app you should only see the menu icon on smaller screens and otherwise just the toolbar with our app title at the top!

This page automatically comes with an isHandset Observable that you can find inside the src/app/app-navigation/app-navigation.component.ts, and it uses the Angular BreakpointObserver to emit if we have reached a “handset” device size.

Since this is an Observable, all occurrences to the isHandset variable use the Angular async pipe to subscribe to it.

Adding Navigation with different Routes

So far we only have this one page, now it’s time to add more pages and we start by generating some components, one even using another schematic from Angular Material to setup a nice little dashboard:

ng generate @angular/material:dashboard dashboard
ng generate component about
ng generate component error

Now we need to reference the different components and creating the according routing entries so the Angular router can resolve a URL to a specific component.

For this, open the src/app/app-routing.module.ts and change it to:

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AboutComponent } from './about/about.component';
import { DashboardComponent } from './dashboard/dashboard.component';
import { ErrorComponent } from './error/error.component';

const routes: Routes = [
  {
    path: '',
    children: [
      {
        path: '',
        component: DashboardComponent,
      },
      {
        path: 'about',
        component: AboutComponent,
      },
      {
        path: '404',
        component: ErrorComponent,
      },
      {
        path: '**',
        redirectTo: '404',
      },
    ],
  },
];

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

We have now different routes in our app:

  • / will open the Dashboard
  • /about will open About
  • /404 will open the Error page
  • Everything else will be redirected to the error page

If you’d like to show something like a login before these pages, take a look at my login app template with Ionic!

Right now the routing doesn’t work because we haven’t added the router-outlet in any place of our app.

The best place for it is actually towards the bottom of our sidenav where we have a comment by default, so bring up the
src/app/app-navigation/app-navigation.component.html and insert:

<mat-sidenav-container class="sidenav-container">
  <mat-sidenav
    #drawer
    class="sidenav"
    [attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
    [mode]="(isHandset$ | async) ? 'over' : 'side'"
  >
    <mat-toolbar color="primary">Menu</mat-toolbar>
    <mat-nav-list>
        <!-- TODO -->
    </mat-nav-list>
  </mat-sidenav>

  <mat-sidenav-content>
    <mat-toolbar color="primary">
      <button
        type="button"
        aria-label="Toggle sidenav"
        mat-icon-button
        (click)="drawer.toggle()"
        *ngIf="isHandset$ | async"
      >
        <mat-icon aria-label="Side nav toggle icon">menu</mat-icon>
      </button>
      <span>My App</span>
      <!-- TODO -->
    </mat-toolbar>

    <!-- Add Content Here -->
    <router-outlet></router-outlet>
    
  </mat-sidenav-content>
</mat-sidenav-container>

Now you should be able to directly navigate to the different routes that we created before!

angular-material-dashboard

But of course we need the navigation links in our menu and nav bar as well, so we need to change the file again.

This time we add the routerLink items in two places.

In the mat-sidenav we also add a click handler to directly close the sidenav menu when we select an entry using the drawer template reference.

In the full screen navigation bar we add a space element so we can move the buttons to the right hand side using the flexbox layout later!

Go ahead and change the src/app/app-navigation/app-navigation.component.html again:

<mat-sidenav-container class="sidenav-container">
  <mat-sidenav
    #drawer
    class="sidenav"
    [attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
    [mode]="(isHandset$ | async) ? 'over' : 'side'"
  >
    <mat-toolbar color="primary">Menu</mat-toolbar>
    <mat-nav-list>
      <a
        mat-list-item
        routerLink="/"
        routerLinkActive="active-link"
        [routerLinkActiveOptions]="{ exact: true }"
        (click)="drawer.toggle()"
        >Dashboard</a
      >
      <a
        mat-list-item
        routerLink="/about"
        routerLinkActive="active-link"
        (click)="drawer.toggle()"
        >About</a
      >
    </mat-nav-list>
  </mat-sidenav>

  <mat-sidenav-content>
    <mat-toolbar color="primary">
      <button
        type="button"
        aria-label="Toggle sidenav"
        mat-icon-button
        (click)="drawer.toggle()"
        *ngIf="isHandset$ | async"
      >
        <mat-icon aria-label="Side nav toggle icon">menu</mat-icon>
      </button>
      <span>My App</span>
      <span class="spacer"></span>
      <div [class.hidden]="isHandset$ | async">
        <a
          mat-button
          routerLink="/"
          routerLinkActive="active-link"
          [routerLinkActiveOptions]="{ exact: true }"
          >Dashboard</a
        >
        <a mat-button routerLink="/about" routerLinkActive="active-link"
          >About</a
        >
      </div>
    </mat-toolbar>
    <router-outlet></router-outlet>
  </mat-sidenav-content>
</mat-sidenav-container>

Additional all buttons and items for routing will receive the active-link CSS class when the route becomes active, and to make sure our dashboard path isn’t activated for every possible page we apply exact match to the routerLinkActiveOptions!

Finally our top navigation is wrapped inside a div which will be hidden when we reach a the handset device width – at that point we have our burger menu and don’t need those items anyway.

Now we just need to add the missing classes and comment out one initial class since this would overwrite the stylig of the toolbar inside the menu. Therefore bring up the src/app/app-navigation/app-navigation.component.scss and change it to:

.sidenav-container {
  height: 100%;
}

.sidenav {
  width: 200px;
}

// .sidenav .mat-toolbar {
//   background: inherit;
// }

.mat-toolbar.mat-primary {
  position: sticky;
  top: 0;
  z-index: 1;
}

.hidden {
  display: none;
}

.spacer {
  flex: 1 1 auto;
}

.active-link {
  color: #ffc000;
}

In order to show some more content and make it easier to route, let’s put in some dummy content into our src/app/about/about.component.html:

<div class="grid-container">
  <h1 class="mat-h1">About</h1>
  This is my epic project!
  <button mat-button routerLink="/asd" color="primary">Broken link</button>
</div>

Let’s also do the same for the src/app/error/error.component.html:

<div class="grid-container">
  <h1 class="mat-h1">Error</h1>
  This page doesn't exist -
  <button mat-button routerLink="/" color="primary">Back home</button>
</div>

And finally add a global styling for both pages inside the src/styles.scss:

.grid-container {
  margin: 20px;
}

With all of that in place we have a decent responsive web application with different routes and a super clean UI – this could be your template for your next web app!

But we won’t stop here…

Adding Capacitor to our Angular Project

With Capacitor we are able to easily build a native application from our web app code – without any actual changes to the code itself!

To setup Capacitor we install the CLI as a local dependency and call the init command. Simply hit enter for every question for now!

Additionally you need to install a few packages for the core and the native platforms iOS/Android that you want to use and add them in the end one time:

# Install the Capacitor CLI locally
npm install @capacitor/cli --save-dev

# Initialize Capacitor in your Angular project
npx cap init

# Install the required packages
npm install @capacitor/core @capacitor/ios @capacitor/android

# Add the native platforms
npx cap add ios
npx cap add android

At this point you should see an error because Capacitor is looking in the wrong place for the build of your Angular app, so let’s open the capacitor.config.ts and change it to:

import { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  appId: 'com.example.app',
  appName: 'material-app',
  webDir: 'dist/material-app',
  bundledWebRuntime: false,
};

export default config;

Now we are pointing to the right build folder, and we can create a new build and sync those files into our native platforms:

# Build the Angular project
ng build

# Sync our files to the native projects
npx cap sync

Now you just need to deploy the app to your device!

Build and Deploy native apps

You now need Xcode for iOS and Android Studio for Android apps on your machine. Additionally you need to be enrolled in the Apple Developer Program if you want to build and distribute apps on the app store, and same for the Google Play Store.

If you never touched a native mobile project, you can easily open both native projects by running:

npx cap open ios
npx cap open android

Inside Android Studio you now just need to wait until everything is ready, and you can deploy your app to a connected device without changing any of the settings!

android-studio-deploy-angular

Inside Xcode it’s almost the same, but you need to setup your signing account if you wan to deploy your app to a real device and not just the simulator. Xcode guides you through this if you’ve never done it (but again, you need to be enrolled in the Developer Program).

After that it’s as easy as hitting play and run the app on your connected device which you can select at the top!
xcode-deploy-app-angular

Congratulations, you have just deployed your Angular web app to a mobile device!

But there are still some challenges ahead, especially on iOS the UI doesn’t look good yet. Before we fix that, let’s make our debugging process faster.

Capacitor Live Reload

By now you are used to have live reload with all modern frameworks, and we can have the same functionality even on a mobile device with minimum effort!

The idea is to make your locally served app with live reload available on your network, and the Capacitor app will simply load the content from that URL.

First step is figuring out your local IP, which you can get on a Mac by running:

ipconfig getifaddr en0

On Windows, run ipconfig and look for the IPv4 address.

With that information you can now tell Angular to use it directly as a host (instead of the keyword localhost) or you can simply use 0.0.0.0 which did the same in my test:

ng serve -o --host 0.0.0.0

# Alternative
ng serve -o --host 192.168.x.xx

Now we only need to tell Capacitor to load the app directly from this server, which we can do right in our capacitor.config.ts with another entry:

import { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  appId: 'com.example.app',
  appName: 'material-app',
  webDir: 'dist/material-app',
  bundledWebRuntime: false,
  server: {
    url: 'http://192.168.x.xx:4200',
    cleartext: true
  },
};

export default config;

Make sure you use the right IP and port, I’ve simply used the default Angular port in here.

To apply those changes we can now copy over the changes to our native project:

npx cap copy

Copy is mostly like sync, but will only copy over the changes of the web folder and config, not update the native project.

Now you can deploy your app one more time through Android Studio or Xcode and then change something in your Angular app – the app will automatically reload and show the changes!

Caution: If you install new plugins like the camera, this still requires a rebuild of your native project because native files are changed which can’t be done on the fly.

Fixing the Mobile UI of Angular Material

Now we can tackle the open issues of our mobile app more easily.

To begin with, we need to change a meta tag inside our src/index.html and also include viewport-fit:

<meta
      name="viewport"
      content="width=device-width, initial-scale=1.0, viewport-fit=cover"
    />

By doing this we can now access an environment variable that gives us information about the iOS notch area at the top which you usually need to keep empty.

The easiest way to correctly apply this in our case is to open the src/app/app-navigation/app-navigation.component.scss and adding a new rule:

mat-toolbar {
  padding-top: env(safe-area-inset-top);
  height: calc(56px + env(safe-area-inset-top));
}

Because we change the top padding, we also need to calculate a new height of the toolbar. But with this in place, you have a very native UI!

An additional “bug” can be seen when you drag the view down on iOS, something know as over-scroll:

angular-material-overscroll

There seems to be no easy solution for this that works in all scenarios, but I found a pretty decent one in this Github issue.

We can disable the behaviour directly inside Xcode – Capacitor allows us to change the native projects however we want, and the changes won’t be overwritten by some magical script!

Therefore, generate a new DisableBounce.m file inside Xcode and insert this (select yes for generating a bridging header):
ios/App/App/DisableBounce.m

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

@implementation UIScrollView (NoBounce)
- (void)didMoveToWindow {
    [super didMoveToWindow];
    self.bounces = NO;
}
@end

And with that fix in place, no more over-scroll on iOS! This means, people won’t really notice they are actually using a web app – which most of us have already done without noticing most likely!

Recap

Angular Material is a great library of components to build beautiful Angular apps, and we haven’t even touched all the other amazing components that you could now use!

By using a sidenav we can also make our page responsive quite easily, and later add in Capacitor to the mix to get the benefit of building a mobile app from our existing codebase.

If you got any questions just let me know, and you can also check out a video version of this tutorial below!

The post Angular Material Mobile App with Capacitor appeared first on Devdactic - Ionic Tutorials.

Angular Landing Page with Airtable Integration and Netlify Functions

$
0
0

If you want to build a public landing page with Angular and email signup you likely don’t want to expose any secret keys, and I’ll show you how with Netlify cloud functions.

In this tutorial we will connect a simple Airtable table as some sort of backend to store emails to our Angular landing page. We do this with the help of Netlify where we will both host our application and also deploy a cloud function!

If you don’t want to use Airtable for storing your users emails you could easily plug in any email service into the cloud function as well. The only thing that matters is that we don’t want to expose any secret keys inside our Angular app and instead keep that kind of information hidden.

Once the technical side is done we will also make the page more polished by adding Tailwind to our Angular app and add relevant meta tags.

You ready? Let’s do this!

Creating an Airtable Table

We begin by setting up our Airtable table, so simply create a new table, remove all the fluff and just make it have one column Email. Don’t mind the two rows in the image below, I later removed the Name column.

airtable-setup

Inside the URL of that table we can find most relevant information for the connection to Airtable, so right now just leave that tab open until we come back to it.

Creating the Angular Project

Next step is creating a new Angular project using the Angular CLI – install it and additionally the Netlify CLI if you haven’t done before.

We create our app including a routing file and SCSS styling, and we also generate a new landing component inside. Finally we install additional pakages for our cloud functions, so go ahead now and run:

# Install CLIs if you haven't done before
npm install -g netlify-cli @angular/cli 

# Setup new Angular project
ng new prelaunch-page --routing --style=scss
cd ./prelaunch-page

# Generate a new component
ng generate component landing

# Install the Airtable package
npm i airtable

# Netlify functions package
npm i @netlify/functions

Because we will later encounter some Typescript warnings, we now need to add one key to the tsconfig.json to solve that issue right upfront:

"compilerOptions": {
    "allowSyntheticDefaultImports": true,

Besides that we should import all necessary modules so we can perform Http calls with the HttpClientModule and build a form with the ReactiveFormsModule.

Therefore open up the src/app/app.module.ts and change it to:

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

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { LandingComponent } from './landing/landing.component';
import { HttpClientModule } from '@angular/common/http';
import { ReactiveFormsModule } from '@angular/forms';

@NgModule({
  declarations: [AppComponent, LandingComponent],
  imports: [
    BrowserModule,
    AppRoutingModule,
    HttpClientModule,
    ReactiveFormsModule,
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

Since we want to display our new component instead of the default page, we can now touch the src/app/app-routing.module.ts and include a path to our new component like this:

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { LandingComponent } from './landing/landing.component';

const routes: Routes = [
  {
    path: '',
    component: LandingComponent,
  },
];

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

Finally we remove all the boilerplate code of the src/app/app.component.html and change it to just one line so the Angular router simply displays the right information/component in our app:

<router-outlet></router-outlet>

If you haven’t done until now you should bring up the live preview of your app by running:

ng serve -o

At this point you should see your landing component on the screen with a small message, which means we are ready to continue.

Creating the Angular Landing Page

Now it’s time to implement the most basic version of our landing page to capture an email. We will tweak the UI later with Tailwind, right now we just want the fields and the logic to work.

Get started by defining a form inside the src/app/landing/landing.component.ts where we make our one email field required and add an empty submit function for the moment:

import { HttpClient } from '@angular/common/http';
import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-landing',
  templateUrl: './landing.component.html',
  styleUrls: ['./landing.component.scss'],
})
export class LandingComponent {
  form: FormGroup;

  constructor(private http: HttpClient, private fb: FormBuilder) {
    this.form = this.fb.group({
      email: ['', [Validators.required, Validators.email]],
    });
  }

  submit() {
    console.log('SUBMIT THIS: ', this.form.value);
  }
}

To use the FormGroup we connect it to a form tag and define the ngSubmit function. The one input is connected to the email field through its formControlName, and if a button inside the form has the submit type our submit action will be called!

Go ahead and change our src/app/landing/landing.component.html to this now:

<form [formGroup]="form" (ngSubmit)="submit()">
  <input formControlName="email" type="email" placeholder="saimon@devdactic.com" />
  <button type="submit">Submit</button>
</form>

It’s not really a great landing page yet, but we should be able to insert our data and see a log when we submit the form.

angular-landing-preview

Before we make it look nicer, we focus on the technical side which is the actual cloud function.

Adding a Netlify Cloud Function to the Angular Project

A cloud function is a simple piece of code that is executed in the cloud without you having to run and maintain your own server.

In our case, we want to use Netlify for this but there are several other hosting options available which offer the same functionality by now.

We begin by creating a new file (and the according folders in our project) at netlify/functions/signup.ts, and we insert the following:

import { Handler, HandlerEvent } from '@netlify/functions';
import Airtable from 'airtable';

// Initialize Airtable connection
const { AIRTABLE_KEY } = process.env;

// USE YOUR TABLE BASE HERE
const base = new Airtable({ apiKey: AIRTABLE_KEY }).base('appXYZ');

const handler: Handler = async (event: HandlerEvent, context: any) => {
  try {
    // Parse the body of the request
    const data = JSON.parse(event.body || '');

    // Make sure we got all data
    if (!data.email) {
      return {
        statusCode: 400,
        body: 'Please include email.',
      };
    }

    // USE YOUR TABLE NAME
    // Insert our data into the table columns
    await base('tbXYZ').create({
      Email: data.email,
    });

    return {
      statusCode: 200,
      body: JSON.stringify({
        message: 'Thanks for signing up!',
      }),
    };
  } catch (e: any) {
    return {
      statusCode: 500,
      body: e.message,
    };
  }
};

export { handler };

Quite a lot going on:

  • We initialize a connection to Airtable using an AIRTABLE_KEY and the base name (which you can find in the URL from the beginning)
  • We define a handler for our function which tries to load the email from the body
  • We call the create() function to insert a row into our table (using the table name from the URL at the beginning)
  • We return the right status codes and messages along the way

It’s a straight forward function, and you could make any kind of external API call in here if you are using a different API like ConvertKit.

By loading the AIRTABLE_KEY from our process environment we make sure it’s save and not exposed anywhere, but right now we don’t even have that environment. But hold on, we will get to that part soon.

For now we will create another file at the roo of our project called netlify.toml which holds some information for Netlify about how to build and deploy our project:

[build]
  publish = "dist/prelaunch-page"
  command = "ng build"
  functions = "./netlify/functions"
[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200

By defining this we also know upfront that our cloud function will be available at .netlify/functions/signup on our domain.

Since we want to make this work both locally and when deployed, we can now open up our src/app/landing/landing.component.ts again and implement the submit function to send the data to this endpoint and use the current window location before that:

submit() {
    const baseUrl = window.location.origin;
    this.http
      .post(`${baseUrl}/.netlify/functions/signup`, this.form.value)
      .subscribe({
        next: (res: any) => {
          alert(res.message);
        },
        error: (err) => {
          alert('ERROR: ' + err.error);
        },
      });
  }

As you can see, making a call to a cloud function is a simple Http call, nothin more, nothing less!

Now we can also use the Netlify CLI to run all of this locally, so simply type:

netlify dev

This would host your Angular app and also deploy your cloud function locally, but it will most likely fail because we have no Airtable key defined yet.

To fix this right now you can simply insert your Airtable key hardcoded into our cloud function.

BTW, you can find that secret key inside your Airtable account page!

Once you used a real value and the Airtable connection works, you should already have a working prototype from which you can submit your email to Airtable!

Host our Angular App with Netlify

But of course we don’t want it hardcoded, and we also want to host our app on Netlify. For all of this we need a new Github (or similar Git service) repository, so create a new one now.

create-github-repo

Back in your Angular project you can add all files, commit them and add the Github repository as your remote and finally push the code:

git add .
git commit -am "First commit"

# If you are not using main as default
git branch -M main

git remote add origin https://github.com/...
git push -u origin main

Now your app is available on Github and we can create a connection to Netlify. We can do this through the admin UI or simply from the CLI by running:

netlify init

Go through the steps of “Create & configure a new site” and you can keep everything as it is. I’m positive that this already takes the information from the toml file we created before!

When this is done we can finally add our secret Airtable key to our Netlify environment by running:

netlify env:set AIRTABLE_KEY YOUR_AIRTABLE_KEY

Of course replace the last part with your real value – and you could also do this through the Netlify web UI if you wanted to.

At this point you can remove the hard coded value from the cloud function as even our dev environment will have access to the key!

Note: If your deployed version on Netlify isn’t working yet, it might not have picked up the new environment variable. Simple rebuild the project and the whole process including the cloud function should work!

You can find the URL to your Netlify project in your account or also on the CLI after you ran the init command.

Adding Tailwind

At this point you have a very ugly but working Angular landing page – that’s already a great success!

But if you want to make this a serious landing page you could now go ahead and add a UI framework like Tailwind to your Angular app, which I’ll show you real quickly.

Begin with these commands to install all necessary packages, and a new file for the Tailwind configuration will be created:

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init

Now we need to include all of our templates in the configuration so open the new tailwind.config.js and change it to:

module.exports = {
  content: ["./src/**/*.{html,ts}"],
  theme: {
    extend: {},
  },
  plugins: [],
};

Finally we also need to import the Tailwind styling, which we can do right in our src/styles.scss like this:

@tailwind base;
@tailwind components;
@tailwind utilities;

You can now try any Tailwind selector in your app and it should work fine. For the landing page you could now use the following block inside our
src/app/landing/landing.component.html:

<div class="bg-white py-16 sm:py-24">
  <div class="relative sm:py-16">
    <div class="mx-auto max-w-md px-4 sm:max-w-3xl sm:px-6 lg:max-w-7xl lg:px-8">
      <div class="relative rounded-2xl px-6 py-10 bg-indigo-600 overflow-hidden shadow-xl sm:px-12 sm:py-20">
        <div class="relative">
          <div class="sm:text-center">
            <h2 class="text-3xl font-extrabold text-white tracking-tight sm:text-4xl">Get notified when we&rsquo;re launching.</h2>
            <p class="mt-6 mx-auto max-w-2xl text-lg text-indigo-200">Subscribe to our prelaunch list and be the first to join our upcoming app!</p>
          </div>
          <form [formGroup]="form" (ngSubmit)="submit()" class="mt-12 sm:mx-auto sm:max-w-lg sm:flex">
            <div class="min-w-0 flex-1">
              <label for="cta-email" class="sr-only">Email address</label>
              <input
                type="email"
                formControlName="email"
                class="block w-full border border-transparent rounded-md px-5 py-3 text-base text-gray-900 placeholder-gray-500 shadow-sm focus:outline-none focus:border-transparent focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-indigo-600"
                placeholder="Enter your email"
              />
            </div>
            <div class="mt-4 sm:mt-0 sm:ml-3">
              <button
                type="submit"
                class="block w-full rounded-md border border-transparent px-5 py-3 bg-indigo-500 text-base font-medium text-white shadow hover:bg-indigo-400 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-indigo-600 sm:px-10"
              >
                Notify me
              </button>
            </div>
          </form>
        </div>
      </div>
    </div>
  </div>
</div>

Adding Tailwind snippets is horrible, but hopefully your app now looks a lot nicer.

angular-landing-tailwind

You can now commit and push your changes and the project will build again on Netlify, but before you actually launch your page I recommend one more thing.

Adding Meta Tags

If some shares your Angular SPA the OG tags for social sharing don’t exist. If you have multiple pages you would need a specific package to dynamically set these tags, but if you plan just a simple landing page go with the fastest method, which is simply adding the required tags and information to your src/index.html like this:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Angular Prelaunch</title>
    <base href="/" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="icon" type="image/x-icon" href="favicon.ico" />

    <meta name="Description" content="An epic app coming soon." />

    <meta property="og:site_name" content="Angular Prelaunch" />
    <meta property="og:url" content="https://super-cool-site-by-saimon24.netlify.app/" />
    <meta property="og:description" content="An epic app coming soon." />
    <meta property="og:image" content="https://super-cool-site-by-saimon24.netlify.app/assets/meta.png" />
    <meta property="og:image:secure_url" content="https://super-cool-site-by-saimon24.netlify.app/assets/meta.png" />
    <meta name="twitter:text:title" content="Angular Prelaunch" />
    <meta name="twitter:image" content="https://super-cool-site-by-saimon24.netlify.app/assets/meta.png" />
  </head>
  <body>
    <app-root></app-root>
  </body>
</html>

You can now paste your URL into a tool like MetaTags and see the improved preview of your page (of course only after committing and building your project)!

angular-landing-meta

Just be aware that these tags would be used for all different pages of your app, so once you add more pages, you would need a dedicated package for this.

Teardown

It’s always challenging to connect a private API to your Angular application as all your source code is basically public.

To overcome this challenge you can use a cloud function which holds the secret key to an API you want to use within the process environment and therefore sheltered from the outside world.

To make this even more secure you could also make the cloud function only callable from your domain, but by preventing access to your key you’ve already taken the most important step to building a landing page with Angular.

If you want to see the full process in action, you can find a video version of this tutorial below.

The post Angular Landing Page with Airtable Integration and Netlify Functions appeared first on Devdactic - Ionic Tutorials.