[Flutter] Firebase + BLoC + go_router で認証 + リダイレクトを実装する

# はじめに🔰

**Navigator2.0**に対応したルーティングパッケージで[routermaster](https://pub.dev/packages/routemaster) を使用していたのですが、
[go_router](https://pub.dev/packages/go_router) が公式パッケージになったこともあり、乗り換えてみました。

Firebase + BLoC + go_router での**認証機能** および それに伴う**ルーティング**の一例として、
0から動くところまでを書いていきます。



## 開発環境

- Windows



## 使用パッケージ

- [flutter_bloc](https://pub.dev/packages/flutter_bloc) : 8.0.1
- [go_router](https://pub.dev/packages/go_router) : 3.1.0

https://pub.dev/packages/flutter_bloc/install

https://pub.dev/packages/go_router/install



# 実装

## Flutterアプリの作成

任意のフォルダで`flutter create`コマンドを叩いてFlutterアプリを作成します。

```console
$ flutter create sample_auth
```

見慣れた構成のアプリが生成されます
<img width="200" alt="created.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/510027/8c34d476-beb0-2cad-d997-512d1b7c73ef.png">



## Firebaseの認証設定

### FirebaseCLI & FlutterFireCLI のインストール

認証に使用するFirebaseの設定を行います。

まずは以下のコマンドを叩いて[FirebaseCLI](https://firebase.google.com/docs/cli?hl=ja#setup_update_cli)をインストールします。

```console
$ npm install -g firebase-tools
```



インストールが完了したら、以下のコマンドを叩いて**Firebaseにログイン**します。

```console
$ firebase login
~~~
Already logged in as example@gmail.com
```

:::note info
上記のようにユーザー名(メールアドレス)が表示されたら**認証完了**
:::



最後に**FlutterFireCLIをインストール**します。
任意のフォルダで以下のコマンドを実行して下さい。

```console
$ dart pub global activate flutterfire_cli
```



### Firebaseプロジェクトの設定

CLIのインストールが完了したので、プロジェクトの設定を行っていきます。
作成した`sample_auth`プロジェクト配下に移動し、`flutterfire configure`を実行します。

```console
$ cd sample_auth
sample_auth$ flutterfire configure
```

ターミナル上で以下の通り設定しながら進めていきます。



#### Firebaseプロジェクトの選択

既存のFirebaseプロジェクトを使用するか、新規のプロジェクトを生成するかを選択します。
今回は**新規のプロジェクトを作成**するため、`<create a new project>`を選択します。

```console
? Select a Firebase project to configure your Flutter application with ›
  hoge-example (hoge-example)
❯ <create a new project>
```



#### プロジェクト名の入力

新規で作成するプロジェクトの名称を設定します。
<u>※ 一意なIDが必要なため、既に存在するIDの場合エラーが発生して再実施が必要になります。</u>

```console
? Enter a project id for your new Firebase project (e.g. my-cool-project) › sample-auth-20220527
```



#### プラットフォーム選択

対応するプラットフォームの選択を行います。
今回はサンプルのため、全てを選択した状態のまま進めます。

```console
? Which platforms should your configuration support (use arrow keys & space to select)? ›
✔ android
✔ ios
✔ macos
✔ web
```



全ての設定が完了すると`lib`配下に設定ファイルが作成されます。

<img width="250" alt="generated.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/510027/e1842961-e153-76af-7111-1c79fc0b0e61.png">

:::note warn
パッケージを追加していないので、現時点では**エラーになっていて問題ありません**。
:::


### 認証プロバイダの設定

続いてFirebaseのコンソール画面から認証プロバイダを設定していきます。

[Firebaseのコンソール画面](https://console.firebase.google.com/u/1/?hl=ja)を開き、作成したプロジェクトを選択します。

<img width="250" alt="firebase_project.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/510027/2b1c3135-5215-8a88-e981-feb888270320.png">





左メニューの **[構築] - [Authentication]** を選択します。

<img width="200" alt="firebase_sidemenu.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/510027/3f04a6f2-1ab8-5ac2-312b-b48579678b2e.png">



「始める」を選択するとログインプロバイダの選択に移ります。

今回は簡単に実装が可能な**匿名**を選択して、有効にします。

<img width="600" alt="firebase_auth.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/510027/cc208cf1-ea15-3922-fcc8-db52300f4572.png">




## Flutterアプリの実装

ようやくFlutter側の実装になります。
この後いくつかのファイルに分けて実装を行っていきますが、フォルダ構成は以下のように作っています。

<img width="250" alt="folder.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/510027/bf32778a-6092-bafc-5a2f-79ae876c5467.png">


### Firebase 初期処理

まずは今回使用するパッケージを`pubspec.yml`に定義します。

```yaml:pubspec.yml
dependencies:
  flutter:
    sdk: flutter

  # ★ここから追加-------------
  # Routing
  go_router: ^3.1.0

  # BLoC
  equatable: ^2.0.3
  bloc: ^8.0.3
  flutter_bloc: ^8.0.1

  # Firebase
  firebase_core: ^1.17.1
  firebase_auth: ^3.3.19
```



先ほど自動生成された`firebase_options.dart`のエラーが解消されるため、
設定ファイルを使って初期化処理を呼び出していきます。

`main.dart`で`runApp()`の前にFirebaseの初期処理を実装します。

```dart:main.dart
void main() async {
  // Firebase Initialize
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  runApp(const App());
}
```



エラーになってしまうので、`app.dart`の中身を仮実装します。

```dart:app.dart
class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    // TODO: 後から実装
    return MaterialApp();
  }
}
```



### 認証機能の実装

**Firebase + BLoC**で認証を実装していきます。
まずは認証に関連する **「イベント」「状態」を定義**していきます。

- イベント

```dart:auth_event.dart
abstract class AuthEvent extends Equatable {
  const AuthEvent();

  @override
  List<Object> get props => [];
}

/// アプリ起動
class AppStarted extends AuthEvent {}

/// 認証情報の変更検知
class AuthenticationUserChanged extends AuthEvent {}
```

- 認証状態

```dart:auth_state.dart
abstract class AuthState extends Equatable {
  const AuthState();

  @override
  List<Object> get props => [];
}

/// 未初期化
class UnInitialized extends AuthState {}

/// 未認証
class NotAuth extends AuthState {}

/// 認証済
class Authenticated extends AuthState {}
```

- イベント時の処理 (仮定義)

```dart:auth_bloc.dart
class AuthBloc extends Bloc<AuthEvent, AuthState> {
  // 初期状態は「初期化されていない状態」
  AuthBloc() : super(UnInitialized()) {
    on<AppStarted>(_appStarted);
    on<AuthenticationUserChanged>(_onAuthenticationUserChanged);
  }

  _appStarted(AppStarted event, Emitter<AuthState> emit) async {
    // TODO アプリ開始時の処理
  }

  _onAuthenticationUserChanged(
      AuthenticationUserChanged event, Emitter<AuthState> emit) async {
    // TODO 認証情報の変更検知時の処理
  }
}
```



Firebaseの認証機能を利用するため、`FirebaseAuth`のインスタンスを定義します。

```dart:auth_bloc.dart
final FirebaseAuth _auth;

AuthBloc(FirebaseAuth? auth)
    : _auth = auth ?? FirebaseAuth.instance,
      // 初期状態は「初期化されていない状態」
      super(UnInitialized())
```



#### イベント別の処理実装

イベント毎に処理を実装していきます。

##### アプリ起動時 (AppStarted)

ユーザーの認証状態の変更を監視します。
変更を検知した際は`AuthenticationoUserChanged`イベントを発火させ、取得した認証情報を渡します。

```dart:auth_bloc.dart
  _appStarted(AppStarted event, Emitter<AuthState> emit) async {
    // 認証状態変更の監視
    _repository.user.listen((user) => add(AuthenticationUserChanged(user)));
  }
```



あわせて、変更検知イベント側にも認証情報のプロパティを追加します。

```dart:auth_event.dart
  const AuthenticationUserChanged(this.user);

  @override
  List<Object> get props => [user ?? ""];

  final User? user;
```



##### 認証情報の変更検知時 (AuthenticationUserChanged)

認証情報を基に **「未認証」か「認証済」** の状態を判定します。

```dart:auth_bloc.dart
  _onAuthenticationUserChanged(
      AuthenticationUserChanged event, Emitter<AuthState> emit) async {
    final user = event.user;
    if (user == null) {
      emit(NotAuth());
    } else {
      emit(Authenticated());
    }
  }
```



これで認証状態を管理するBLoCが完成しました。



#### アプリへの組み込み

作成した認証機能を組み込みます。

アプリ起動時と同時に検知するため、`app.dart`の先頭でBLoCを定義します。

```dart:app.dart
  @override
  Widget build(BuildContext context) {
    return BlocProvider<AuthBloc>(
      create: (_) => AuthBloc()..add(AppStarted()),
      child: Builder(
        // TODO 後から実装
        builder: (context) => MaterialApp(),
      ),
    );
  }
```

:::note warn
**サインインやサインアウト**の処理は認証状態とは直接関係がないので、
各画面の製造時に別BLoCとして追加します。
:::



### ルーティングの実装

今回は以下の3種類の画面で作成します。

- スプラッシュ画面：起動中に表示
- 認証画面：未認証状態でアプリを起動した際に表示
- ホーム画面：認証済状態でアプリを起動した際に表示



#### 画面の実装

まずはそれぞれのページへのパスやタイトルを管理しやすいように`enum`で定義します

```dart:app_pages.dart
enum AppPages {
  signin,
  home,
  splash,
}

extension AppPageExtension on AppPages {
  String get toPath {
    switch (this) {
      case AppPages.signin:
        return "/signin";
      case AppPages.home:
        return "/";
      case AppPages.splash:
        return "/splash";
      default:
        return "/";
    }
  }

  String get toTitle {
    switch (this) {
      case AppPages.signin:
        return "Sign In";
      case AppPages.home:
        return "Home";
      case AppPages.splash:
        return "Splash";
      default:
        return "Home";
    }
  }
}
```



定義した各画面を実装します。

認証画面では匿名ログインボタンを用意します。

`SignInBloc`については、認証周りの実装とは関係がないので割愛します。
詳細は[Github](https://github.com/yakitama5/sample_auth)をご参照下さい。

```dart:sing_in_page.dart
class SignInPage extends StatelessWidget {
  const SignInPage({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocListener<SignInBloc, SignInState>(
      listener: (context, state) {
        if (state is SignInStateProgress) {
          ProgressDialog.show(context);
        } else {
          ProgressDialog.hide(context);
        }
      },
      child: Scaffold(
        appBar: AppBar(title: Text(AppPages.signin.toTitle)),
        body: Center(
          child: ElevatedButton(
              // 認証イベントを呼び出す
              onPressed: () => BlocProvider.of<SignInBloc>(context)
                  .add(SignInWithAnonymous()),
              child: const Text("匿名サインイン")),
        ),
      ),
    );
  }
}

```



他画面についても同様に作成して下さい。(割愛)



#### 静的なルーティング定義

まずはシンプルな各画面のルーティング定義を行います。
それぞれ遷移用のパスとページを定義するだけです。

```dart:app_router.dart
class AppRouter {
  GoRouter get goRouter => _goRouter;

  AppRouter();

  late final GoRouter _goRouter = GoRouter(
    initialLocation: AppPages.home.toPath,
    routes: <GoRoute>[
      GoRoute(
        path: AppPages.home.toPath,
        builder: (context, state) => const HomePage(),
      ),
      GoRoute(
        path: AppPages.signin.toPath,
        builder: (context, state) => const SignInPage(),
      ),
      GoRoute(
        path: AppPages.splash.toPath,
        builder: (context, state) => const SplashPage(),
      ),
    ],
  );
}
```



#### 認証状態によるリダイレクト定義

先ほど実装した`AuthBloc`をルーティング処理にも組み込みます。
まずは`AppRouter`に認証状態を持たせます。

```dart:app_router.dart
  final AuthBloc _authBloc;
  AppRouter(AuthBloc bloc) : _authBloc = bloc;
```



認証状態の変更を**GoRouter側でも検知**出来るように、`refreshListenable`にStreamを渡します。

```dart:app_router.dart
GoRouter(
refreshListenable: GoRouterRefreshStream(_authBloc.stream),
...
)
```



認証状態に応じた画面のリダイレクト処理を`GoRouter`上に定義していきます。

```dart:app_router.dart
GoRouter(
...
redirect: (state) {
    // 認証ステータスを判定
    final authState = _authBloc.state;

    if (authState is UnInitialized) {
    // TODO 初期化されていない状態のリダイレクト処理
    }

    if (authState is NotAuth) {
    // TODO 未認証の際のリダイレクト処理
    }

    if (authState is Authenticated) {
    // TODO 認証済の際のリダイレクト処理
    }
    
    return null;
)
```



後は**遷移不可能なケース**毎に記載していくだけです。
今回のケースでは下記に沿ってリダイレクトを定義していきます。

> - スプラッシュ画面：起動中に表示
> - 認証画面：未認証状態でアプリを起動した際に表示
> - ホーム画面：認証済状態でアプリを起動した際に表示

```dart:app_router.dart
if (authState is UnInitialized &&
    state.location != AppPages.splash.toPath) {
  // 初期化されていない かつ スプラッシュ画面でない場合
  return AppPages.splash.toPath;
}

if (authState is NotAuth && state.location != AppPages.signin.toPath) {
  // 未認証 かつ 認証画面でない場合
  return AppPages.signin.toPath;
}

if (authState is Authenticated &&
    state.location != AppPages.home.toPath) {
  // 認証済 かつ ホーム画面でない場合
  return AppPages.home.toPath;
}
```



#### アプリへの組み込み

定義したルーティングをアプリに組み込みます。

`MaterialApp`の定義箇所に今回定義したルーティングを指定します。

```dart:app.dart
Builder(
    builder: (context) {
      final authBloc = BlocProvider.of<AuthBloc>(context);
      final appRouter = AppRouter(authBloc);

      return MaterialApp.router(
        routeInformationParser: appRouter.goRouter.routeInformationParser,
        routerDelegate: appRouter.goRouter.routerDelegate,
      );
    },
  )
```



これで認証状態に応じたルーティングが行われます。





## Demo

<img width="200" alt="demo.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/510027/10ba999e-3574-3bd9-a2ff-2d7d73b7b18e.gif">




# さいごに

他にいいやり方があったり、アンチパターンを使用している場合はご指摘頂きたいです！

乗り換えてあまり経っていませんが、`go_router`は非常に使い勝手がいい印象です。

まだ`NavigationBar`を使った遷移周りで課題は残っているそうですが、
公式パッケージになったこともあり今後のサポートに期待です。

状態管理に`Provider`や`riverpod`を利用した場合のルーティングに関しては、
参考サイトに載せていますので、ぜひ参考にして下さい。

※ 当記事で作成した全ソースは[Github](https://github.com/yakitama5/sample_auth)に公開しています。

https://github.com/yakitama5/sample_auth

## 参考サイト

- [Firebase Auth + Flutter Web + go_router + riverpod で認証周りの画面遷移やってみる](https://zenn.dev/kingu/scraps/5216f812efc4e7)
- [Firebase + go_router + Provider](https://blog.ishangavidusha.com/flutter-authentication-flow-with-go-router-and-provider)