😇

auto_route + riverpod + FirebaseAuthentication

2024/06/08に公開

対象者

  • riverpodを使ったことがある
  • auto_routeを使ったことがある
  • FirebaseAuthenticationを使ったことがある

多分中級者向けの技術記事と思います😅
知ってるの前提で書くのでわからない人はご遠慮ください。

画面遷移のパッケージのauto_routeで、Firebaseログインを実装した記事がない(−_−;)
最近、auto_routeが気になって使っていたので、使い方間違っているかもだけど記事を書いてみることにしました。

完成品あるので参考にしてみてください

プロジェクトの説明

Flutterの新規プロジェクトとFirebaseのプロジェクトを作成しておいてください。

  1. 開発に必要な開発環境の準備
  2. packageのインストール

🔥Firebase

flutter pub add firebase_core
flutter pub add firebase_auth

📦add flutter_riverpod

flutter pub add \
flutter_riverpod \
riverpod_annotation \
dev:riverpod_generator \
dev:build_runner \
dev:custom_lint \
dev:riverpod_lint

🚪add auto_route

flutter pub add auto_route
dart pub global activate auto_route_generator

コマンドでパッケージを追加できないときは、手動で配置しましょう😅

pubsepc.yaml
name: auto_route_auth_app
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev

# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1

environment:
  sdk: '>=3.4.1 <4.0.0'

# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
  flutter:
    sdk: flutter


  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.6
  firebase_core: ^3.0.0
  firebase_auth: ^5.0.0
  flutter_riverpod: ^2.5.1
  riverpod_annotation: ^2.3.5
  auto_route: ^8.1.3

dev_dependencies:
  flutter_test:
    sdk: flutter

  # The "flutter_lints" package below contains a set of recommended lints to
  # encourage good coding practices. The lint set provided by the package is
  # activated in the `analysis_options.yaml` file located at the root of your
  # package. See that file for information about deactivating specific lint
  # rules and activating additional ones.
  flutter_lints: ^3.0.0
  auto_route_generator: ^8.0.0
  riverpod_generator: ^2.4.0
  build_runner: ^2.4.11
  custom_lint: ^0.6.4
  riverpod_lint: ^2.3.10

# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

# The following section is specific to Flutter packages.
flutter:

  # The following line ensures that the Material Icons font is
  # included with your application, so that you can use the icons in
  # the material Icons class.
  uses-material-design: true

  # To add assets to your application, add an assets section, like this:
  # assets:
  #   - images/a_dot_burr.jpeg
  #   - images/a_dot_ham.jpeg

  # An image asset can refer to one or more resolution-specific "variants", see
  # https://flutter.dev/assets-and-images/#resolution-aware

  # For details regarding adding assets from package dependencies, see
  # https://flutter.dev/assets-and-images/#from-packages

  # To add custom fonts to your application, add a fonts section here,
  # in this "flutter" section. Each entry in this list should have a
  # "family" key with the font family name, and a "fonts" key with a
  # list giving the asset and other descriptors for the font. For
  # example:
  # fonts:
  #   - family: Schyler
  #     fonts:
  #       - asset: fonts/Schyler-Regular.ttf
  #       - asset: fonts/Schyler-Italic.ttf
  #         style: italic
  #   - family: Trajan Pro
  #     fonts:
  #       - asset: fonts/TrajanPro.ttf
  #       - asset: fonts/TrajanPro_Bold.ttf
  #         weight: 700
  #
  # For details regarding fonts from package dependencies,
  # see https://flutter.dev/custom-fonts/#from-packages

今回は動くものを作るだけなので、設計は無視です。アレンジしてみてください。

FirebaseAuthProviderを作成

  1. FirebaseAuthをインスタンス化したプロバイダーを作成
import 'package:firebase_auth/firebase_auth.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'auth.g.dart';

// flutter pub run build_runner watch --delete-conflicting-outputs

// FirebaseAuthのプロバイダー

FirebaseAuth firebaseAuth(FirebaseAuthRef ref) {
  return FirebaseAuth.instance;
}
  1. StreamNotifierを使用して、認証状態の監視と、ref.listenで認証が通っていれば画面遷移するロジックを実行するのに使う。
import 'package:auto_route_auth_app/provider/auth.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'auth_notifier.g.dart';

/// [認証状態を監視するプロバイダー]

class StreamAuthNotifier extends _$StreamAuthNotifier {
  
  Stream<User?> build() async* {
    yield* authStateChangeFunction();
  }

  Stream<User?> authStateChangeFunction() async* {
    yield* ref.read(firebaseAuthProvider).authStateChanges();
  }
}

ログインページ・新規追加のページ・ログイン後のページ

ログイン後のページを作成します。このページですけど、サインアウトした後に、手動で画面遷移させないとログアウトとしなかったです?
go_routerみたいにリダイレクトするのを自動でやってくれないみたい?
ログアウトしたか監視してくれないようだ。authStateChangesを使う必要があることを以前勉強会で聞いたことがあったような....

ログイン後のページ

import 'package:auto_route/auto_route.dart';
import 'package:auto_route_auth_app/presentation/router.gr.dart';
import 'package:auto_route_auth_app/provider/auth.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

()
class WelcomePage extends ConsumerWidget {
  const WelcomePage({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(
        automaticallyImplyLeading: false, // AppBarの戻るボタンを非表示にする
        actions: [
          IconButton(
            icon: const Icon(Icons.logout),
            onPressed: () async {
              await ref.read(firebaseAuthProvider).signOut();

              /// [ログアウト後の処理]
              /// signOutしても画面遷移はしないので手動で行う必要がある。
              if (context.mounted) {
                context.router.replace(const SignInRoute());
              }
            },
          ),
        ],
        title: const Text('Welcome to Riverpod!'),
      ),
      body: const Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text('Welcome to Riverpod!'),
          ],
        ),
      ),
    );
  }
}

新規登録ページ

ログインと新規登録のページをConsumerStatefulWidgetにしているのは、ConsumerWidgetで、TextEditingControllerを使ったときに、キーボードを閉じると入力したテキストがリセットされて消えてしまう現象が起きてしまうようで、状態を保持するために使わざるを得ませんでした💦
普段わたしが使っているflutter_hooksを使えば、状態を保持してくれるようで、Statelessな感じにはできそう。でもStatefulWidgetを嫌う人いるんですよね😅
私も好きではないです。しかしUI側で状態管理するとなると、必要な場面が出てきますね。

なので、buildメソッドの中には、TextEditingControllerは書かないほうが良い。

import 'package:auto_route/auto_route.dart';
import 'package:auto_route_auth_app/presentation/router.gr.dart';
import 'package:auto_route_auth_app/provider/auth.dart';
import 'package:auto_route_auth_app/provider/auth_notifier.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

()
class SignUpPage extends ConsumerStatefulWidget {
  const SignUpPage({super.key});

  
  ConsumerState createState() => _SignUpPageState();
}

class _SignUpPageState extends ConsumerState<SignUpPage> {
  final email = TextEditingController();
  final password = TextEditingController();

  
  void dispose() {
    email.dispose();
    password.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    ref.listen(streamAuthNotifierProvider, (previous, next) {
      if (next.asData?.value != null) {
        context.router.replace(const WelcomeRoute());
      } else {
        //  ユーザーが新規作成されなかったら処理を終了する。
        return;
      }
    });

    return Scaffold(
      appBar: AppBar(
        title: const Text('Sign Up'),
      ),
      body: SingleChildScrollView(
        child: SizedBox(
          width: MediaQuery.of(context).size.width,
          child: Column(
            children: [
              const SizedBox(height: 50),
              TextFormField(
                controller: email,
                decoration: const InputDecoration(
                  labelText: 'Email',
                ),
              ),
              const SizedBox(height: 20),
              TextFormField(
                obscureText: true,
                controller: password,
                decoration: const InputDecoration(
                  labelText: 'Password',
                ),
              ),
              const SizedBox(height: 20),
              ElevatedButton(
                onPressed: () async {
                  try {
                    await ref
                        .read(firebaseAuthProvider)
                        .createUserWithEmailAndPassword(
                            email: email.text, password: password.text);
                  } catch (e) {
                    if (context.mounted) {
                      ScaffoldMessenger.of(context).showSnackBar(
                        SnackBar(
                          content: Text(e.toString()),
                        ),
                      );
                    }
                  }
                },
                child: const Text('Sign Up'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

ユーザーが新規作成されたら、ref.listenを使って、ログイン後のページへ画面遷移します。新規登録に失敗すると、何も起きません。ちなみデバッグしてて気づいたが、入力フォームをタップすると、 buildメソッドが実行されるのか、ref.listenが実行される。ずっと実行されてないと、リダイレクトみたいなことできないですけどね😅

go_routerだと、 riverpodと組み合わせれば、リダイレクトの処理で、「いい感じ」で、「画面遷移のコード書かなくても」勝手に画面遷移してくれる。スタックだって削除されている。

auto_routeだと困ったことにページによっては、スタックが削除されていないので、AppBarにバックボタンが表示されてた😱
「なのでログイン後のページでは非表示になるように設定しました笑」
仕方がない...

これがログインのページ

go_routerだとリダイレクトの処理で、ログインしているか、いないかで、画面が切り替わるけど、auto_routeにはその機能はないようだ...
FirebaseAuthのauthStateChangesを使う必要がある。riverpod使えば、refメソッドを使って、ログインしていなければ、「こいつログインしてないからはじく!」って機能を使うことができるAuthGuardクラスにある分岐処理に、認証状態を保持しているプロバイダーを渡すことができます。

import 'package:auto_route/auto_route.dart';
import 'package:auto_route_auth_app/presentation/router.gr.dart';
import 'package:auto_route_auth_app/provider/auth.dart';
import 'package:auto_route_auth_app/provider/auth_notifier.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

()
class SignInPage extends ConsumerStatefulWidget {
  const SignInPage({super.key});

  
  ConsumerState createState() => _SignInPageState();
}

class _SignInPageState extends ConsumerState<SignInPage> {
  final email = TextEditingController();
  final password = TextEditingController();

  
  void dispose() {
    email.dispose();
    password.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    ref.listen(streamAuthNotifierProvider, (previous, next) {
      if (next.asData?.value != null) {
        context.router.replace(const WelcomeRoute());
      } else {
        context.router.replace(const SignInRoute());
      }
    });

    return Scaffold(
      appBar: AppBar(
        title: const Text('Sign In'),
      ),
      body: SingleChildScrollView(
        child: SizedBox(
          width: MediaQuery.of(context).size.width,
          child: Column(
            children: [
              const SizedBox(height: 50),
              TextFormField(
                controller: email,
                decoration: const InputDecoration(
                  labelText: 'Email',
                ),
              ),
              const SizedBox(height: 20),
              TextFormField(
                obscureText: true,
                controller: password,
                decoration: const InputDecoration(
                  labelText: 'Password',
                ),
              ),
              const SizedBox(height: 20),
              ElevatedButton(
                onPressed: () async {
                  try {
                    await ref
                        .read(firebaseAuthProvider)
                        .signInWithEmailAndPassword(
                            email: email.text, password: password.text);
                  } catch (e) {
                    if (context.mounted) {
                      ScaffoldMessenger.of(context).showSnackBar(
                        SnackBar(
                          content: Text(e.toString()),
                        ),
                      );
                    }
                  }
                },
                child: const Text('Sign In'),
              ),
              TextButton(
                  onPressed: () {
                    context.router.push(const SignUpRoute());
                  },
                  child: const Text('Sign Up')),
            ],
          ),
        ),
      ),
    );
  }
}

AutoRouteの設定をする

AuthGuardクラスの設定をしましよう。このファイルで、認証がされていなければ、ログインをさせない処理を実行することができます。go_routerとは違って、常に動いているわけではない。

import 'package:auto_route/auto_route.dart';
import 'package:auto_route_auth_app/presentation/router.gr.dart';
import 'package:auto_route_auth_app/provider/auth_notifier.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'auth_guard.g.dart';

(keepAlive: true)
AuthGuard authGuard(AuthGuardRef ref) {
  return AuthGuard(ref: ref);
}

class AuthGuard extends AutoRouteGuard {
  Ref ref;
  AuthGuard({required this.ref});
  
  void onNavigation(NavigationResolver resolver, StackRouter router) {
    // resolver.next()が呼び出されるまで、ナビゲーションは一時停止されます。
    // ナビゲーションを再開/継続するにはtrueを、ナビゲーションを中止するにはfalseを返します。
    // authenticatedは、FirebaseAuthの現在のユーザーがnullでない場合にtrueになります。

    // Providerを使用して認証状態を取得します。
    final isAuth = ref.read(streamAuthNotifierProvider).asData?.value != null;
    if (isAuth) {
      // ユーザーが認証された場合、続行する
      resolver.next(true);
    } else {
      // ユーザーをログインページにリダイレクトする
      // tip: resolver.redirectを使用して、リダイレクトされたルート
      // リゾルバが完了したときにスタックから自動的に削除されます。
      resolver.redirect(const SignInRoute());
    }
  }
}

build_runnerのコマンドを実行すると、ルートのパスを自動生成してくれます。go_router_builderみたいですね。普通のgo_routerにはない機能ですね。

flutter pub run build_runner watch --delete-conflicting-outputs

List<AutoRoute> get routes なの中のコードは、自動生成された後に、自分でインポートしないといけませんので、やってください。

AutoRoute(page: SignInRoute.page, initial: true)が、最初に表示されるページ。
AutoRoute(page: WelcomeRoute.page, guards: [AuthGuard(ref: ref)])が、ログインしていないと、画面遷移できないページ。

import 'package:auto_route/auto_route.dart';
import 'package:auto_route_auth_app/presentation/auth_guard.dart';
import 'package:auto_route_auth_app/presentation/router.gr.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'router.g.dart';

(keepAlive: true)
AppRouter appRouter(AppRouterRef ref) {
  return AppRouter(ref: ref);
}

// flutter pub run build_runner watch --delete-conflicting-outputs

(replaceInRouteName: 'Page,Route')
class AppRouter extends $AppRouter {
  final Ref ref;
  AppRouter({required this.ref});

  
  List<AutoRoute> get routes => [
        AutoRoute(page: SignInRoute.page, initial: true),
        AutoRoute(page: SignUpRoute.page),
        // ガードを使用して認証されたユーザーのみがアクセスできるページ
        AutoRoute(page: WelcomeRoute.page, guards: [AuthGuard(ref: ref)]),
      ];
}

android error!

minsdkを最近は、23に設定するようだ。Androidでビルドするときにハマった😅

─ Flutter Fix ─────────────────────────────────────────────────────────────────────────────────┐
│ The plugin firebase_auth requires a higher Android SDK version.                               │
│ Fix this issue by adding the following to the file                                            │
│ /Users/MY_PJ/flutter_new/auto_route_auth_app/android/app/build.gradle:                        │
│ android {                                                                                     │
│   defaultConfig {                                                                             │
│     minSdkVersion 23                                                                          │
│   }                                                                                           │
│ }                                                                                             │
│         

修正する。

defaultConfig {
        // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
        applicationId = "com.example.auto_route_auth_app"
        // You can update the following values to match your application needs.
        // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
        minSdk = 23
        targetSdk = flutter.targetSdkVersion
        versionCode = flutterVersionCode.toInteger()
        versionName = flutterVersionName
    }

感想

今回は、auto_route + riverpod + FirebaseAuthenticationを使用して、ログイン機能を持ったデモアプリを作ってみました。調べてもどこにも情報がないから、詰まりましたね😅
公式ドキュメントが充実してるから、情報がないとか聞いたことあるが、このパッケージを使ってる会社の人でも「使い方のってないですね💦」というやりとりを私はしたので、「書いてーねーよハゲ🧑‍🦲」と言いたい😇

公式サイト以前見たときに、情報が古くないかと思った😅
海外の動画やpub.devの解説を読んで、試行錯誤してやっと動くものを作れました😭

Discussion