|
@@ -0,0 +1,911 @@
|
|
1
|
+# Spring Boot, Ionic, and Stormpath
|
|
2
|
+
|
|
3
|
+This tutorial shows how to build a secure Spring Boot API with Stormpath. It also shows how to build an Ionic app that securely connects to this API and can be deployed to a mobile device.
|
|
4
|
+
|
|
5
|
+**Prerequisites**: Java 8, Node.js, Maven, a [Stormpath Account](https://api.stormpath.com/register), and an `apiKey.properties` file in `~/stormpath/`.
|
|
6
|
+
|
|
7
|
+## Spring Boot API
|
|
8
|
+
|
|
9
|
+Create your Spring Boot API project using [start.spring.io](https://start.spring.io).
|
|
10
|
+
|
|
11
|
+```
|
|
12
|
+http https://start.spring.io/starter.zip \
|
|
13
|
+dependencies==data-jpa,data-rest,h2,web,devtools,security,stormpath -d
|
|
14
|
+```
|
|
15
|
+
|
|
16
|
+Run the application with `./mvnw spring-boot:run`.
|
|
17
|
+
|
|
18
|
+Create a `Beer` entity class in `src/main/java/com/example/beer`.
|
|
19
|
+
|
|
20
|
+→ **boot-entity**
|
|
21
|
+
|
|
22
|
+```java
|
|
23
|
+package com.example.beer;
|
|
24
|
+
|
|
25
|
+import javax.persistence.Entity;
|
|
26
|
+import javax.persistence.GeneratedValue;
|
|
27
|
+import javax.persistence.Id;
|
|
28
|
+
|
|
29
|
+@Entity
|
|
30
|
+public class Beer {
|
|
31
|
+
|
|
32
|
+ @Id
|
|
33
|
+ @GeneratedValue
|
|
34
|
+ private Long id;
|
|
35
|
+ private String name;
|
|
36
|
+
|
|
37
|
+ public Beer() {
|
|
38
|
+ }
|
|
39
|
+
|
|
40
|
+ public Beer(String name) {
|
|
41
|
+ this.name = name;
|
|
42
|
+ }
|
|
43
|
+
|
|
44
|
+ public Long getId() {
|
|
45
|
+ return id;
|
|
46
|
+ }
|
|
47
|
+
|
|
48
|
+ public void setId(Long id) {
|
|
49
|
+ this.id = id;
|
|
50
|
+ }
|
|
51
|
+
|
|
52
|
+ public String getName() {
|
|
53
|
+ return name;
|
|
54
|
+ }
|
|
55
|
+
|
|
56
|
+ public void setName(String name) {
|
|
57
|
+ this.name = name;
|
|
58
|
+ }
|
|
59
|
+
|
|
60
|
+ @Override
|
|
61
|
+ public String toString() {
|
|
62
|
+ return "Beer{" +
|
|
63
|
+ "id=" + id +
|
|
64
|
+ ", name='" + name + '\'' +
|
|
65
|
+ '}';
|
|
66
|
+ }
|
|
67
|
+}
|
|
68
|
+```
|
|
69
|
+
|
|
70
|
+Create a JPA Repository to manage the `Beer` entity.
|
|
71
|
+
|
|
72
|
+→ **boot-repository**
|
|
73
|
+
|
|
74
|
+```java
|
|
75
|
+package com.example.beer;
|
|
76
|
+
|
|
77
|
+import org.springframework.data.jpa.repository.JpaRepository;
|
|
78
|
+import org.springframework.data.rest.core.annotation.RepositoryRestResource;
|
|
79
|
+
|
|
80
|
+@RepositoryRestResource
|
|
81
|
+interface BeerRepository extends JpaRepository<Beer, Long> {
|
|
82
|
+}
|
|
83
|
+```
|
|
84
|
+
|
|
85
|
+Create a CommandLineRunner to populate the database.
|
|
86
|
+
|
|
87
|
+→ **boot-command**
|
|
88
|
+
|
|
89
|
+```java
|
|
90
|
+package com.example.beer;
|
|
91
|
+
|
|
92
|
+import org.springframework.boot.CommandLineRunner;
|
|
93
|
+import org.springframework.stereotype.Component;
|
|
94
|
+
|
|
95
|
+import java.util.stream.Stream;
|
|
96
|
+
|
|
97
|
+@Component
|
|
98
|
+class BeerCommandLineRunner implements CommandLineRunner {
|
|
99
|
+ private final BeerRepository repository;
|
|
100
|
+
|
|
101
|
+ public BeerCommandLineRunner(BeerRepository repository) {
|
|
102
|
+ this.repository = repository;
|
|
103
|
+ }
|
|
104
|
+
|
|
105
|
+ @Override
|
|
106
|
+ public void run(String... strings) throws Exception {
|
|
107
|
+ System.out.println(repository.findAll());
|
|
108
|
+ }
|
|
109
|
+}
|
|
110
|
+```
|
|
111
|
+
|
|
112
|
+Add default data in the `run()` method:
|
|
113
|
+
|
|
114
|
+→ **boot-add**
|
|
115
|
+
|
|
116
|
+```java
|
|
117
|
+// top 5 beers from https://www.beeradvocate.com/lists/top/
|
|
118
|
+Stream.of("Good Morning", "Kentucky Brunch Brand Stout", "ManBearPig", "King Julius",
|
|
119
|
+ "Very Hazy", "Budweiser", "Coors Light", "PBR").forEach(name ->
|
|
120
|
+ repository.save(new Beer(name))
|
|
121
|
+);
|
|
122
|
+```
|
|
123
|
+
|
|
124
|
+Create a `BeerController` for your REST API. Add some business logic that results in a `/good-beers` endpoint.
|
|
125
|
+
|
|
126
|
+→ **boot-controller**
|
|
127
|
+
|
|
128
|
+```java
|
|
129
|
+package com.example.beer;
|
|
130
|
+
|
|
131
|
+import org.springframework.web.bind.annotation.GetMapping;
|
|
132
|
+import org.springframework.web.bind.annotation.RestController;
|
|
133
|
+
|
|
134
|
+import java.util.Collection;
|
|
135
|
+import java.util.HashMap;
|
|
136
|
+import java.util.Map;
|
|
137
|
+import java.util.stream.Collectors;
|
|
138
|
+
|
|
139
|
+@RestController
|
|
140
|
+public class BeerController {
|
|
141
|
+ private BeerRepository repository;
|
|
142
|
+
|
|
143
|
+ public BeerController(BeerRepository repository) {
|
|
144
|
+ this.repository = repository;
|
|
145
|
+ }
|
|
146
|
+
|
|
147
|
+ @GetMapping("/list-beers")
|
|
148
|
+ public Collection<Beer> list() {
|
|
149
|
+ return repository.findAll();
|
|
150
|
+ }
|
|
151
|
+}
|
|
152
|
+```
|
|
153
|
+
|
|
154
|
+Add a `/get-beers` mapping that filters out beers that aren't great.
|
|
155
|
+
|
|
156
|
+→ **boot-good**
|
|
157
|
+
|
|
158
|
+```java
|
|
159
|
+@GetMapping("/good-beers")
|
|
160
|
+public Collection<Map<String, String>> goodBeers() {
|
|
161
|
+
|
|
162
|
+ return repository.findAll().stream()
|
|
163
|
+ .filter(this::isGreat)
|
|
164
|
+ .map(b -> {
|
|
165
|
+ Map<String, String> m = new HashMap<>();
|
|
166
|
+ m.put("id", b.getId().toString());
|
|
167
|
+ m.put("name", b.getName());
|
|
168
|
+ return m;
|
|
169
|
+ }).collect(Collectors.toList());
|
|
170
|
+}
|
|
171
|
+
|
|
172
|
+private boolean isGreat(Beer beer) {
|
|
173
|
+ return !beer.getName().equals("Budweiser") &&
|
|
174
|
+ !beer.getName().equals("Coors Light") &&
|
|
175
|
+ !beer.getName().equals("PBR");
|
|
176
|
+}
|
|
177
|
+```
|
|
178
|
+
|
|
179
|
+Access the API using `http localhost:8080/good-beers --auth <user>:<password>`.
|
|
180
|
+
|
|
181
|
+## Create Ionic App
|
|
182
|
+
|
|
183
|
+Install Ionic and Cordova:
|
|
184
|
+
|
|
185
|
+```
|
|
186
|
+yarn global add cordova ionic
|
|
187
|
+```
|
|
188
|
+
|
|
189
|
+From a terminal window, create a new application using the following command:
|
|
190
|
+
|
|
191
|
+```
|
|
192
|
+ionic start ionic-beer --v2
|
|
193
|
+```
|
|
194
|
+
|
|
195
|
+This may take a minute or two to complete.
|
|
196
|
+```
|
|
197
|
+cd ionic-beer
|
|
198
|
+ionic serve
|
|
199
|
+```
|
|
200
|
+
|
|
201
|
+## Upgrade to Angular 2.3
|
|
202
|
+
|
|
203
|
+```json
|
|
204
|
+"dependencies": {
|
|
205
|
+ "@angular/common": "2.3.1",
|
|
206
|
+ "@angular/compiler": "2.3.1",
|
|
207
|
+ "@angular/compiler-cli": "2.3.1",
|
|
208
|
+ "@angular/core": "2.3.1",
|
|
209
|
+ "@angular/forms": "2.3.1",
|
|
210
|
+ "@angular/http": "2.3.1",
|
|
211
|
+ "@angular/platform-browser": "2.3.1",
|
|
212
|
+ "@angular/platform-browser-dynamic": "2.3.1",
|
|
213
|
+ "@angular/platform-server": "2.3.1",
|
|
214
|
+```
|
|
215
|
+
|
|
216
|
+Run `yarn` to update to these versions.
|
|
217
|
+
|
|
218
|
+## Install Ionic Pages for Stormpath
|
|
219
|
+
|
|
220
|
+Install [Ionic pages for Stormpath](https://github.com/stormpath/stormpath-sdk-angular-ionic):
|
|
221
|
+
|
|
222
|
+```
|
|
223
|
+yarn add angular-stormpath-ionic
|
|
224
|
+```
|
|
225
|
+
|
|
226
|
+Modify `src/app/app.module.ts` to define a `stormpathConfig` function that configures the `endpointPrefix` to point to `http://localhost:8080`. Import `StormpathModule`, `StormpathIonicModule`, and override the provider of `StormpathConfiguration`. You’ll also need to append Stormpath's pre-built Ionic pages to `entryComponents`.
|
|
227
|
+
|
|
228
|
+→ **stormpath-config**
|
|
229
|
+
|
|
230
|
+```typescript
|
|
231
|
+import { StormpathConfiguration, StormpathModule } from 'angular-stormpath';
|
|
232
|
+import { StormpathIonicModule, LoginPage, ForgotPasswordPage, RegisterPage } from 'angular-stormpath-ionic';
|
|
233
|
+
|
|
234
|
+export function stormpathConfig(): StormpathConfiguration {
|
|
235
|
+ let spConfig: StormpathConfiguration = new StormpathConfiguration();
|
|
236
|
+ spConfig.endpointPrefix = 'http://localhost:8080';
|
|
237
|
+ return spConfig;
|
|
238
|
+}
|
|
239
|
+
|
|
240
|
+@NgModule({
|
|
241
|
+ ...
|
|
242
|
+ imports: [
|
|
243
|
+ IonicModule.forRoot(MyApp),
|
|
244
|
+ StormpathModule,
|
|
245
|
+ StormpathIonicModule
|
|
246
|
+ ],
|
|
247
|
+ bootstrap: [IonicApp],
|
|
248
|
+ entryComponents: [
|
|
249
|
+ ...
|
|
250
|
+ LoginPage,
|
|
251
|
+ ForgotPasswordPage,
|
|
252
|
+ RegisterPage
|
|
253
|
+ ],
|
|
254
|
+ providers: [
|
|
255
|
+ {provide: ErrorHandler, useClass: IonicErrorHandler},
|
|
256
|
+ {provide: StormpathConfiguration, useFactory: stormpathConfig}
|
|
257
|
+ ]
|
|
258
|
+})
|
|
259
|
+export class AppModule {}
|
|
260
|
+```
|
|
261
|
+
|
|
262
|
+To render a login page before users can view the application, modify `src/app/app.component.ts` to use the `Stormpath` service and navigate to Stormpath's `LoginPage` if the user is not authenticated.
|
|
263
|
+
|
|
264
|
+→ **io-app**
|
|
265
|
+
|
|
266
|
+```typescript
|
|
267
|
+import { Component } from '@angular/core';
|
|
268
|
+import { Platform } from 'ionic-angular';
|
|
269
|
+import { StatusBar, Splashscreen } from 'ionic-native';
|
|
270
|
+import { TabsPage } from '../pages/tabs/tabs';
|
|
271
|
+import { Stormpath } from 'angular-stormpath';
|
|
272
|
+import { LoginPage } from 'angular-stormpath-ionic';
|
|
273
|
+
|
|
274
|
+@Component({
|
|
275
|
+ templateUrl: 'app.html'
|
|
276
|
+})
|
|
277
|
+export class MyApp {
|
|
278
|
+ rootPage;
|
|
279
|
+
|
|
280
|
+ constructor(platform: Platform, private stormpath: Stormpath) {
|
|
281
|
+ stormpath.user$.subscribe(user => {
|
|
282
|
+ if (!user) {
|
|
283
|
+ this.rootPage = LoginPage;
|
|
284
|
+ } else {
|
|
285
|
+ this.rootPage = TabsPage;
|
|
286
|
+ }
|
|
287
|
+ });
|
|
288
|
+ }
|
|
289
|
+}
|
|
290
|
+```
|
|
291
|
+
|
|
292
|
+If you run `ionic serve`, you’ll likely see something similar to the following error in your browser’s console.
|
|
293
|
+
|
|
294
|
+```
|
|
295
|
+XMLHttpRequest cannot load http://localhost:8080/me. Response to preflight request
|
|
296
|
+doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on
|
|
297
|
+the requested resource. Origin 'http://localhost:8100 is therefore not allowed access.
|
|
298
|
+The response had HTTP status code 403.
|
|
299
|
+```
|
|
300
|
+
|
|
301
|
+To fix this, open your Spring Boot application's `src/main/resources/application.properties` and add the following line. This enables cross-origin resource sharing (CORS) from both the browser and the mobile client.
|
|
302
|
+
|
|
303
|
+```
|
|
304
|
+stormpath.web.cors.allowed.originUris = http://localhost:8100,file://
|
|
305
|
+```
|
|
306
|
+
|
|
307
|
+Restart Spring Boot and your Ionic app.
|
|
308
|
+
|
|
309
|
+In `src/pages/home.html`, add a logout link to the header and a paragraph in the content section that shows the currently logged in user.
|
|
310
|
+
|
|
311
|
+→ **io-logout**
|
|
312
|
+
|
|
313
|
+```html
|
|
314
|
+<ion-header>
|
|
315
|
+ <ion-navbar>
|
|
316
|
+ <ion-title>Home</ion-title>
|
|
317
|
+ <ion-buttons end>
|
|
318
|
+ <button ion-button icon-only (click)="logout()">
|
|
319
|
+ Logout
|
|
320
|
+ </button>
|
|
321
|
+ </ion-buttons>
|
|
322
|
+ </ion-navbar>
|
|
323
|
+</ion-header>
|
|
324
|
+```
|
|
325
|
+
|
|
326
|
+→ **io-username**
|
|
327
|
+
|
|
328
|
+```html
|
|
329
|
+<ion-content padding>
|
|
330
|
+ ...
|
|
331
|
+ <p *ngIf="(user$ | async)">
|
|
332
|
+ You are logged in as: <b>{{ ( user$ | async ).fullName }}</b>
|
|
333
|
+ </p>
|
|
334
|
+</ion-content>
|
|
335
|
+```
|
|
336
|
+
|
|
337
|
+If you login, the “Logout” button will render, but won’t work because there’s no `logout()` method in `src/pages/home.ts`. Similarly, the “You are logged in” message won’t appear because there’s no `user$` variable defined. Change the body of `home.ts` to retrieve `user$` from the `Stormpath` service and define the `logout()` method.
|
|
338
|
+
|
|
339
|
+→ **io-home**
|
|
340
|
+
|
|
341
|
+```typescript
|
|
342
|
+import { Account, Stormpath } from 'angular-stormpath';
|
|
343
|
+import { Observable } from 'rxjs';
|
|
344
|
+...
|
|
345
|
+export class HomePage {
|
|
346
|
+ user$: Observable<Account | boolean>;
|
|
347
|
+
|
|
348
|
+ constructor(private stormpath: Stormpath) {
|
|
349
|
+ this.user$ = this.stormpath.user$;
|
|
350
|
+ }
|
|
351
|
+
|
|
352
|
+ logout(): void {
|
|
353
|
+ this.stormpath.logout();
|
|
354
|
+ }
|
|
355
|
+}
|
|
356
|
+```
|
|
357
|
+
|
|
358
|
+If you’re logged in, you should see a screen with a logout button and the name of the currently logged in user.
|
|
359
|
+
|
|
360
|
+The `LoginPage` tries to auto-focus onto the `email` field when it loads. To auto-activate the keyboard you'll need to tell Cordova it’s OK to display the keyboard without user interaction. You can do this by adding the following to `config.xml` in the root directory.
|
|
361
|
+
|
|
362
|
+→ **io-keyboard**
|
|
363
|
+
|
|
364
|
+```xml
|
|
365
|
+<preference name="KeyboardDisplayRequiresUserAction" value="false"/>
|
|
366
|
+```
|
|
367
|
+
|
|
368
|
+Check your changes into Git.
|
|
369
|
+
|
|
370
|
+```
|
|
371
|
+git add .
|
|
372
|
+git commit -m "Add Stormpath"
|
|
373
|
+```
|
|
374
|
+
|
|
375
|
+## Build a Good Beers UI
|
|
376
|
+
|
|
377
|
+Run `ionic generate page beer` to create a component and a template to display the list of good beers.
|
|
378
|
+
|
|
379
|
+Add `BeerPage` to the `declarations` and `entryComponent` lists in `app.module.ts`.
|
|
380
|
+
|
|
381
|
+Run `ionic generate provider beer-service` to create a service to fetch the beer list from the Spring Boot API.
|
|
382
|
+
|
|
383
|
+Change `src/providers/beer-service.ts` to use have a `getGoodBeers()` method.
|
|
384
|
+
|
|
385
|
+→ **io-service**
|
|
386
|
+
|
|
387
|
+```typescript
|
|
388
|
+import { Injectable } from '@angular/core';
|
|
389
|
+import { Http, Response } from '@angular/http';
|
|
390
|
+import 'rxjs/add/operator/map';
|
|
391
|
+import { Observable } from 'rxjs';
|
|
392
|
+import { StormpathConfiguration } from 'angular-stormpath';
|
|
393
|
+
|
|
394
|
+@Injectable()
|
|
395
|
+export class BeerService {
|
|
396
|
+ public API;
|
|
397
|
+ public BEER_API;
|
|
398
|
+
|
|
399
|
+ constructor(public http: Http, public config: StormpathConfiguration) {
|
|
400
|
+ this.API = config.endpointPrefix;
|
|
401
|
+ this.BEER_API = this.API + '/beers';
|
|
402
|
+ }
|
|
403
|
+
|
|
404
|
+ getGoodBeers(): Observable<any> {
|
|
405
|
+ return this.http.get(this.API + '/good-beers')
|
|
406
|
+ .map((response: Response) => response.json());
|
|
407
|
+ }
|
|
408
|
+}
|
|
409
|
+```
|
|
410
|
+
|
|
411
|
+Modify `beer.html` to show the list of beers.
|
|
412
|
+
|
|
413
|
+→ **io-beer-list**
|
|
414
|
+
|
|
415
|
+```html
|
|
416
|
+<ion-header>
|
|
417
|
+ <ion-navbar>
|
|
418
|
+ <ion-title>Good Beers</ion-title>
|
|
419
|
+ </ion-navbar>
|
|
420
|
+
|
|
421
|
+</ion-header>
|
|
422
|
+
|
|
423
|
+<ion-content padding>
|
|
424
|
+ <ion-list>
|
|
425
|
+ <ion-item *ngFor="let beer of beers" >
|
|
426
|
+ <ion-item>
|
|
427
|
+ <h2>{{beer.name}}</h2>
|
|
428
|
+ </ion-item>
|
|
429
|
+ </ion-item>
|
|
430
|
+ </ion-list>
|
|
431
|
+</ion-content>
|
|
432
|
+```
|
|
433
|
+
|
|
434
|
+Update `beer.ts` to import `BeerService` and add as a provider. Call the `getGoodBeers()` method in the `ionViewDidLoad()` lifecycle method.
|
|
435
|
+
|
|
436
|
+```typescript
|
|
437
|
+import { Component } from '@angular/core';
|
|
438
|
+import { BeerService } from '../../providers/beer-service';
|
|
439
|
+
|
|
440
|
+@Component({
|
|
441
|
+ selector: 'page-beer',
|
|
442
|
+ templateUrl: 'beer.html',
|
|
443
|
+ providers: [BeerService]
|
|
444
|
+})
|
|
445
|
+export class BeerPage {
|
|
446
|
+ beers: Array<any>;
|
|
447
|
+
|
|
448
|
+ constructor(public beerService: BeerService) {
|
|
449
|
+ }
|
|
450
|
+
|
|
451
|
+ ionViewDidLoad() {
|
|
452
|
+ this.beerService.getGoodBeers().subscribe(beers => {
|
|
453
|
+ this.beers = beers;
|
|
454
|
+ })
|
|
455
|
+ }
|
|
456
|
+}
|
|
457
|
+```
|
|
458
|
+
|
|
459
|
+To expose this page on the tab bar, add it to `tabs.ts`
|
|
460
|
+
|
|
461
|
+```typescript
|
|
462
|
+import { Component } from '@angular/core';
|
|
463
|
+
|
|
464
|
+import { HomePage } from '../home/home';
|
|
465
|
+import { AboutPage } from '../about/about';
|
|
466
|
+import { ContactPage } from '../contact/contact';
|
|
467
|
+import { BeerPage } from '../beer/beer';
|
|
468
|
+
|
|
469
|
+@Component({
|
|
470
|
+ templateUrl: 'tabs.html'
|
|
471
|
+})
|
|
472
|
+export class TabsPage {
|
|
473
|
+ // this tells the tabs component which Pages
|
|
474
|
+ // should be each tab's root Page
|
|
475
|
+ tab1Root: any = HomePage;
|
|
476
|
+ tab2Root: any = BeerPage;
|
|
477
|
+ tab3Root: any = ContactPage;
|
|
478
|
+ tab4Root: any = AboutPage;
|
|
479
|
+
|
|
480
|
+ constructor() {
|
|
481
|
+ }
|
|
482
|
+}
|
|
483
|
+```
|
|
484
|
+
|
|
485
|
+Update `tabs.html` too!
|
|
486
|
+
|
|
487
|
+```html
|
|
488
|
+<ion-tabs>
|
|
489
|
+ <ion-tab [root]="tab1Root" tabTitle="Home" tabIcon="home"></ion-tab>
|
|
490
|
+ <ion-tab [root]="tab2Root" tabTitle="Beer" tabIcon="beer"></ion-tab>
|
|
491
|
+ <ion-tab [root]="tab3Root" tabTitle="Contact" tabIcon="contacts"></ion-tab>
|
|
492
|
+ <ion-tab [root]="tab4Root" tabTitle="About" tabIcon="information-circle"></ion-tab>
|
|
493
|
+</ion-tabs>
|
|
494
|
+```
|
|
495
|
+
|
|
496
|
+Add some fun with Giphy! Run `ionic generate provider giphy-service`. Replace the code in `src/providers/giphy-service.ts` with the following TypeScript:
|
|
497
|
+
|
|
498
|
+→ **ng-giphy-service**
|
|
499
|
+
|
|
500
|
+```typescript
|
|
501
|
+import { Injectable } from '@angular/core';
|
|
502
|
+import { Http, Response } from '@angular/http';
|
|
503
|
+import { Observable } from 'rxjs';
|
|
504
|
+
|
|
505
|
+@Injectable()
|
|
506
|
+// http://tutorials.pluralsight.com/front-end-javascript/getting-started-with-angular-2-by-building-a-giphy-search-application
|
|
507
|
+export class GiphyService {
|
|
508
|
+
|
|
509
|
+ giphyApi = 'https://api.giphy.com/v1/gifs/search?api_key=dc6zaTOxFJmzC&q=';
|
|
510
|
+
|
|
511
|
+ constructor(public http: Http) {
|
|
512
|
+ }
|
|
513
|
+
|
|
514
|
+ get(searchTerm): Observable<any> {
|
|
515
|
+ let apiLink = this.giphyApi + searchTerm;
|
|
516
|
+ return this.http.request(apiLink).map((res: Response) => {
|
|
517
|
+ let results = res.json().data;
|
|
518
|
+ if (results.length > 0) {
|
|
519
|
+ return results[0].images.original.url;
|
|
520
|
+ } else {
|
|
521
|
+ return 'https://media.giphy.com/media/YaOxRsmrv9IeA/giphy.gif'; // dancing cat for 404
|
|
522
|
+ }
|
|
523
|
+ });
|
|
524
|
+ }
|
|
525
|
+}
|
|
526
|
+```
|
|
527
|
+
|
|
528
|
+Update `beer.ts` to take advantage of `GiphyService`:
|
|
529
|
+
|
|
530
|
+→ **ng-giphy-foreach**
|
|
531
|
+
|
|
532
|
+```typescript
|
|
533
|
+import { Component } from '@angular/core';
|
|
534
|
+import { BeerService } from '../../providers/beer-service';
|
|
535
|
+import { GiphyService } from '../../providers/giphy-service';
|
|
536
|
+
|
|
537
|
+@Component({
|
|
538
|
+ selector: 'page-beer',
|
|
539
|
+ templateUrl: 'beer.html',
|
|
540
|
+ providers: [BeerService, GiphyService]
|
|
541
|
+})
|
|
542
|
+export class BeerPage {
|
|
543
|
+ private beers: Array<any>;
|
|
544
|
+
|
|
545
|
+ constructor(public beerService: BeerService, public giphyService: GiphyService) {
|
|
546
|
+ }
|
|
547
|
+
|
|
548
|
+ ionViewDidLoad() {
|
|
549
|
+ this.beerService.getGoodBeers().subscribe(beers => {
|
|
550
|
+ this.beers = beers;
|
|
551
|
+ for (let beer of this.beers) {
|
|
552
|
+ this.giphyService.get(beer.name).subscribe(url => {
|
|
553
|
+ beer.giphyUrl = url
|
|
554
|
+ });
|
|
555
|
+ }
|
|
556
|
+ })
|
|
557
|
+ }
|
|
558
|
+}
|
|
559
|
+```
|
|
560
|
+
|
|
561
|
+Update `beer.html` to display the image retrieved:
|
|
562
|
+
|
|
563
|
+→ **io-avatar**
|
|
564
|
+
|
|
565
|
+```html
|
|
566
|
+<ion-item>
|
|
567
|
+ <ion-avatar item-left>
|
|
568
|
+ <img src="{{beer.giphyUrl}}">
|
|
569
|
+ </ion-avatar>
|
|
570
|
+ <h2>{{beer.name}}</h2>
|
|
571
|
+</ion-item>
|
|
572
|
+```
|
|
573
|
+
|
|
574
|
+If everything works as expected, you should see a page with a list of beers and images.
|
|
575
|
+
|
|
576
|
+### Add a Modal for Editing
|
|
577
|
+
|
|
578
|
+Change the header in `beer.html` to have a button that opens a modal to add a new beer.
|
|
579
|
+
|
|
580
|
+→ **io-open-modal**
|
|
581
|
+
|
|
582
|
+```html
|
|
583
|
+<ion-header>
|
|
584
|
+ <ion-navbar>
|
|
585
|
+ <ion-title>Good Beers</ion-title>
|
|
586
|
+ <ion-buttons end>
|
|
587
|
+ <button ion-button icon-only (click)="openModal()" color="primary">
|
|
588
|
+ <ion-icon name="add-circle"></ion-icon>
|
|
589
|
+ <ion-icon name="beer"></ion-icon>
|
|
590
|
+ </button>
|
|
591
|
+ </ion-buttons>
|
|
592
|
+ </ion-navbar>
|
|
593
|
+```
|
|
594
|
+
|
|
595
|
+In this same file, change `<ion-item>` to have a click handler for opening the modal for the current item.
|
|
596
|
+
|
|
597
|
+```html
|
|
598
|
+<ion-item (click)="openModal({id: beer.id})">
|
|
599
|
+```
|
|
600
|
+
|
|
601
|
+Add `ModalController` as a dependency in `BeerPage` and add an `openModal()` method.
|
|
602
|
+
|
|
603
|
+→ **io-open-modal-ts**
|
|
604
|
+
|
|
605
|
+```typescript
|
|
606
|
+export class BeerPage {
|
|
607
|
+ private beers: Array<any>;
|
|
608
|
+
|
|
609
|
+ constructor(public beerService: BeerService, public giphyService: GiphyService,
|
|
610
|
+ public modalCtrl: ModalController) {
|
|
611
|
+ }
|
|
612
|
+
|
|
613
|
+ // ionViewDidLoad method
|
|
614
|
+
|
|
615
|
+ openModal(beerId) {
|
|
616
|
+ let modal = this.modalCtrl.create(BeerModalPage, beerId);
|
|
617
|
+ modal.present();
|
|
618
|
+ // refresh data after modal dismissed
|
|
619
|
+ modal.onDidDismiss(() => this.ionViewDidLoad())
|
|
620
|
+ }
|
|
621
|
+}
|
|
622
|
+```
|
|
623
|
+
|
|
624
|
+This won't compile because `BeerModalPage` doesn't exist. Create `beer-modal.ts` in the same directory. This page will retrieve the beer from the `beerId` that's passed in. It will render the name, allow it to be edited, and show the Giphy image found for the name.
|
|
625
|
+
|
|
626
|
+→ **io-beer-modal**
|
|
627
|
+
|
|
628
|
+```typescript
|
|
629
|
+import { BeerService } from '../../providers/beer-service';
|
|
630
|
+import { Component, ViewChild } from '@angular/core';
|
|
631
|
+import { GiphyService } from '../../providers/giphy-service';
|
|
632
|
+import { NavParams, ViewController, ToastController, NavController } from 'ionic-angular';
|
|
633
|
+import { NgForm } from '@angular/forms';
|
|
634
|
+
|
|
635
|
+@Component({
|
|
636
|
+ templateUrl: './beer-modal.html'
|
|
637
|
+})
|
|
638
|
+export class BeerModalPage {
|
|
639
|
+ @ViewChild('name') name;
|
|
640
|
+ beer: any = {};
|
|
641
|
+ error: any;
|
|
642
|
+
|
|
643
|
+ constructor(public beerService: BeerService,
|
|
644
|
+ public giphyService: GiphyService,
|
|
645
|
+ public params: NavParams,
|
|
646
|
+ public viewCtrl: ViewController,
|
|
647
|
+ public toastCtrl: ToastController,
|
|
648
|
+ public navCtrl: NavController) {
|
|
649
|
+ if (this.params.data.id) {
|
|
650
|
+ this.beerService.get(this.params.get('id')).subscribe(beer => {
|
|
651
|
+ this.beer = beer;
|
|
652
|
+ this.beer.href = beer._links.self.href;
|
|
653
|
+ this.giphyService.get(beer.name).subscribe(url => beer.giphyUrl = url);
|
|
654
|
+ });
|
|
655
|
+ }
|
|
656
|
+ }
|
|
657
|
+
|
|
658
|
+ dismiss() {
|
|
659
|
+ this.viewCtrl.dismiss();
|
|
660
|
+ }
|
|
661
|
+
|
|
662
|
+ save(form: NgForm) {
|
|
663
|
+ let update: boolean = form['href'];
|
|
664
|
+ this.beerService.save(form).subscribe(result => {
|
|
665
|
+ let toast = this.toastCtrl.create({
|
|
666
|
+ message: 'Beer "' + form.name + '" ' + ((update) ? 'updated' : 'added') + '.',
|
|
667
|
+ duration: 2000
|
|
668
|
+ });
|
|
669
|
+ toast.present();
|
|
670
|
+ this.dismiss();
|
|
671
|
+ }, error => this.error = error)
|
|
672
|
+ }
|
|
673
|
+
|
|
674
|
+ ionViewDidLoad() {
|
|
675
|
+ setTimeout(() => {
|
|
676
|
+ this.name.setFocus();
|
|
677
|
+ },150);
|
|
678
|
+ }
|
|
679
|
+}
|
|
680
|
+```
|
|
681
|
+
|
|
682
|
+Create `beer-modal.html` as a template for this page.
|
|
683
|
+
|
|
684
|
+→ **io-beer-modal-html**
|
|
685
|
+
|
|
686
|
+```html
|
|
687
|
+<ion-header>
|
|
688
|
+ <ion-toolbar>
|
|
689
|
+ <ion-title>
|
|
690
|
+ {{beer ? 'Beer Details' : 'Add Beer'}}
|
|
691
|
+ </ion-title>
|
|
692
|
+ <ion-buttons start>
|
|
693
|
+ <button ion-button (click)="dismiss()">
|
|
694
|
+ <span ion-text color="primary" showWhen="ios,core">Cancel</span>
|
|
695
|
+ <ion-icon name="md-close" showWhen="android,windows"></ion-icon>
|
|
696
|
+ </button>
|
|
697
|
+ </ion-buttons>
|
|
698
|
+ </ion-toolbar>
|
|
699
|
+</ion-header>
|
|
700
|
+<ion-content padding>
|
|
701
|
+ <form #beerForm="ngForm" (ngSubmit)="save(beerForm.value)">
|
|
702
|
+ <input type="hidden" name="href" [(ngModel)]="beer.href">
|
|
703
|
+ <ion-row>
|
|
704
|
+ <ion-col>
|
|
705
|
+ <ion-list inset>
|
|
706
|
+ <ion-item>
|
|
707
|
+ <ion-input placeholder="Beer Name" name="name" type="text"
|
|
708
|
+ required [(ngModel)]="beer.name" #name></ion-input>
|
|
709
|
+ </ion-item>
|
|
710
|
+ </ion-list>
|
|
711
|
+ </ion-col>
|
|
712
|
+ </ion-row>
|
|
713
|
+ <ion-row>
|
|
714
|
+ <ion-col *ngIf="beer" text-center>
|
|
715
|
+ <img src="{{beer.giphyUrl}}">
|
|
716
|
+ </ion-col>
|
|
717
|
+ </ion-row>
|
|
718
|
+ <ion-row>
|
|
719
|
+ <ion-col>
|
|
720
|
+ <div *ngIf="error" class="alert alert-danger">{{error}}</div>
|
|
721
|
+ <button ion-button color="primary" full type="submit"
|
|
722
|
+ [disabled]="!beerForm.form.valid">Save</button>
|
|
723
|
+ </ion-col>
|
|
724
|
+ </ion-row>
|
|
725
|
+ </form>
|
|
726
|
+</ion-content>
|
|
727
|
+```
|
|
728
|
+
|
|
729
|
+Add `BeerModalPage` to the `declarations` and `entryComponent` lists in `app.module.ts`.
|
|
730
|
+
|
|
731
|
+You'll also need to modify `beer-service.ts` to have `get()` and `save()` methods.
|
|
732
|
+
|
|
733
|
+→ **io-get-save**
|
|
734
|
+
|
|
735
|
+```typescript
|
|
736
|
+get(id: string) {
|
|
737
|
+ return this.http.get(this.BEER_API + '/' + id)
|
|
738
|
+ .map((response: Response) => response.json());
|
|
739
|
+}
|
|
740
|
+
|
|
741
|
+save(beer: any): Observable<any> {
|
|
742
|
+ let result: Observable<Response>;
|
|
743
|
+ if (beer['href']) {
|
|
744
|
+ result = this.http.put(beer.href, beer);
|
|
745
|
+ } else {
|
|
746
|
+ result = this.http.post(this.BEER_API, beer)
|
|
747
|
+ }
|
|
748
|
+ return result.map((response: Response) => response.json())
|
|
749
|
+ .catch(error => Observable.throw(error));
|
|
750
|
+}
|
|
751
|
+
|
|
752
|
+remove(id: string) {
|
|
753
|
+ return this.http.delete(this.BEER_API + '/' + id)
|
|
754
|
+ .map((response: Response) => response.json());
|
|
755
|
+}
|
|
756
|
+```
|
|
757
|
+
|
|
758
|
+### Add Swipe to Delete
|
|
759
|
+
|
|
760
|
+To add swipe-to-delete functionality on the list of beers, open `beer.html` and make it so `<ion-item-sliding>` wraps `<ion-item>` and contains the `*ngFor`. Add a delete button using `<ion-item-options>`.
|
|
761
|
+
|
|
762
|
+→ **io-swipe**
|
|
763
|
+
|
|
764
|
+```html
|
|
765
|
+<ion-content padding>
|
|
766
|
+ <ion-list>
|
|
767
|
+ <ion-item-sliding *ngFor="let beer of beers">
|
|
768
|
+ <ion-item (click)="openModal({id: beer.id})">
|
|
769
|
+ <ion-avatar item-left>
|
|
770
|
+ <img src="{{beer.giphyUrl}}">
|
|
771
|
+ </ion-avatar>
|
|
772
|
+ <h2>{{beer.name}}</h2>
|
|
773
|
+ </ion-item>
|
|
774
|
+ <ion-item-options>
|
|
775
|
+ <button ion-button color="danger" (click)="remove(beer)"><ion-icon name="trash"></ion-icon> Delete</button>
|
|
776
|
+ </ion-item-options>
|
|
777
|
+ </ion-item-sliding>
|
|
778
|
+ </ion-list>
|
|
779
|
+</ion-content>
|
|
780
|
+```
|
|
781
|
+
|
|
782
|
+Add a `remove()` method to `beer.ts`.
|
|
783
|
+
|
|
784
|
+→ **io-remove**
|
|
785
|
+
|
|
786
|
+```typescript
|
|
787
|
+remove(beer) {
|
|
788
|
+ this.beerService.remove(beer.id).subscribe(response => {
|
|
789
|
+ for (let i = 0; i < this.beers.length; i++) {
|
|
790
|
+ if (this.beers[i] === beer) {
|
|
791
|
+ this.beers.splice(i, 1);
|
|
792
|
+ let toast = this.toastCtrl.create({
|
|
793
|
+ message: 'Beer "' + beer.name + '" deleted.',
|
|
794
|
+ duration: 2000,
|
|
795
|
+ position: 'top'
|
|
796
|
+ });
|
|
797
|
+ toast.present();
|
|
798
|
+ }
|
|
799
|
+ }
|
|
800
|
+ });
|
|
801
|
+}
|
|
802
|
+```
|
|
803
|
+
|
|
804
|
+Add `toastCtrl` as a dependency in the constructor so everything compiles.
|
|
805
|
+
|
|
806
|
+```typescript
|
|
807
|
+constructor(public beerService: BeerService, public giphyService: GiphyService,
|
|
808
|
+ public modalCtrl: ModalController, public toastCtrl: ToastController) {
|
|
809
|
+}
|
|
810
|
+```
|
|
811
|
+
|
|
812
|
+After making these additions, you should be able to add, edit and delete beers.
|
|
813
|
+
|
|
814
|
+<p align="center">
|
|
815
|
+<img src="./static/beer-modal.png" width="350">
|
|
816
|
+<img src="./static/beer-delete.png" width="350">
|
|
817
|
+</div>
|
|
818
|
+
|
|
819
|
+## PWAs with Ionic
|
|
820
|
+
|
|
821
|
+Ionic 2 ships with support for creating progressive web apps (PWAs). If you’d like to learn more about what PWAs are, see [Navigating the World of Progressive Web Apps with Ionic 2](http://blog.ionic.io/navigating-the-world-of-progressive-web-apps-with-ionic-2/).
|
|
822
|
+
|
|
823
|
+If you run the [Lighthouse Chrome extension](https://developers.google.com/web/tools/lighthouse/) on this application, you’ll get a mediocre score (54/100).
|
|
824
|
+
|
|
825
|
+To register a service worker, and improve the app’s score, uncomment the following block in `index.html`.
|
|
826
|
+
|
|
827
|
+```html
|
|
828
|
+<!-- un-comment this code to enable service worker
|
|
829
|
+<script>
|
|
830
|
+ if ('serviceWorker' in navigator) {
|
|
831
|
+ navigator.serviceWorker.register('service-worker.js')
|
|
832
|
+ .then(() => console.log('service worker installed'))
|
|
833
|
+ .catch(err => console.log('Error', err));
|
|
834
|
+ }
|
|
835
|
+</script>-->
|
|
836
|
+```
|
|
837
|
+
|
|
838
|
+After making this change, the score should improve. In my tests, it increased to 69/100.
|
|
839
|
+
|
|
840
|
+If you refresh the app and Chrome doesn’t prompt you to install the app (a PWA feature), you probably need to turn on a couple of features.
|
|
841
|
+
|
|
842
|
+```
|
|
843
|
+chrome://flags/#bypass-app-banner-engagement-checks
|
|
844
|
+chrome://flags/#enable-add-to-shelf
|
|
845
|
+```
|
|
846
|
+
|
|
847
|
+After enabling these flags, you’ll see an error in your browser’s console about `assets/imgs/logo.png` not being found. This files is referenced in `src/manifest.json`. You can fix this by copying a 512x512 PNG into this location or by modifying `manifest.json` accordingly.
|
|
848
|
+
|
|
849
|
+## Deploy to a Mobile Device
|
|
850
|
+
|
|
851
|
+To see how your application will look on different devices you can run `ionic serve --lab`. The `--lab` flag opens opens a page in your browser that lets you see how your app looks on different devices.
|
|
852
|
+
|
|
853
|
+### iOS
|
|
854
|
+
|
|
855
|
+To emulate or deploy to an iOS device, you’ll need a Mac and a fresh installation of [Xcode](https://developer.apple.com/xcode/). If you’d like to build iOS apps on Windows, Ionic offers an [Ionic Package](http://ionic.io/cloud#packaging) service.
|
|
856
|
+
|
|
857
|
+Make sure to open Xcode to complete the installation.
|
|
858
|
+
|
|
859
|
+```
|
|
860
|
+ionic platform add ios
|
|
861
|
+```
|
|
862
|
+
|
|
863
|
+You’ll need to run `ionic emulate ios` to open your app in Simulator.
|
|
864
|
+
|
|
865
|
+The biggest problem I found when running the app in Simulator was that it was difficult to get the keyboard to popup. To workaround this, I used Edit > Hardware > Keyboard > Toggle Software Keyboard when I needed to type text in a field.
|
|
866
|
+
|
|
867
|
+To deploy the app to an iPhone, start by plugging your iOS device into your computer. Then run the following commands to install ios-deploy/ios-sim, build the app, and run it on your device.
|
|
868
|
+
|
|
869
|
+```
|
|
870
|
+npm install -g ios-deploy ios-sim
|
|
871
|
+ionic build ios --prod
|
|
872
|
+cd platforms/ios/
|
|
873
|
+open ionic-auth.xcodeproj
|
|
874
|
+```
|
|
875
|
+
|
|
876
|
+Select your phone as the target in Xcode and click the play button to run your app. The first time you do this, Xcode may spin for a while with a “Processing symbol files” message at the top.
|
|
877
|
+
|
|
878
|
+See Ionic’s [deploying documentation](https://ionicframework.com/docs/v2/setup/deploying/) for information on code signing and trusting the app’s certificate.
|
|
879
|
+
|
|
880
|
+Once you’re configured your phone, computer, and Apple ID to work, you should be able to open the app and see login, register, and forgot password screens.
|
|
881
|
+
|
|
882
|
+### Android
|
|
883
|
+
|
|
884
|
+To emulate or deploy to an Android device, you’ll first need to install [Android Studio](https://developer.android.com/studio/index.html). As part of the install, it will show you where it installed the Android SDK. Set this path as an ANDROID_HOME environment variable. On a Mac, it should be `~/Library/Android/sdk/`.
|
|
885
|
+
|
|
886
|
+To deploy to the Android emulator, add support for it to the ionic-auth project using the `ionic` command.
|
|
887
|
+
|
|
888
|
+```
|
|
889
|
+ionic platform add android
|
|
890
|
+```
|
|
891
|
+
|
|
892
|
+If you run `ionic emulate android` you’ll get instructions from about how to create an emulator image.
|
|
893
|
+
|
|
894
|
+```
|
|
895
|
+Error: No emulator images (avds) found.
|
|
896
|
+1. Download desired System Image by running: /Users/mraible/Library/Android/sdk/tools/android sdk
|
|
897
|
+2. Create an AVD by running: /Users/mraible/Library/Android/sdk/tools/android avd
|
|
898
|
+HINT: For a faster emulator, use an Intel System Image and install the HAXM device driver
|
|
899
|
+```
|
|
900
|
+
|
|
901
|
+I ran the first suggestion and downloaded my desired system image. Then I ran the second command and created an AVD with the following settings:
|
|
902
|
+
|
|
903
|
+```
|
|
904
|
+AVD Name: TestPhone
|
|
905
|
+Device: Nexus 5
|
|
906
|
+Target: Android 7.1.1
|
|
907
|
+CPU/ABI: Google APIs Intel Axom (x86_64)
|
|
908
|
+Skin: Skin with dynamic hardware controls
|
|
909
|
+```
|
|
910
|
+
|
|
911
|
+After performing these steps, I was able to run `ionic emulate android` and see my app running in the AVD.
|