🦍

GoRouterRedirectを使う

2023/03/03に公開

認証機能とリダイレクト処理を導入する

go_routerのリダイレクト処理を使用して、認証状態によって、画面遷移をする機能とログイン機能の維持をする機能を作っているアプリに導入しようとしたのですが、参考になりそうな資料がなくて困りました😇
たまたま、Githubを検索するといい感じのコードを見つけたので、改造してデモアプリを作ってみました!
匿名認証にメールアドレスとパスワード認証を追加しただけです。

go_routerのリダイレクトについて
https://pub.dev/documentation/go_router/latest/topics/Redirection-topic.html
リダイレクションの話題
リダイレクトは、アプリケーションの状態に応じて、場所を変更するものです。例えば、ユーザーがログインしていない場合にサインイン画面を表示するために、リダイレクトを使用することができます。

リダイレクトは、GoRouterRedirect型のコールバックです。アプリケーションの状態に応じて受信位置を変更するには、GoRouter または GoRoute コンストラクタにコールバックを追加してください。


今回は、ディレクトリ構造をクリーンアーキテクチャーぽくしてみました。この構造には、決まりはないようで、重要なのは、自分が見てどんな構造で、どんな仕組みのロジックが存在しているのかが分かれば問題ないそうです。
ディレクトリ構造

lib
├── applications
│   ├── auth_provider.dart
│   └── router.dart
├── firebase_options.dart
├── infrastructure
│   ├── anonymous_class.dart
│   ├── auth_controller_provider.dart
│   ├── signin_class.dart
│   ├── signout_class.dart
│   └── signup_class.dart
├── main.dart
└── presentation
    └── pages
        ├── home_page.dart
        ├── login_page.dart
        └── splash_page.dart

こちらが完成品
https://github.com/sakurakotubaki/GoRouterRedirectApplication

こんな感じで動作します
https://youtu.be/KZPrmsQ33xg

RiverpodのStreamProviderを使用したログイン状態を監視するプロバイダー
元々あった英語のコメントを翻訳して付け加えています。

applications/auth_provider.dart
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// firebaseの場合、シンプルなプロバイダを書くことが多いです。
//
// 通常はストリームベースのリダイレクトで十分すぎるほどです。
//
// Auth関連のロジックのほとんどはSDKで処理される
final authProvider = StreamProvider<User?>((ref) {
  return FirebaseAuth.instance.authStateChanges();
});

/// FirebaseAuthを外部ファイルで使うためのプロバイダー.
final firebaseAuthProvider =
    Provider<FirebaseAuth>((ref) => FirebaseAuth.instance);

// カスタムロジックやノーティファイアを追加することも可能です。
//
// 個人的には複雑にしすぎるのは好きではないのですが、限界はありますよね!?

ユーザーのログイン状態によってルートの画面遷移を変更するロジックを書いたコード

applications/router.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:go_router_auth/applications/auth_provider.dart';
import 'package:go_router_auth/presentation/pages/home_page.dart';
import 'package:go_router_auth/presentation/pages/login_page.dart';
import 'package:go_router_auth/presentation/pages/splash_page.dart';

final _key = GlobalKey<NavigatorState>();

final routerProvider = Provider<GoRouter>((ref) {
  final authState = ref.watch(authProvider);

  return GoRouter(
    navigatorKey: _key,
    debugLogDiagnostics: true,
    initialLocation: SplashPage.routeLocation,
    routes: [
      GoRoute(
        path: SplashPage.routeLocation,
        name: SplashPage.routeName,
        builder: (context, state) {
          return const SplashPage();
        },
      ),
      GoRoute(
        path: HomePage.routeLocation,
        name: HomePage.routeName,
        builder: (context, state) {
          return const HomePage();
        },
      ),
      GoRoute(
        path: LoginPage.routeLocation,
        name: LoginPage.routeName,
        builder: (context, state) {
          return const LoginPage();
        },
      ),
    ],
    redirect: (context, state) {
      // 非同期状態がロード中であれば、リダイレクトは行わない。
      if (authState.isLoading || authState.hasError) return null;

      // ここでは、hasData==trueであること、すなわち、読み取り可能な値であることを保証する。

      // これはFirebaseAuth SDKが「ログイン」状態をどのように処理するかに関係する
      // `null`を返すと、"権限がない "という意味になる。
      final isAuth = authState.valueOrNull != null;

      final isSplash = state.location == SplashPage.routeLocation;
      if (isSplash) {
        return isAuth ? HomePage.routeLocation : LoginPage.routeLocation;
      }

      final isLoggingIn = state.location == LoginPage.routeLocation;
      if (isLoggingIn) return isAuth ? HomePage.routeLocation : null;

      return isAuth ? null : SplashPage.routeLocation;
    },
  );
});

認証機能のロジックを書いたサービスクラス。アプリで使用するときは、StateProviderを使用して、呼び出します。

匿名ログインをするロジック

infrastructure/anonymous_class.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router_auth/applications/auth_provider.dart';

final anonymousClassProvider = StateProvider<AnonymousClass>((ref) => AnonymousClass(ref));

class AnonymousClass {

  Ref ref;
  AnonymousClass(this.ref);

  Future<void> signInAnonymous () async {
    await ref.read(firebaseAuthProvider).signInAnonymously();
  }
}

ログアウトをするロジック

infrastructure/signout_class.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router_auth/applications/auth_provider.dart';

final signOutClass = StateProvider<SignOutClass>((ref) => SignOutClass(ref));

class SignOutClass {
  Ref ref;
  SignOutClass(this.ref);

  Future<void> signOut() async {
    await ref.read(firebaseAuthProvider).signOut();
  }
}

新規登録をするロジック
FirebaseAuthの持っている機能を使用して、エラーメッセージをスナックバーで表示できるようにしています。

infrastructure/signup_class.dart
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../applications/auth_provider.dart';

final signUpClassProvider =
    StateProvider<SignUpClass>((ref) => SignUpClass(ref));

class SignUpClass {
  Ref ref;
  SignUpClass(this.ref);

  Future<void> signUpUser(
      String _email, String _password, BuildContext context) async {
    try {
      final newUser = await ref
          .read(firebaseAuthProvider)
          .createUserWithEmailAndPassword(email: _email, password: _password);
    } on FirebaseAuthException catch (e) {
      if (e.code == 'invalid-email') {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('メールアドレスのフォーマットが正しくありません'),
          ),
        );
        print('メールアドレスのフォーマットが正しくありません');
      } else if (e.code == 'user-disabled') {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('現在指定したメールアドレスは使用できません'),
          ),
        );
        print('現在指定したメールアドレスは使用できません');
      } else if (e.code == 'user-not-found') {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('指定したメールアドレスは登録されていません'),
          ),
        );
        print('指定したメールアドレスは登録されていません');
      } else if (e.code == 'wrong-password') {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('パスワードが間違っています'),
          ),
        );
        print('パスワードが間違っています');
      }
    }
  }
}

ログインをするロジック

infrastructuresignin_class.dart
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../applications/auth_provider.dart';

final signInClassProvider =
    StateProvider<SignInClass>((ref) => SignInClass(ref));

class SignInClass {
  Ref ref;
  SignInClass(this.ref);

  Future<void> signInUser(
      String _email, String _password, BuildContext context) async {
    try {
      final newUser = await ref
          .read(firebaseAuthProvider)
          .signInWithEmailAndPassword(email: _email, password: _password);
    } on FirebaseAuthException catch (e) {
      if (e.code == 'invalid-email') {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('メールアドレスのフォーマットが正しくありません'),
          ),
        );
        print('メールアドレスのフォーマットが正しくありません');
      } else if (e.code == 'user-disabled') {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('現在指定したメールアドレスは使用できません'),
          ),
        );
        print('現在指定したメールアドレスは使用できません');
      } else if (e.code == 'user-not-found') {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('指定したメールアドレスは登録されていません'),
          ),
        );
        print('指定したメールアドレスは登録されていません');
      } else if (e.code == 'wrong-password') {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('パスワードが間違っています'),
          ),
        );
        print('パスワードが間違っています');
      }
    }
  }
}

認証機能用のTextEditingController
RiverpodのStateProviderを使用して、外部ファイルで使用できるようにします。処理が終わったら状態を破棄するために、autoDisposeを追加しています。

infrastructure/auth_controller_provider.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

/// メールアドレスのテキストを保存するProvider
final emailProvider = StateProvider.autoDispose((ref) {
  return TextEditingController(text: '');
});

/// パスワードのテキストを保存するProvider
final passwordProvider = StateProvider.autoDispose((ref) {
  return TextEditingController(text: '');
});

go_routerのルートで表示をするページ
単純な画面しか表示していませんが、go_routerのリダイレクト処理を実装するだけでも難しいので、今回は苦労しました😵

ログイン後のページ

/presentation/pages/home_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router_auth/applications/auth_provider.dart';
import 'package:go_router_auth/infrastructure/signout_class.dart';

class HomePage extends ConsumerWidget {
  const HomePage({super.key});
  static String get routeName => 'home';
  static String get routeLocation => '/';

  
  Widget build(BuildContext context, WidgetRef ref) {
    final name = ref.watch(authProvider.select(
      (value) => value.valueOrNull?.displayName,
    ));

    return Scaffold(
      appBar: AppBar(title: const Text("ログイン状態を維持している")),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            Text("Wellcome, $name. This is your homepage."),
            ElevatedButton(
              onPressed: () async {
                ref.read(signOutClass.notifier).state.signOut();
              },
              child: const Text("Logout"),
            ),
          ],
        ),
      ),
    );
  }
}

スプラッシュ画面を表示するページ

/presentation/pagessplash_page.dart
import 'package:flutter/material.dart';

class SplashPage extends StatelessWidget {
  const SplashPage({super.key});
  static String get routeName => 'splash';
  static String get routeLocation => '/$routeName';

  
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(child: Text("Splash Page")),
    );
  }
}

ログインページ
今回は、ログインと新規登録のページは同じページになっています。リダイレクト処理を学ぶのが目的なので、ページを分けるのは、省略しました🙇‍♂️

presentation/pages/login_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router_auth/infrastructure/anonymous_class.dart';
import 'package:go_router_auth/infrastructure/auth_controller_provider.dart';
import 'package:go_router_auth/infrastructure/signin_class.dart';
import 'package:go_router_auth/infrastructure/signup_class.dart';

class LoginPage extends ConsumerWidget {
  const LoginPage({super.key});
  static String get routeName => 'login';
  static String get routeLocation => '/$routeName';

  
  Widget build(BuildContext context, WidgetRef ref) {
    final _email = ref.watch(emailProvider);
    final _password = ref.watch(passwordProvider);

    return Scaffold(
      appBar: null,
      body: Center(
        child: Container(
          width: 300,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            crossAxisAlignment: CrossAxisAlignment.center,
            children: [
              const Text("Login Page"),
              SizedBox(height: 20),
              TextField(
                controller: _email,
                decoration: InputDecoration(
                    filled: true,
                    fillColor: Colors.grey.shade200,
                    border: OutlineInputBorder(
                      borderRadius: BorderRadius.circular(10),
                      borderSide: BorderSide.none,
                    )),
              ),
              SizedBox(height: 20),
              TextField(
                controller: _password,
                decoration: InputDecoration(
                    filled: true,
                    fillColor: Colors.grey.shade200,
                    border: OutlineInputBorder(
                      borderRadius: BorderRadius.circular(10),
                      borderSide: BorderSide.none,
                    )),
              ),
              SizedBox(height: 20),
              ElevatedButton(
                onPressed: () async {
                  ref
                      .read(signUpClassProvider.notifier)
                      .state
                      .signUpUser(_email.text, _password.text, context);
                },
                child: const Text("新規作成"),
              ),
              SizedBox(height: 20),
              ElevatedButton(
                  onPressed: () async {
                    ref
                        .read(signInClassProvider.notifier)
                        .state
                        .signInUser(_email.text, _password.text, context);
                  },
                  child: const Text("ログイン")),
              SizedBox(height: 20.0),
              ElevatedButton(
                onPressed: () async {
                  ref
                      .read(anonymousClassProvider.notifier)
                      .state
                      .signInAnonymous();
                },
                child: const Text("登録せずに利用"),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

go_routerの設定がされたmain.dart
今回は、Riverpodを使用しているので、go_routerを読み込んでいる場所を監視させて状態の変更見張らせないと、リダイレクトの処理がうまくいかないそうです。
Firebase CLIを使用している人は、firebase_options.dartを新たに作成してインポートさせておいてください。

main.dart
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router_auth/applications/router.dart';
import 'package:go_router_auth/firebase_options.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  runApp(const ProviderScope(child: AppWithFirebase()));
}

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

  
  Widget build(BuildContext context, WidgetRef ref) {
    final router = ref.watch(routerProvider);

    return MaterialApp.router(
      routeInformationParser: router.routeInformationParser,
      routerDelegate: router.routerDelegate,
      routeInformationProvider: router.routeInformationProvider,
      title: 'flutter_riverpod + go_router Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
    );
  }
}

まとめ

たまに、公式ドキュメントを見るように言われるのですが、どこにも使い方が載ってないこともあるので、今回は自分の経験と人のソースコードを参考に自分が使うためということもあるのですが、誰かの役に立てればなと思い、リファレンスアプリを作成しました。
今回は、リダイレクト処理についてざっとですが、学んでみました。

Flutter大学

Discussion