Analyzing the Ionic AWS Full-Stack Starter: The Features
In this tutorial we will see how the application makes good use of the Providers we analyzed previously.
The application is divided in two parts:
- The authentication process
- The tasks manipulation
Many intermediate Ionic concepts are present here, if you have any issue, you can have a look at my book to quickly update your knowledge.
Authentication
Starting with the login.html file:
<ion-content>
<div text-center class="logo">
<img src="assets/ionic-aws-logo.png" />
</div>
<form (submit)="login()">
<ion-list>
<ion-item>
<ion-label floating>Username</ion-label>
<ion-input [(ngModel)]="loginDetails.username"
type="text" autocorrect="off"
autocapitalize="none" name="username"></ion-input>
</ion-item>
<ion-item>
<ion-label floating>Password</ion-label>
<ion-input [(ngModel)]="loginDetails.password"
type="password" name="password"></ion-input>
</ion-item>
<div padding>
<button ion-button color="primary" block>LOGIN</button>
</div>
<div padding text-center>
<p>Don't have an account yet? <a (click)="signup()">Create one.</a></p>
</div>
</ion-list>
</form>
</ion-content>
Two interesting parts here.
Either the user doesn’t have an account and goes to the signup page or he enters his credentials, which will be stocked in the loginDetails property.
Note that, even though there is no:
type="submit"
The button will trigger the form submission.
Moving to the login.ts file:
import { Component } from "@angular/core";
import { NavController, LoadingController } from "ionic-angular";
import { TabsPage } from "../tabs/tabs";
import { SignupPage } from "../signup/signup";
import { ConfirmPage } from "../confirm/confirm";
import { User } from "../../providers/providers";
Aside from the classic imports, we have our User Provider that will be used to log our user, followed by some Pages:
- Tabs: Where the secure content is available
- Signup: That will register the user
- Confirm: If the user hasn’t yet confirmed his registration code
Finally the LoadingController that will be used to show a loader.
The logic of the authentication process is simple:
- Show the loader
- Register, confirm or login
- Hide the loader
Having a quick look at the LoginPage:
export class LoginDetails {
username: string;
password: string;
}
@Component({
selector: 'page-login',
templateUrl: 'login.html'
})
export class LoginPage {
public loginDetails: LoginDetails;
constructor(public navCtrl: NavController,
public user: User,
public loadingCtrl: LoadingController) {
this.loginDetails = new LoginDetails();
}
.
.
.
}
The login details are cleanly formatted by the LoginDetails Class.
This property is then used once the user taps on login:
login() {
let loading = this.loadingCtrl.create({
content: 'Please wait...'
});
loading.present();
let details = this.loginDetails;
console.log('login..');
this.user.login(details.username, details.password)
.then((result) => {
console.log('result:', result);
loading.dismiss();
this.navCtrl.setRoot(TabsPage);
}).catch((err) => {
if (err.message === "User is not confirmed.") {
loading.dismiss();
this.navCtrl.push(ConfirmPage, { 'username': details.username });
}
console.log('errrror', err);
loading.dismiss();
});
}
We will frequently encounter this in the application, the loadingCtrl will show the loader until the end of the process.
The loginDetails are then passed to the User Provider to log the user. Three cases here:
- There are no errors so the user is confirmed and we show the TabsPage
- There is an error because the user has not entered his registration code so we move to the ConfirmPage while passing the username
- There is a different error and we do nothing. If we want to, we can give some feedback to the user, otherwise he won’t understand that there is something wrong with the credentials
The signup.html file is pretty similar to the login one:
<p *ngIf="error" style="text-align: center">{{error.message}}</p>
<ion-list>
<ion-item>
<ion-label floating>Username</ion-label>
<ion-input type="text" [(ngModel)]="userDetails.username"
autocorrect="off" autocapitalize="none"
name="username"></ion-input>
</ion-item>
<ion-item>
<ion-label floating>Email</ion-label>
<ion-input type="email" [(ngModel)]="userDetails.email"
name="email"></ion-input>
</ion-item>
<ion-item>
<ion-label floating>Password</ion-label>
<ion-input type="password" [(ngModel)]="userDetails.password"
name="password"></ion-input>
</ion-item>
<div padding>
<button ion-button color="primary" block>Register</button>
</div>
<div padding text-center>
<p><a (click)="login()">Return to login</a>
</div>
</ion-list>
The data are stocked in the userDetails property and if there is an error (ex: weak password), the error message is displayed at the top.
Moving to the signup method in the signup.ts file:
signup() {
let loading = this.loadingCtrl.create({
content: 'Please wait...'
});
loading.present();
let details = this.userDetails;
this.error = null;
console.log('register');
this.user.register(details.username, details.password, {'email': details.email})
.then((user) => {
console.log('hooray', user);
loading.dismiss();
this.navCtrl.push(ConfirmPage, { username: details.username });
}).catch((err) => {
loading.dismiss();
this.error = err;
});
}
Once again, using the User Provider we register the user using the userDetails property.
The username and the password are credentials whereas the email is an attribute.
If everything is good, the user accesses the ConfirmPage with the username as parameter.
The confirm.html file:
<form (submit)="confirm()">
<ion-list>
<ion-item>
<ion-label floating>Confirmation Code</ion-label>
<ion-input type="text" [(ngModel)]="code" name="code"></ion-input>
</ion-item>
<div padding>
<button ion-button color="primary" block>Confirm Account</button>
</div>
<div padding>
<p>Haven't received the confirmation code email yet?
<a (click)="resendCode()">Resend</a>
</p>
</div>
</ion-list>
</form>
One form that uses the code property and a link to resend the registration code.
As you can expect, the confirm.ts file has two methods, however let’s focus on the initialization first:
export class ConfirmPage {
public code: string;
public username: string;
constructor(public navCtrl: NavController,
public navParams: NavParams, public user: User) {
this.username = navParams.get('username');
}
.
.
.
}
There we grab the username that was passed as a NavParam.
The two useful methods:
confirm() {
this.user.confirmRegistration(this.username, this.code).then(() => {
this.navCtrl.push(LoginPage);
});
}
resendCode() {
this.user.resendRegistrationCode(this.username);
}
Both methods use the User Service’s methods to do the work.
As for confirm, it will move the user to the LoginPage once the code is validated.
The logout is very simple.
As in many applications nowadays, it’s located in the SettingsPage. Here is the code from the settings.ts file:
logout() {
this.user.logout();
this.app.getRootNav().setRoot(LoginPage);
}
Let’s now focus on some Ionic Native feature, this feature only works on a device so no need to try it in the browser.
The Account
It starts in the account.html file:
<div *ngIf="avatarPhoto" class="avatar"
[style.background-image]="'url('+ avatarPhoto +')'"></div>
<button ion-button clear (click)="selectAvatar()">Change photo</button>
<input #avatar class="avatar-input" type="file" (change)="upload($event)" />
If there is an image, it will be used as background image in an area. If not, the user can upload it by using its camera through the selectAvatar method.
Which leads us to the account.ts file:
import { Camera, CameraOptions } from "@ionic-native/camera";
The Ionic Native Camera plugin is imported there.
The initialization is very important:
export class AccountPage {
@ViewChild('avatar') avatarInput;
private s3: any;
public avatarPhoto: string;
public selectedPhoto: Blob;
public attributes: any;
public sub: string = null;
constructor(public navCtrl: NavController,
public user: User,
public db: DynamoDB,
public config: Config,
public camera: Camera,
public loadingCtrl: LoadingController) {
let self = this;
this.attributes = [];
this.avatarPhoto = null;
this.selectedPhoto = null;
this.s3 = new AWS.S3({
'params': {
'Bucket': config.get('aws_user_files_s3_bucket')
},
'region': config.get('aws_user_files_s3_bucket_region')
});
this.sub = AWS.config.credentials.identityId;
user.getUser().getUserAttributes(function(err, data) {
self.attributes = data;
self.refreshAvatar();
});
}
Many properties there:
- s3: The AWS S3 service
- avatarPhoto: Will contain the url to our photo
- selectedPhoto: Will contain the Blob that will be stocked in the AWS Cloud
- attributes: The user information (name, email, etc)
- sub: Will contain AWS credentials identity ID
Once again, the self variable is required to keep the this object.
First important thing in the constructor: the AWS S3 initialization (bucket, region). We then set the sub using the AWS config and we acquire the user’s attributes.
Moving on to the selectAvatar method that will allow us to take a picture:
selectAvatar() {
const options: CameraOptions = {
quality: 100,
targetHeight: 200,
targetWidth: 200,
destinationType: this.camera.DestinationType.DATA_URL,
encodingType: this.camera.EncodingType.JPEG,
mediaType: this.camera.MediaType.PICTURE
}
this.camera.getPicture(options).then((imageData) => {
let loading = this.loadingCtrl.create({
content: 'Please wait...'
});
loading.present();
// imageData is either a base64 encoded string or a file URI
// If it's base64:
this.selectedPhoto = this.dataURItoBlob('data:image/jpeg;base64,' + imageData);
this.upload(loading);
}, (err) => {
// Handle error
});
//this.avatarInput.nativeElement.click();
}
As usual with Ionic the process is simple.
We first configure some options for the camera (quality, height, width , etc.). Note that some default constants are available for use.
Finally, we use the camera object’s getPicture method.
Once the picture is taken, the imageData variable is available. The loader is shown until the end of the upcoming process: the upload.
Before uploading, the image is transformed so we can stock it.
The upload method is as follow:
upload(loading: any) {
let self = this;
if (self.selectedPhoto) {
this.s3.upload({
'Key': 'protected/' + self.sub + '/avatar',
'Body': self.selectedPhoto,
'ContentType': 'image/jpeg'
}).promise().then((data) => {
this.refreshAvatar();
console.log('upload complete:', data);
loading.dismiss();
}).catch((err) => {
console.log('upload failed....', err);
loading.dismiss();
});
}
loading.dismiss();
}
If the picture is ready, we use the s3 object to upload the data, passing the following information:
- a unique Key
- the Body or content
- ContentType so AWS knows how to handle it
Once the upload is good, the refreshAvatar method is used:
refreshAvatar() {
let self = this;
this.s3.getSignedUrl('getObject', {'Key': 'protected/' + self.sub + '/avatar'},
function(err, url) {
self.avatarPhoto = url;
});
}
The getSignedUrl method will securely launch an operation: getObject.
This operation requires one important parameter: the key.
S3 then returns the url where the image is stocked and we can assign it to the avatarPhoto property.
Quite a gymnastic there, here is a summary:
Process
You might have noticed those lines:
@ViewChild('avatar') avatarInput;
//this.avatarInput.nativeElement.click();
This is not used anymore.
It’s an HTML Element that triggers the upload method, but it’s cleaner to directly use the upload method.
Moving to the last feature: the Task List.
Tasks
At the top of this page, the addTask button:
<ion-buttons end>
<button ion-button icon-only (click)="addTask()">
<ion-icon name="add"></ion-icon>
</button>
</ion-buttons>
We will analyze this one later.
Followed by an <ion-refresher> Component:
<ion-content>
<ion-refresher (ionRefresh)="refreshData($event)">
<ion-refresher-content
pullingIcon="arrow-dropdown"
pullingText="Pull to refresh"
refreshingSpinner="circles"
refreshingText="Refreshing...">
</ion-refresher-content>
</ion-refresher>
.
.
.
</ion-content>
When pulling down, the refreshData method will be triggered.
The following list will be updated:
<ion-list>
<ion-item-sliding *ngFor="let item of items; let idx = index;">
<button ion-item>
<h2>{{item.category}}</h2>
<p>{{item.description}}</p>
</button>
<ion-item-options>
<button ion-button color="danger" (click)="deleteTask(item, idx)">DELETE</button>
</ion-item-options>
</ion-item-sliding>
</ion-list>
Just a traditional <ion-list> of tasks, it will create many <ion-item-sliding> Elements with the task information and a delete button shown when sliding.
The tasks.ts file’s initialization is more interesting:
import { ModalController } from 'ionic-angular';
import { TasksCreatePage } from '../tasks-create/tasks-create';
.
.
.
export class TasksPage {
public items: any;
public refresher: any;
private taskTable: string = 'ionic-mobile-hub-tasks';
constructor(public navCtrl: NavController,
public modalCtrl: ModalController,
public user: User,
public db: DynamoDB) {
this.refreshTasks();
}
The ModalController is acquired, the task creation process will be located in another area: a Modal.
The content of the modal is located in the TasksCreatePage.
The TasksPage has:
- An items property to show the tasks
- The refresher we earlier talked about
- The tasktable that will contain the name of the DynamoDB table
Finally, the refreshTasks method is called in the constructor.
Which is as follow:
refreshTasks() {
var self = this;
this.db.getDocumentClient().query({
'TableName': self.taskTable,
'IndexName': 'DateSorted',
'KeyConditionExpression': "#userId = :userId",
'ExpressionAttributeNames': {
'#userId': 'userId',
},
'ExpressionAttributeValues': {
//':userId': self.user.getUser().getUsername(),
':userId': AWS.config.credentials.identityId
},
'ScanIndexForward': false
}).promise().then((data) => {
this.items = data.Items;
if (this.refresher) {
this.refresher.complete();
}
}).catch((err) => {
console.log(err);
});
}
The DynamoDB Provider is used to access the document client.
We request the tasks that belong to the user by passing the userId. The results are ordered by creation date because we use the index that was declared in the aws-config.js file:
"indexes":[{"indexName":"DateSorted","hashKey":"userId","rangeKey":"created"}]
All of this is quite complicated, this generally takes one or two lines of code with MongoDB ?
This query returns a promise method that’s resolved with the data. Those data are then stocked in the items property. If a refresh is in progress, it’s stopped.
Taking a step back, a Task Service could be used here, typically calling taskService.getTasks().
Let’s take a break and go easy with this small method:
deleteTask(task, index) {
let self = this;
this.db.getDocumentClient().delete({
'TableName': self.taskTable,
'Key': {
'userId': AWS.config.credentials.identityId,
'taskId': task.taskId
}
}).promise().then((data) => {
this.items.splice(index, 1);
}).catch((err) => {
console.log('there was an error', err);
});
}
Once again using the document client: the delete method.
Much simpler, we need the table’s name, the user’s ID and the task’s ID. Once the task is deleted in AWS, the item needs to be deleted locally in the application, the splice method is good enough for this job.
The last action on this page is the addTask method:
addTask() {
let id = this.generateId();
let addModal = this.modalCtrl.create(TasksCreatePage, { 'id': id });
let self = this;
addModal.onDidDismiss(item => {
if (item) {
item.userId = AWS.config.credentials.identityId;
item.created = (new Date().getTime() / 1000);
self.db.getDocumentClient().put({
'TableName': self.taskTable,
'Item': item,
'ConditionExpression': 'attribute_not_exists(id)'
}, function(err, data) {
if (err) { console.log(err); }
self.refreshTasks();
});
}
})
addModal.present();
}
The generateId method is used to create a unique task ID. Nothing really interesting to comment on this method, let’s focus on addTask ?.
The modalCtrl and TasksCreatePage that were imported earlier are finally used with the task’s ID.
Skipping the task creation for now, a callback is created to handle when the modal dismiss Event is triggered, this callback receives an item that will be stocked in the remote database.
The user’s ID and created date are added to this item before using the document client to put it the database.
Finally the modal is shown.
The last feature: the task creation.
Let’s start with the TypeScript file.
The tasks-create.ts file:
export class TasksCreatePage {
isReadyToSave: boolean;
item: any;
isAndroid: boolean;
constructor(public navCtrl: NavController,
public navParams: NavParams,
public viewCtrl: ViewController,
public platform: Platform) {
this.isAndroid = platform.is('android');
this.item = {
'taskId': navParams.get('id'),
'category': 'Todo'
};
this.isReadyToSave = true;
}
.
.
.
}
The isReadyToSave property can be ignored, it’s not used anywhere.
The item property will contain our new task and the isAndroid boolean will help us in the UI to make some platform specific changes.
This boolean is set in the constructor by using Ionic’s Platform is method with the ‘android’ parameter.
By default the item’s category is set to ‘Todo’, the task’s ID is retrieved from the navParams.
Two simple methods remain:
cancel() {
this.viewCtrl.dismiss();
}
done() {
this.viewCtrl.dismiss(this.item);
}
The cancel method will just use the viewCtrl to dismiss the modal and the done method will do the same, however, it will return the new task.
Moving to the tasks-create.html file:
<ion-header>
<ion-navbar>
<ion-title>New Task</ion-title>
<ion-buttons end>
<button ion-button [attr.icon-only]="!isAndroid ? null : ''" (click)="cancel()">
<span color="primary" showWhen="ios">
Cancel
</span>
<ion-icon name="md-close" showWhen="core,android,windows"></ion-icon>
</button>
</ion-buttons>
</ion-navbar>
</ion-header>
Taking a closer look at the <ion-header> Element, we can see that the Ionic Team cared a lot about the details (as we all should in our applications).
By using the showWhen attribute. The iOS platform will only display the ‘Cancel’ label.
On the other platforms, a button using the icon-only attribute will be coupled with the X icon.
On every platforms, a tap on the button will trigger the cancel method.
Below this header, the form is displayed:
<ion-content>
<ion-list>
<ion-item>
<ion-label>Category</ion-label>
<ion-select [(ngModel)]="item.category" name="category">
<ion-option value="todo" selected="true">Todo</ion-option>
<ion-option value="errand">Errand</ion-option>
</ion-select>
</ion-item>
<ion-item class="custom-item">
<ion-label>Task Description</ion-label>
<ion-textarea rows="5"
[(ngModel)]="item.description" name="description">
</ion-textarea>
</ion-item>
<div padding>
<button block icon-left ion-button color="primary" (click)="done()">
<ion-icon name="md-checkmark"
showWhen="core,android,windows"></ion-icon>
Create task
</button>
</div>
</ion-list>
</ion-content>
A traditional form that will put the data in the item property.
At the end of this form, the ‘Create task’ button that will trigger the done method and Voila!
We have our new task.
Conclusion
Many AWS Services covered in this application:
- Cognito for authentication
- DynamoDB for the NoSQL database
- S3 for stocking the pictures
And it’s only the beginning, the AWS Mobile Hub is still in beta so we can expect new features to pop up!
The AWS sdk is a pure JavaScript library, this collaboration might lead to an Angular AWS library or an Ionic AWS plugin.
It’s very easy to create the backend services with the Mobile Hub, however, sometime those services aren’t enough and we might need some custom backend APIs.
This is possible with the Amazon API Gateway and that’s exactly what we will see in the next tutorial!
Until next time.