瀏覽代碼

Add demo script and polishing

Matt Raible 7 年之前
父節點
當前提交
d1c61ffe94
共有 2 個檔案被更改,包括 914 行新增12 行删除
  1. 911
    0
      DEMO.md
  2. 3
    12
      TUTORIAL.md

+ 911
- 0
DEMO.md 查看文件

@@ -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">&nbsp;&nbsp;
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.

+ 3
- 12
TUTORIAL.md 查看文件

@@ -172,8 +172,6 @@ ionic serve
172 172
 
173 173
 This will open your default browser on [http://localhost:8100](http://localhost:8100). You can click through the tabbed interface to see the default structure of the app.
174 174
 
175
-Thanks to the [recent release of Stormpath's Client API](https://stormpath.com/blog/client-api-authentication-mobile-frontend), you can now authenticate directly without needing to hit your server with a Stormpath SDK integration installed. This article shows you how to do just that in an Ionic application.
176
-
177 175
 ## Upgrade to Angular 2.3
178 176
 
179 177
 With Angular versions less than 2.3, you can’t extend components and override their templates. The Ionic pages for Stormpath module uses component extension to override the templates in its pages. Because of this, you have to upgrade your project to use Angular 2.3. The only downside to use Angular 2.3 with Ionic 2.0.0 is that you won’t be able to use the `--prod` build flag when compiling. This is because its compiler does not support Angular 2.3.
@@ -203,7 +201,7 @@ Install [Ionic pages for Stormpath](https://github.com/stormpath/stormpath-sdk-a
203 201
 yarn add angular-stormpath-ionic
204 202
 ```
205 203
 
206
-Modify `src/app/app.module.ts` to define a `stormpathConfig` function. This function is used to configure the `endpointPrefix` to point to your Spring Boot API. Import `StormpathModule`, `StormpathIonicModule`, and override the instance of `StormpathConfiguration`. You’ll also need to append Stormpath's pre-built Ionic pages to `entryComponents`.
204
+Modify `src/app/app.module.ts` to define a `stormpathConfig` function. This function is used to configure the `endpointPrefix` to point to your Spring Boot API. Import `StormpathModule`, `StormpathIonicModule`, and override the provider of `StormpathConfiguration`. You’ll also need to append Stormpath's pre-built Ionic pages to `entryComponents`.
207 205
 
208 206
 ```typescript
209 207
 import { StormpathConfiguration, StormpathModule } from 'angular-stormpath';
@@ -275,7 +273,7 @@ export class MyApp {
275 273
 If you run `ionic serve`, you’ll likely see something similar to the following error in your browser’s console.
276 274
 
277 275
 ```
278
-XMLHttpRequest cannot load https://raible.apps.stormpath.io/me. Response to preflight request
276
+XMLHttpRequest cannot load http://localhost:8080/me. Response to preflight request
279 277
 doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on
280 278
 the requested resource. Origin 'http://localhost:8100 is therefore not allowed access.
281 279
 The response had HTTP status code 403.
@@ -339,7 +337,7 @@ If you’re logged in, you should see a screen with a logout button and the name
339 337
 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.
340 338
 
341 339
 ```xml
342
-<preference name="KeyboardDisplayRequiresUserAction" value="false" />
340
+<preference name="KeyboardDisplayRequiresUserAction" value="false"/>
343 341
 ```
344 342
 
345 343
 Check your changes into Git.
@@ -723,13 +721,6 @@ save(beer: any): Observable<any> {
723 721
 }
724 722
 ```
725 723
 
726
-This won't work because `/beers` is not an auto-authorized URI. To fix this, add this URI as to the StormpathConfiguration in `src/app/app.module.ts`.
727
-
728
-```typescript
729
-spConfig.autoAuthorizedUris.push(new RegExp('http://localhost:8080/good-beers'));
730
-spConfig.autoAuthorizedUris.push(new RegExp('http://localhost:8080/beers'));
731
-```
732
-
733 724
 ### Add Swipe to Delete
734 725
 
735 726
 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>`.