🔥

FirebaseAuth AuthExceptionを考える

2025/01/30に公開

Enumを使ってバリデーションを作ってみた

2022年。あまり情報が無い頃は、Firebase Authのエラーハンドリングはネットの情報を参考に作っていた。今回もやったようなものですが、コードアンドレアを参考にしたのでだいぶマシになった💦

こちらのアンドレアさんが作ったFlutterの学習ができるアプリで学びました。他にも役立つ情報が多数あります。
https://codewithandrea.com/

昔書いたスクラップ

ボタンの処理の中に直接書いたり関数の中にswitchで分岐する処理を書いていたりした。
https://zenn.dev/joo_hashi/scraps/0cb55e2a98a163

今回は直和構造なるものになるのだろうか。Enumでデータ型を定義してメールやパスワードを間違えるとエラーを出すデータ型を定義してみた。

こちらが完成品。Firebase CLIのコード削除してるのでエラーでている箇所があります。ファイルはこれを用意する。

こんな感じのものを作りました

https://youtu.be/Ay20kvnFzhc

今回使うのがこちら。引数付きのEnumを使うとカッコよく書けた。

enum AuthException {
  invalidEmail('メールアドレスの形式が正しくありません'),
  weakPassword('パスワードは6文字以上で入力してください'),
  emailAlreadyInUse('メールアドレスがすでに使用されています'),
  userNotFound('メールアドレスまたはパスワードが正しくありません'),
  invalidCredentials('メールアドレスまたはパスワードが正しくありません');

  final String message;
  const AuthException(this.message);
}

カプセル化したクラスを定義する。getはゲッターと呼ばれている値を参照することができるメソッドで特徴といえば、引数がないメソッドでありプライベートなクラスにアクセスして値を取得することができます。

import 'package:firebase_auth/firebase_auth.dart';
import 'auth_exceptoin.dart';

class AuthService {
  // Singleton instance
  static final AuthService _instance = AuthService._internal();

  // Private constructor
  AuthService._internal();

  // Getter for the singleton instance
  static AuthService get instance => _instance;

  // Firebase Auth instance
  final FirebaseAuth _auth = FirebaseAuth.instance;

  // Current user getter
  User? get currentUser => _auth.currentUser;

  // Sign Up method
  Future<User?> signUp({
    required String email,
    required String password,
  }) async {
    try {
      // Validate email format (basic check)
      if (!_isValidEmail(email)) {
        throw AuthException.invalidEmail;
      }

      // Validate password length
      if (password.length < 6) {
        throw AuthException.weakPassword;
      }

      // Attempt to create user
      UserCredential userCredential =
          await _auth.createUserWithEmailAndPassword(
        email: email,
        password: password,
      );

      return userCredential.user;
    } on FirebaseAuthException catch (e) {
      if (e.code == 'email-already-in-use') {
        throw AuthException.emailAlreadyInUse;
      }
      rethrow;
    }
  }

  // Sign In method
  Future<User?> signIn({
    required String email,
    required String password,
  }) async {
    try {
      // Validate email format (basic check)
      if (!_isValidEmail(email)) {
        throw AuthException.invalidEmail;
      }

      // Attempt to sign in
      UserCredential userCredential = await _auth.signInWithEmailAndPassword(
        email: email,
        password: password,
      );

      return userCredential.user;
    } on FirebaseAuthException catch (e) {
      if (e.code == 'user-not-found' || e.code == 'wrong-password') {
        throw AuthException.invalidCredentials;
      }
      rethrow;
    }
  }

  // Sign Out method
  Future<void> signOut() async {
    await _auth.signOut();
  }

  // Basic email validation
  bool _isValidEmail(String email) {
    final emailRegExp = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
    return emailRegExp.hasMatch(email);
  }
}

エラーを表示するスナックバーはView側に持たせたいのでロジックから切り離します。これは好みですけど私は、mixinを作って認証の画面を作るクラスで多重継承させています。

import 'package:flutter/material.dart';

mixin SnackbarWidget {
  void showSuccessSnackBar(BuildContext context, String message) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
        backgroundColor: Colors.green,
      ),
    );
  }

  void showErrorSnackBar(BuildContext context, String message) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
        backgroundColor: Colors.red,
      ),
    );
  }
}

今回はデモ用ということで、新規登録とログインのページは一緒にしてます。

import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_auth_demo/auth/auth_service.dart';
import 'package:firebase_auth_demo/auth/authj_exceptoin.dart';
import 'package:firebase_auth_demo/widget/snackbar_widget.dart';
import 'package:flutter/material.dart';

class AuthScreen extends StatefulWidget {
  const AuthScreen({super.key});

  
  State<AuthScreen> createState() => _AuthScreenState();
}

class _AuthScreenState extends State<AuthScreen> with SnackbarWidget {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  bool _isLogin = true;
  bool _isLoading = false;

  
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  Future<void> _submitForm() async {
    if (!_formKey.currentState!.validate()) return;

    // Prevent multiple submissions
    if (_isLoading) return;

    setState(() {
      _isLoading = true;
    });

    try {
      User? user;
      if (_isLogin) {
        user = await AuthService.instance.signIn(
          email: _emailController.text.trim(),
          password: _passwordController.text.trim(),
        );
      } else {
        user = await AuthService.instance.signUp(
          email: _emailController.text.trim(),
          password: _passwordController.text.trim(),
        );
      }

      // Check mounted before using context
      if (!mounted) return;

      // Show success message
      showSuccessSnackBar(context, _isLogin ? 'ログインに成功しました' : '新規登録に成功しました');

      // Navigate to WelcomeScreen
      if (user != null) {
        Navigator.of(context).pushReplacement(
          MaterialPageRoute(builder: (_) => const WelcomeScreen()),
        );
      }
    } on AuthjExceptoin catch (e) {
      // Check mounted before using context
      if (!mounted) return;

      showErrorSnackBar(context, e.message);
    } catch (e) {
      // Check mounted before using context
      if (!mounted) return;

      showErrorSnackBar(context, '予期せぬエラーが発生しました');
    } finally {
      // Always reset loading state
      if (mounted) {
        setState(() {
          _isLoading = false;
        });
      }
    }
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(_isLogin ? 'ログイン' : '新規登録'),
      ),
      body: Center(
        child: SingleChildScrollView(
          padding: const EdgeInsets.all(16),
          child: Form(
            key: _formKey,
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                TextFormField(
                  controller: _emailController,
                  decoration: const InputDecoration(
                    labelText: 'メールアドレス',
                    border: OutlineInputBorder(),
                  ),
                  validator: (value) {
                    if (value == null || value.isEmpty) {
                      return 'メールアドレスを入力してください';
                    }
                    return null;
                  },
                ),
                const SizedBox(height: 16),
                TextFormField(
                  controller: _passwordController,
                  obscureText: true,
                  decoration: const InputDecoration(
                    labelText: 'パスワード',
                    border: OutlineInputBorder(),
                  ),
                  validator: (value) {
                    if (value == null || value.isEmpty) {
                      return 'パスワードを入力してください';
                    }
                    return null;
                  },
                ),
                const SizedBox(height: 16),
                ElevatedButton(
                  onPressed: _isLoading ? null : _submitForm,
                  child: _isLoading
                      ? const CircularProgressIndicator()
                      : Text(_isLogin ? 'ログイン' : '新規登録'),
                ),
                TextButton(
                  onPressed: _isLoading
                      ? null
                      : () {
                          setState(() {
                            _isLogin = !_isLogin;
                          });
                        },
                  child: Text(_isLogin ? '新規登録はこちら' : 'ログインはこちら'),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class WelcomeScreen extends StatelessWidget with SnackbarWidget {
  const WelcomeScreen({super.key});

  
  Widget build(BuildContext context) {
    final user = AuthService.instance.currentUser;

    return Scaffold(
      appBar: AppBar(
        title: const Text('ようこそ'),
        actions: [
          IconButton(
            icon: const Icon(Icons.logout),
            onPressed: () async {
              await AuthService.instance.signOut();

              // Use Navigator.of(context) safely
              if (context.mounted) {
                Navigator.of(context).pushReplacement(
                  MaterialPageRoute(builder: (_) => const AuthScreen()),
                );
              }
            },
          ),
        ],
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('ログイン中: ${user?.email ?? 'ユーザー'}'),
            const SizedBox(height: 16),
            const Text('Firebase認証デモへようこそ!'),
          ],
        ),
      ),
    );
  }
}

main.dartでモジュールをインポートして、Firebaseを使用できるように設定すれば使えます。

import 'package:firebase_auth_demo/auth/auth_screen.dart';
import 'package:firebase_auth_demo/firebase_options.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const AuthScreen(),
    );
  }
}

まとめ

最近はできるだけライブラリを使わないようにする現場で働いているせいかオブジェクト思考だけで頑張って書いてみました(^^;;

  • 💊カプセル化されたクラス
  • 🔑プライベートな変数
  • 🔑値を外から参照するゲッター

モダンな新規開発をする現場だとライブラリはたくさん使うのであまり使わない現場があるとすれば、保守することを考えて頼りすぎないようにするのが目的なんでしょうね。メンテナンスコストを上げたくはない。RiverpodやGoRouterのアップデート対応も結構大変ですからね(^_^;)

Discussion