[Flutter x Firebase] カウンターアプリに認証機能を追加する

# やること
* FlutterとFirebaseの連携方法や、使い方などを学ぶ。今回はFirebase Authentication。
* あくまでFirebaseの学習がメインなので、デフォルトで作成されるカウンターアプリ部分にはなるべく手を加えない。
* iOSのみ
* Googleアカウントでのサインインのみ(他のは少し手間がかかりそうだったので)

## 環境
* Flutter 1.22
* VSCode

# 成果物
## 動作イメージ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/1d263b88-3cda-1b84-1438-c46b63eb5275.png)

## ソースコード
https://github.com/popy1017/firebase_counter_app/tree/v0.1

# 下調べ
## Firebaseで何ができるか
こちらの記事を参考にさせていただきました。
[Firebaseの各機能を3行で説明する - Qiita](https://qiita.com/shibukk/items/4a015c5b3296563ac19d)

気になったやつ↓

|サービス|概要|
|:--|:--|
|Authentication|認証|
|Cloud Firestore※|リアルタイムNoSQL|
|Storage|ファイルを保存|
|Hosting|ホスティング|
|Crashlytics|クラッシュレポート|
|Performance|パフォーマンス分析|
|Cloud Messaging|ターゲットを絞って通知|
|In-App Messaging|ポップアップ、バナー？|
|Predictions|ユーザーによるイベントを用いた予測？|
|Remote Config|アプリ内のパラメータを変更|

※ アンケートみたいなのに答えると、`Cloud Firestore`と`Realtime Database`のどちらが推奨されるかを表示してくれるが、だいたい`Cloud Firestore`をおすすめされる。

今回は、`Authentication`を使って ログインが必要なカウンターアプリ（！？） を作る。

# 準備
詳細は割愛。注意するところのみ記載。

## 0. カウンターアプリを作る
```shell
flutter create special_counter_app
```
以上。

## 1. Firebaseのプロジェクトを作る
Firebaseコンソールにログインして、「プロジェクトの作成」から新規プロジェクトを作成する。

以上。

## 2. Flutter アプリにFirebaseを追加する
参考ページ → [Flutter アプリに Firebase を追加する](https://firebase.google.com/docs/flutter/setup?hl=ja)
Flutterを使ったことがあれば、Step1は終わっているはず。
上でStep2（Firebaseプロジェクトの作成）もやっているので、Step3からスタート。

FirebaseコンソールのプロジェクトページにあるiOSボタン(以下参照)を押して、指示に従う。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/e56ebb39-7177-9bc3-3de5-5001776ebe99.png)

### アプリの登録
Bundle IDが必要なので、XCodeで設定・確認しておく必要がある。

### 設定ファイルのダウンロード
`GoogleService-Info.plist`をダウンロードして、`.gitignore`に追加して、`ios/Runner`配下に配備する。

```yaml:.gitignore
# Firebase related
ios/Runner/GoogleService-Info.plist
```
### Firebase SDKの追加
`ios/`配下で`pod init`を実行し、以下を追記。
追記後、`pod install`を実行。

```yaml:Podfile
# add the Firebase pod for Google Analytics
pod 'Firebase/Analytics'
# add pods for any other desired Firebase products
# https://firebase.google.com/docs/ios/setup#available-pods
```
### 初期化コードの追加
`AppDelegate.swift`に 以下の3行を追加

```swift:AppDelegate.swift
import UIKit
import Flutter
import Firebase // <- 追加
import FirebaseCore // <- 追加

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    FirebaseApp.configure()  // <- 追加
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}
```
>公式サイトの説明では`import FirebaseCore`は含まれていなかったが、`FirebaseApp`は`FirebaseCore`で定義されているようなので必須。
[ios - Cannot import Firebase in Swift app - Stack Overflow](https://stackoverflow.com/questions/34468636/cannot-import-firebase-in-swift-app)


### アプリを実行してインストールを確認
#### エラー対処
上記の手順を実行して、いざVSCodeで実行すると なんのエラーも出ずにアプリが落ちてしまう現象が起こった。
XCodeで実行してみると、以下のようなエラーが出ていたことが判明。

```
libc++abi.dylib: terminating with uncaught exception of type NSException
*** Terminating app due to uncaught exception 'com.firebase.core', reason: '`[FIRApp configure];`
 (`FirebaseApp.configure()` in Swift) could not find a valid GoogleService-Info.plist 
in your project. Please download one from https://console.firebase.google.com/.'
terminating with uncaught exception of type NSException
```

`GoogleService-Info.plist`がないよ と言われているが、上の手順で追加したはず。。。
調べてみると、XCodeのプロジェクトに認識されていないようで、**XCode上で追加する必要があった。**

[プロジェクトで有効なGoogleService-Info.plistが見つかりませんでした](https://www.366service.com/jp/qa/937ee4ab1775317839d984fc315c899f)

XCodeの「File」メニューから「Add Files to "Runner" ...」を選択して、`GoogleService-Info.plist`を追加する。

#### 確認中が終わらない
原因は掴めなかったが、`flutter clean`や実行を繰り返していたら 完了になった。

以上で、FlutterアプリとFirebaseの連携は完了。

# Authentication を使う
まず、以下のような画面フローにするため、ログイン画面を先に作る。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/73382202-31b6-c804-9fad-f8b450cbae1e.png)

## ログイン画面を作る
今回は、AppleIDやGoogleアカウントなどを使ってログインできる仕様にするため、それぞれのサインインボタンを追加する。
パッケージ [sign_button](https://pub.dev/packages/sign_button) を使うと、あらかじめデザインされたサインインボタンを簡単に作ることができる。
`lib/pages/login_form.dart`を作成し、以下のようにした。
それっぽく？するために、`Padding`や`Card`を使っているが、中身は`Column`で`SignInButton`を並べているだけ。

```dart:login_form.dart
import 'package:flutter/material.dart';
import 'package:sign_button/sign_button.dart';

class LoginForm extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('ログイン'),
      ),
      body: Center(
        child: Card(
          child: Padding(
            padding: const EdgeInsets.symmetric(
              vertical: 100,
              horizontal: 50,
            ),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                SignInButton(
                  buttonType: ButtonType.apple,
                  onPressed: () {
                    print('click');
                  },
                ),
                SignInButton(
                  buttonType: ButtonType.google,
                  onPressed: () {
                    print('click');
                  },
                ),
                SignInButton(
                  buttonType: ButtonType.twitter,
                  onPressed: () {
                    print('click');
                  },
                ),
                SignInButton(
                  buttonType: ButtonType.github,
                  onPressed: () {
                    print('click');
                  },
                ),
                SignInButton(
                  buttonType: ButtonType.yahoo,
                  onPressed: () {
                    print('click');
                  },
                )
              ],
            ),
          ),
        ),
      ),
    );
  }
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/7b85cfb2-c694-7ac8-2de2-6a7ff07c4f68.png" width=300 />

## ログイン処理を書いていく
今回実装する Google、Appleなどのアカウントを使ったログイン方法`ソーシャルログイン`のやり方はこちら → [Social Authentication | FlutterFire](https://firebase.flutter.dev/docs/auth/social)

### 準備:パッケージのインストール

```yaml:package.yaml
~~
dependencies:
  flutter:
    sdk: flutter
  
  # ソーシャルログインボタンのデザインセット
  sign_button: ^1.0.0

  # Firebase related
  firebase_core: ^0.5.0+1
  firebase_auth: ^0.18.1+2
  google_sign_in: ^4.5.5

  # 最前面にローディング(くるくる)を簡単に出すパッケージ
  flutter_easyloading: ^2.0.0

  # 状態管理で使用
  provider: ^4.3.2+2
~~
```

### 実装時のポイント
* ログイン状態の管理
* ログインの実装(Google)
* ログアウトの実装
* ロード中(くるくる)の表示

#### ログイン状態の管理
今回は`ログイン画面`(未ログイン状態)と`カウンター画面`(ログイン状態)で状態を共有する必要があるので、`Provider`パターンを使って状態を共有する。

##### 状態管理クラスの作成
まず `lib/models/auth_model.dart`を作って 状態を保持するクラスとして`ChangeNotifier`を継承したクラス`AuthModel`を以下のようにした。

```dart:auth_model.dart
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:google_sign_in/google_sign_in.dart';

class AuthModel extends ChangeNotifier {
  User _user;
  User get user => _user;

  // ログイン処理
  Future<void> login() async {}  

  // ログアウト処理
  Future<void> logout() async {} 
}
```
##### 状態管理クラスをProvideする
このままでは ログイン画面やカウンター画面は`AuthModel`のことを知らないので、`ChangeNotifierProvider`を使って知らせてあげる必要がある。
今回はそれぞれ入れ子になっていない2つの画面に渡す必要があるので、`main.dart`の`MaterialApp`を`ChangeNotifierProvider`で囲む必要がある。

```dart:main.dart
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider( // <== 追加
      create: (context) => AuthModel(), // <== 追加
      child: MaterialApp(
        title: 'Flutter Demo',
        theme: ThemeData(
          primarySwatch: Colors.blue,
          visualDensity: VisualDensity.adaptivePlatformDensity,
        ),
        home: LoginForm(),
      ),
    );
  }
}
```
##### 状態管理クラスの関数を呼ぶ
ログイン画面とカウンター画面は`AuthModel`を認識することができたので、`context.read<AuthModel>().login()`などでログイン・ログアウト関数を呼び出すことができる。

```dart:login_form.dart
class LoginForm extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('ログイン'),
      ),
      body: _buildSocialLogin(context),
    );
  }

  Center _buildSocialLogin(BuildContext context) {
    return Center(
      child: Card(
        child: Padding(
          padding: const EdgeInsets.symmetric(
            vertical: 100,
            horizontal: 50,
          ),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              ~~~
              SignInButton(
                buttonType: ButtonType.google,
                onPressed: () async {
                  await context.read<AuthModel>().login(); // <== ログイン
                },
              ),
              ~~~
            ],
          ),
        ),
      ),
    );
  }
}
```
#### ログイン・ログアウトの実装
[Social Authentication | FlutterFire](https://firebase.flutter.dev/docs/auth/social)を参考に、以下のように実装。
以下のコードでは`Google`アカウントでしかサインインできないので、他の種類のアカウントでログインしたい場合は`login()`関数内で種類に応じて振り分ける必要がある。

```dart:auth_model.dart
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:google_sign_in/google_sign_in.dart';

class AuthModel extends ChangeNotifier {
  User _user;

  final FirebaseAuth _auth = FirebaseAuth.instance;

  User get user => _user;

  Future<bool> login() async {
    try {
      UserCredential _userCredential = await _signInWithGoogle();
      _user = _userCredential.user;
      notifyListeners();
      return true;
    } catch (error) {
      print(error);
      return false;
    }
  }

  Future<void> logout() async {
    _user = null;
    await _auth.signOut();
    notifyListeners();
  }

  Future<UserCredential> _signInWithGoogle() async {
    // Trigger the authentication flow
    final GoogleSignInAccount googleUser = await GoogleSignIn().signIn();

    // Obtain the auth details from the request
    final GoogleSignInAuthentication googleAuth =
        await googleUser.authentication;

    // Create a new credential
    final GoogleAuthCredential credential = GoogleAuthProvider.credential(
      accessToken: googleAuth.accessToken,
      idToken: googleAuth.idToken,
    );

    // Once signed in, return the UserCredential
    return await FirebaseAuth.instance.signInWithCredential(credential);
  }
}
```

#### ロード中(くるくる)の表示
ログイン処理中に何も表示されないのも味気ないので、ローディングインジケーターを表示する。

こちらのパッケージを使う → [firebase_auth | Flutter Package](https://pub.dev/packages/flutter_easyloading)

##### 準備
使い方にも書いてあるが、常に最前面に出すために`MaterialApp`の`builder`に`FlutterEasyLoading()`を仕込む必要がある。

```dart:main.dart
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) => AuthModel(),
      child: MaterialApp(
        title: 'Flutter Demo',
        theme: ThemeData(
          primarySwatch: Colors.blue,
          visualDensity: VisualDensity.adaptivePlatformDensity,
        ),
        home: LoginForm(),
        /* 追加 */
        builder: (BuildContext context, Widget child) {
          /// make sure that loading can be displayed in front of all other widgets
          return FlutterEasyLoading(child: child);
        },
      ),
    );
  }
}
```

##### 使い方
`EasyLoading.show(status: 文字列)`でローディングを表示
`EasyLoading.dismiss()`でローディングを閉じる。
`AuthModel`のログイン処理を以下のようにラップすれば ログイン処理中にローディングが表示されるようになる。


```dart:login_form.dart
  Future<void> _login(BuildContext context) async {
    EasyLoading.show(status: 'loading...');
    if (await context.read<AuthModel>().login()) {
      Navigator.pushAndRemoveUntil(
        context,
        MaterialPageRoute(builder: (_) => MyHomePage(title: "カウンター")),
        (_) => false,
      );
    }
    EasyLoading.dismiss();
  }
```

## (おまけ)カウンター画面にアカウント情報を表示する

```dart:main.dart
class _MyHomePageState extends State<MyHomePage> {
  
  ~~

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      ~~
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            _buildAccountInfo(),
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      ~~
    );
  }
  
  Widget _buildAccountInfo() {
    final User _user = context.select((AuthModel _auth) => _auth.user);

    // ログアウト直後に _user を null にしており、_user.photoURLでエラーが出るため分岐させている
    return _user != null
        ? Card(
            child: ListTile(
              // 丸いアバターを表示
              leading: CircleAvatar(
                backgroundImage: NetworkImage(_user.photoURL ?? ''),
              ),
              title: Text(_user.displayName),
              subtitle: Text(_user.email),
            ),
          )
        : Container(); 
  }
  ~~
}
```

## エラー記録
### module 'firebase_auth not found"
`Podfile`, `Podfile.lock`を削除して、`flutter clean`。
その後、`flutter run`でOK。
参考: [xcode - module 'firebase_auth not found" - Stack Overflow](https://stackoverflow.com/questions/61204454/module-firebase-auth-not-found)

### No Firebase App '[DEFAULT]' has been created - call Firebase.initializeApp()
Firebaseの機能を使う前に`initializeApp`を読んでくださいねっていうエラー。
以下の記事でどこで呼ぶのが良さそうかを解説してくれている。
参考: [FlutterでFirebaseを使うときのFirebase.initializeApp()の呼び方 - Qiita](https://qiita.com/edasan/items/f32bc58a4afd0ca92432)

* `FutureBuilder`
* `StatefulWidget`の`initState`
* `runApp`の前 ← 今回はこれを採用

```dart:main.dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized(); // <- 追加
  await Firebase.initializeApp(); // <- 追加
  runApp(MyApp());
}
```